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.
- {slack_objects-0.0.8 → slack_objects-0.1.0}/.gitignore +4 -1
- {slack_objects-0.0.8 → slack_objects-0.1.0}/PKG-INFO +1 -1
- {slack_objects-0.0.8 → slack_objects-0.1.0}/src/slack_objects/_version.py +3 -3
- {slack_objects-0.0.8 → slack_objects-0.1.0}/src/slack_objects/users.py +72 -37
- {slack_objects-0.0.8 → slack_objects-0.1.0}/src/slack_objects.egg-info/PKG-INFO +1 -1
- {slack_objects-0.0.8 → slack_objects-0.1.0}/src/slack_objects.egg-info/SOURCES.txt +1 -2
- {slack_objects-0.0.8 → slack_objects-0.1.0}/tests/SCIM/conftest_live.py +5 -5
- {slack_objects-0.0.8 → slack_objects-0.1.0}/tests/SCIM/run_all_scim_users_live_tests.py +1 -0
- {slack_objects-0.0.8 → slack_objects-0.1.0}/tests/SCIM/test_scim_users_input_validation_live.py +14 -0
- slack_objects-0.1.0/tests/SCIM/test_scim_users_update_email_live.py +160 -0
- {slack_objects-0.0.8 → slack_objects-0.1.0}/tests/Smoke/users_smoke_test.py +10 -0
- {slack_objects-0.0.8 → slack_objects-0.1.0}/tests/UnitTests/users_unit_test.py +139 -2
- {slack_objects-0.0.8 → slack_objects-0.1.0}/tests/Users/conftest_live.py +5 -5
- slack_objects-0.0.8/.vs/VSWorkspaceState.json +0 -8
- slack_objects-0.0.8/.vs/slnx.sqlite +0 -0
- {slack_objects-0.0.8 → slack_objects-0.1.0}/.github/copilot-instructions.md +0 -0
- {slack_objects-0.0.8 → slack_objects-0.1.0}/.github/workflows/ci.yml +0 -0
- {slack_objects-0.0.8 → slack_objects-0.1.0}/.github/workflows/publish-pypi.yml +0 -0
- {slack_objects-0.0.8 → slack_objects-0.1.0}/.github/workflows/publish-testpypi.yml +0 -0
- {slack_objects-0.0.8 → slack_objects-0.1.0}/LICENSE +0 -0
- {slack_objects-0.0.8 → slack_objects-0.1.0}/README.md +0 -0
- {slack_objects-0.0.8 → slack_objects-0.1.0}/pyproject.toml +0 -0
- {slack_objects-0.0.8 → slack_objects-0.1.0}/pytest.ini +0 -0
- {slack_objects-0.0.8 → slack_objects-0.1.0}/setup.cfg +0 -0
- {slack_objects-0.0.8 → slack_objects-0.1.0}/src/slack_objects/__init__.py +0 -0
- {slack_objects-0.0.8 → slack_objects-0.1.0}/src/slack_objects/api_caller.py +0 -0
- {slack_objects-0.0.8 → slack_objects-0.1.0}/src/slack_objects/base.py +0 -0
- {slack_objects-0.0.8 → slack_objects-0.1.0}/src/slack_objects/client.py +0 -0
- {slack_objects-0.0.8 → slack_objects-0.1.0}/src/slack_objects/config.py +0 -0
- {slack_objects-0.0.8 → slack_objects-0.1.0}/src/slack_objects/conversations.py +0 -0
- {slack_objects-0.0.8 → slack_objects-0.1.0}/src/slack_objects/files.py +0 -0
- {slack_objects-0.0.8 → slack_objects-0.1.0}/src/slack_objects/idp_groups.py +0 -0
- {slack_objects-0.0.8 → slack_objects-0.1.0}/src/slack_objects/messages.py +0 -0
- {slack_objects-0.0.8 → slack_objects-0.1.0}/src/slack_objects/rate_limits.py +0 -0
- {slack_objects-0.0.8 → slack_objects-0.1.0}/src/slack_objects/scim_base.py +0 -0
- {slack_objects-0.0.8 → slack_objects-0.1.0}/src/slack_objects/usergroups.py +0 -0
- {slack_objects-0.0.8 → slack_objects-0.1.0}/src/slack_objects/workspaces.py +0 -0
- {slack_objects-0.0.8 → slack_objects-0.1.0}/src/slack_objects.egg-info/dependency_links.txt +0 -0
- {slack_objects-0.0.8 → slack_objects-0.1.0}/src/slack_objects.egg-info/requires.txt +0 -0
- {slack_objects-0.0.8 → slack_objects-0.1.0}/src/slack_objects.egg-info/top_level.txt +0 -0
- {slack_objects-0.0.8 → slack_objects-0.1.0}/tests/Examples/examples_config.example.json +0 -0
- {slack_objects-0.0.8 → slack_objects-0.1.0}/tests/Examples/users_search_by_email_example.py +0 -0
- {slack_objects-0.0.8 → slack_objects-0.1.0}/tests/Examples/users_test_AzureKeyVault.py +0 -0
- {slack_objects-0.0.8 → slack_objects-0.1.0}/tests/SCIM/test_scim_idp_groups_live.py +0 -0
- {slack_objects-0.0.8 → slack_objects-0.1.0}/tests/SCIM/test_scim_users_create_live.py +0 -0
- {slack_objects-0.0.8 → slack_objects-0.1.0}/tests/SCIM/test_scim_users_deactivate_live.py +0 -0
- {slack_objects-0.0.8 → slack_objects-0.1.0}/tests/SCIM/test_scim_users_make_guest_live.py +0 -0
- {slack_objects-0.0.8 → slack_objects-0.1.0}/tests/SCIM/test_scim_users_reactivate_live.py +0 -0
- {slack_objects-0.0.8 → slack_objects-0.1.0}/tests/SCIM/test_scim_users_update_attribute_live.py +0 -0
- {slack_objects-0.0.8 → slack_objects-0.1.0}/tests/Smoke/_smoke_harness.py +0 -0
- {slack_objects-0.0.8 → slack_objects-0.1.0}/tests/Smoke/api_caller_smoke_test.py +0 -0
- {slack_objects-0.0.8 → slack_objects-0.1.0}/tests/Smoke/conversations_smoke_test.py +0 -0
- {slack_objects-0.0.8 → slack_objects-0.1.0}/tests/Smoke/files_smoke_test.py +0 -0
- {slack_objects-0.0.8 → slack_objects-0.1.0}/tests/Smoke/idp_groups_smoke_test.py +0 -0
- {slack_objects-0.0.8 → slack_objects-0.1.0}/tests/Smoke/messages_smoke_test.py +0 -0
- {slack_objects-0.0.8 → slack_objects-0.1.0}/tests/Smoke/run_all_smoke.py +0 -0
- {slack_objects-0.0.8 → slack_objects-0.1.0}/tests/Smoke/security_smoke_test.py +0 -0
- {slack_objects-0.0.8 → slack_objects-0.1.0}/tests/Smoke/usergroups_smoke_test.py +0 -0
- {slack_objects-0.0.8 → slack_objects-0.1.0}/tests/Smoke/workspaces_smoke_test.py +0 -0
- {slack_objects-0.0.8 → slack_objects-0.1.0}/tests/UnitTests/config_unit_test.py +0 -0
- {slack_objects-0.0.8 → slack_objects-0.1.0}/tests/UnitTests/conversations_unit_test.py +0 -0
- {slack_objects-0.0.8 → slack_objects-0.1.0}/tests/UnitTests/files_unit_test.py +0 -0
- {slack_objects-0.0.8 → slack_objects-0.1.0}/tests/UnitTests/idp_groups_unit_test.py +0 -0
- {slack_objects-0.0.8 → slack_objects-0.1.0}/tests/UnitTests/messages_unit_test.py +0 -0
- {slack_objects-0.0.8 → slack_objects-0.1.0}/tests/UnitTests/rate_limits_unit_test.py +0 -0
- {slack_objects-0.0.8 → slack_objects-0.1.0}/tests/UnitTests/usergroups_test.py +0 -0
- {slack_objects-0.0.8 → slack_objects-0.1.0}/tests/UnitTests/workspaces_unit_test.py +0 -0
- {slack_objects-0.0.8 → slack_objects-0.1.0}/tests/Users/run_all_users_live_tests.py +0 -0
- {slack_objects-0.0.8 → slack_objects-0.1.0}/tests/Users/test_users_admin_live.py +0 -0
- {slack_objects-0.0.8 → slack_objects-0.1.0}/tests/Users/test_users_classification_live.py +0 -0
- {slack_objects-0.0.8 → slack_objects-0.1.0}/tests/Users/test_users_input_validation_live.py +0 -0
- {slack_objects-0.0.8 → slack_objects-0.1.0}/tests/Users/test_users_read_live.py +0 -0
- {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
|
|
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
|
|
32
|
-
__version_tuple__ = version_tuple = (0,
|
|
31
|
+
__version__ = version = '0.1.0'
|
|
32
|
+
__version_tuple__ = version_tuple = (0, 1, 0)
|
|
33
33
|
|
|
34
|
-
__commit_id__ = commit_id = '
|
|
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(
|
|
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
|
-
|
|
517
|
-
|
|
518
|
-
|
|
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(
|
|
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
|
-
|
|
532
|
-
|
|
533
|
-
|
|
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.
|
|
547
|
-
|
|
548
|
-
|
|
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
|
|
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-
|
|
11
|
-
User-token-
|
|
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-
|
|
55
|
-
_SECRET_USER_TOKEN = "User-token-
|
|
56
|
-
_SECRET_SCIM_TOKEN = "User-token-
|
|
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)
|
{slack_objects-0.0.8 → slack_objects-0.1.0}/tests/SCIM/test_scim_users_input_validation_live.py
RENAMED
|
@@ -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
|
|
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(
|
|
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-
|
|
11
|
-
User-token-
|
|
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-
|
|
52
|
-
_SECRET_USER_TOKEN = "User-token-
|
|
53
|
-
_SECRET_SCIM_TOKEN = "User-token-
|
|
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
|
# ---------------------------------------------------------------------------
|
|
Binary file
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{slack_objects-0.0.8 → slack_objects-0.1.0}/tests/SCIM/test_scim_users_update_attribute_live.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|