mirror of
https://github.com/Sea-Haven-Industries/afterhours-shift-manager.git
synced 2026-10-07 16:19:00 +00:00
Compare commits
5 commits
9a6c48943e
...
76e51ab8ae
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
76e51ab8ae | ||
|
|
5981776178 | ||
|
|
5a173e911e | ||
|
|
9dd1dfc4c1 | ||
|
|
01c4902985 |
17 changed files with 306 additions and 66 deletions
8
package-lock.json
generated
8
package-lock.json
generated
|
|
@ -8,13 +8,13 @@
|
|||
"name": "afterhours-shift-manager",
|
||||
"version": "1.0.0",
|
||||
"devDependencies": {
|
||||
"@redocly/cli": "2.52.1"
|
||||
"@redocly/cli": "2.53.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@redocly/cli": {
|
||||
"version": "2.52.1",
|
||||
"resolved": "https://registry.npmjs.org/@redocly/cli/-/cli-2.52.1.tgz",
|
||||
"integrity": "sha512-gwfx1WelVDHU/cw+GkpLV9+xksOI3obJGgL1nUVrAFUyXNDxM+aifXDUZk7BfbOIpF69Sea2ant0cOqbQfg/KQ==",
|
||||
"version": "2.53.3",
|
||||
"resolved": "https://registry.npmjs.org/@redocly/cli/-/cli-2.53.3.tgz",
|
||||
"integrity": "sha512-hzNAWzHCOZ05vwRx0ehTxNeJaxxizjGV505eKtfs9MR/8ieD/8lYhAK4GF5FaqRRwOEpLO0PvzQwBSKYXmxhRA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
|
|
|
|||
|
|
@ -6,6 +6,6 @@
|
|||
"openapi:lint": "redocly lint --config .redocly.yaml openapi.yaml"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@redocly/cli": "2.52.1"
|
||||
"@redocly/cli": "2.53.3"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,2 +1,2 @@
|
|||
flask==3.1.3
|
||||
gunicorn==23.0.0
|
||||
gunicorn==26.2.0
|
||||
|
|
|
|||
|
|
@ -1,2 +1,2 @@
|
|||
boto3>=1.43.98
|
||||
boto3>=1.43.99
|
||||
requests>=2.34.2
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
PyJWT[crypto]==2.14.0
|
||||
boto3>=1.43.98
|
||||
boto3>=1.43.99
|
||||
requests>=2.34.2
|
||||
|
|
|
|||
|
|
@ -1,2 +1,2 @@
|
|||
boto3>=1.43.98
|
||||
boto3>=1.43.99
|
||||
requests>=2.34.2
|
||||
|
|
|
|||
|
|
@ -1 +1 @@
|
|||
boto3>=1.43.98
|
||||
boto3>=1.43.99
|
||||
|
|
|
|||
|
|
@ -1,2 +1,2 @@
|
|||
boto3>=1.43.98
|
||||
boto3>=1.43.99
|
||||
requests>=2.34.2
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
boto3>=1.43.98
|
||||
boto3>=1.43.99
|
||||
requests>=2.34.2
|
||||
sentry-sdk==2.69.2
|
||||
|
||||
|
|
|
|||
|
|
@ -86,23 +86,17 @@ def maybe_repoint_today(date_str: str, shift_type: str, extension: str) -> bool:
|
|||
return False
|
||||
|
||||
|
||||
_cached_3cx: ThreeCXClient | None = None
|
||||
|
||||
|
||||
def make_3cx_client() -> ThreeCXClient | None:
|
||||
global _cached_3cx
|
||||
if _cached_3cx is not None:
|
||||
return _cached_3cx
|
||||
"""Return the process OAuth client, refreshing it when the token or secret changed."""
|
||||
secret_prefix = os.environ.get("TCX_SECRET_PREFIX")
|
||||
if not secret_prefix:
|
||||
logger.warning("3CX env vars not set — skipping 3CX call")
|
||||
return None
|
||||
_cached_3cx = oauth_client(
|
||||
return oauth_client(
|
||||
domain=get_secret(f"{secret_prefix}domain"),
|
||||
client_id=get_secret(f"{secret_prefix}client-id"),
|
||||
client_secret=get_secret(f"{secret_prefix}client-secret"),
|
||||
)
|
||||
return _cached_3cx
|
||||
|
||||
|
||||
def set_holiday_queue_agents(schedule, date_str: str) -> None:
|
||||
|
|
|
|||
|
|
@ -1,13 +1,23 @@
|
|||
import logging
|
||||
import threading
|
||||
import time
|
||||
|
||||
import requests
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_oauth_clients: dict[tuple[str, str], "ThreeCXClient"] = {}
|
||||
# Refresh this long before 3CX's expires_in, so a job does not start on a token
|
||||
# that dies mid-call. 3CX client-credentials tokens last 3600 seconds.
|
||||
_TOKEN_SKEW_SECONDS = 60
|
||||
|
||||
|
||||
def oauth_client(domain: str, client_id: str, client_secret: str) -> "ThreeCXClient":
|
||||
"""Reuse one OAuth client per (domain, client_id) in this process."""
|
||||
"""Reuse one OAuth client per (domain, client_id) in this process.
|
||||
|
||||
Re-authenticates when the access token is near expiry, and immediately when
|
||||
``client_secret`` differs from the one the cached client logged in with.
|
||||
"""
|
||||
key = (domain, client_id)
|
||||
client = _oauth_clients.get(key)
|
||||
if client is None:
|
||||
|
|
@ -18,6 +28,8 @@ def oauth_client(domain: str, client_id: str, client_secret: str) -> "ThreeCXCli
|
|||
client_secret=client_secret,
|
||||
)
|
||||
_oauth_clients[key] = client
|
||||
return client
|
||||
client.use_client_secret(client_secret)
|
||||
return client
|
||||
|
||||
|
||||
|
|
@ -32,7 +44,14 @@ class ThreeCXClient:
|
|||
auth_kwargs: credentials — see _authenticate_user / _authenticate_oauth
|
||||
"""
|
||||
self.base_url = f"https://{domain}"
|
||||
self._auth_mode = auth_mode
|
||||
self._client_id = auth_kwargs.get("client_id")
|
||||
self._client_secret = auth_kwargs.get("client_secret")
|
||||
self._token_expires_at = 0.0
|
||||
self._auth_lock = threading.RLock()
|
||||
self.session = requests.Session()
|
||||
self._raw_request = self.session.request
|
||||
self.session.request = self._request
|
||||
self.session.headers.update(
|
||||
{
|
||||
"OData-Version": "4.0",
|
||||
|
|
@ -41,12 +60,66 @@ class ThreeCXClient:
|
|||
)
|
||||
|
||||
if auth_mode == "oauth":
|
||||
self._authenticate_oauth(
|
||||
auth_kwargs["client_id"], auth_kwargs["client_secret"]
|
||||
)
|
||||
self._authenticate_oauth(self._client_id, self._client_secret)
|
||||
else:
|
||||
self._authenticate_user(auth_kwargs["username"], auth_kwargs["password"])
|
||||
|
||||
def use_client_secret(self, client_secret: str) -> None:
|
||||
"""Point this client at ``client_secret`` and log in again if needed.
|
||||
|
||||
A different secret is logged in with before it replaces the current one.
|
||||
A candidate that 3CX rejects leaves the working secret in place, so a
|
||||
slower caller still holding a revoked secret cannot clobber a good one,
|
||||
and a later revert to a secret 3CX accepts still takes effect.
|
||||
"""
|
||||
with self._auth_lock:
|
||||
if client_secret == self._client_secret:
|
||||
self._refresh_expired_token()
|
||||
return
|
||||
try:
|
||||
self._authenticate_oauth(self._client_id, client_secret)
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"3CX login with a new client secret failed; keeping the current secret"
|
||||
)
|
||||
self._refresh_expired_token()
|
||||
return
|
||||
self._client_secret = client_secret
|
||||
|
||||
def ensure_fresh_token(self) -> None:
|
||||
"""Fetch a new access token when the current one is missing or near expiry."""
|
||||
if self._auth_mode != "oauth":
|
||||
return
|
||||
if time.monotonic() < self._token_expires_at:
|
||||
return
|
||||
with self._auth_lock:
|
||||
self._refresh_expired_token()
|
||||
|
||||
def _refresh_expired_token(self) -> None:
|
||||
"""Log in again when the token is due. Caller holds ``_auth_lock``."""
|
||||
if self._auth_mode != "oauth":
|
||||
return
|
||||
if time.monotonic() < self._token_expires_at:
|
||||
return
|
||||
self._authenticate_oauth(self._client_id, self._client_secret)
|
||||
|
||||
def _request(self, method, url, **kwargs):
|
||||
if self._auth_mode == "oauth":
|
||||
self.ensure_fresh_token()
|
||||
response = self._raw_request(method, url, **kwargs)
|
||||
if self._auth_mode == "oauth" and response.status_code == 401:
|
||||
try:
|
||||
with self._auth_lock:
|
||||
self._token_expires_at = 0.0
|
||||
self._authenticate_oauth(self._client_id, self._client_secret)
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"3CX re-login after 401 failed; returning the original response"
|
||||
)
|
||||
return response
|
||||
response = self._raw_request(method, url, **kwargs)
|
||||
return response
|
||||
|
||||
def _authenticate_user(self, username: str, password: str):
|
||||
"""Authenticate via extension/user credentials (any license tier)."""
|
||||
resp = self.session.post(
|
||||
|
|
@ -64,7 +137,8 @@ class ThreeCXClient:
|
|||
def _authenticate_oauth(self, client_id: str, client_secret: str):
|
||||
"""Authenticate via OAuth2 client credentials (Enterprise license required).
|
||||
API client must be created in 3CX Admin > Integrations > API."""
|
||||
resp = self.session.post(
|
||||
resp = self._raw_request(
|
||||
"POST",
|
||||
f"{self.base_url}/connect/token",
|
||||
data={
|
||||
"client_id": client_id,
|
||||
|
|
@ -74,7 +148,13 @@ class ThreeCXClient:
|
|||
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
token = resp.json()["access_token"]
|
||||
body = resp.json()
|
||||
token = body.get("access_token")
|
||||
if not token:
|
||||
raise ValueError("Failed to get access token from 3CX OAuth response")
|
||||
expires_in = int(body.get("expires_in") or 3600)
|
||||
skew = min(_TOKEN_SKEW_SECONDS, expires_in // 10)
|
||||
self._token_expires_at = time.monotonic() + max(expires_in - skew, 0)
|
||||
self.session.headers.update({"Authorization": f"Bearer {token}"})
|
||||
logger.info("Authenticated to 3CX via OAuth2 client credentials")
|
||||
|
||||
|
|
|
|||
|
|
@ -1,2 +1,2 @@
|
|||
slack_bolt>=1.30.0,<2.0
|
||||
boto3>=1.43.98
|
||||
boto3>=1.43.99
|
||||
|
|
|
|||
|
|
@ -1,2 +1,2 @@
|
|||
boto3>=1.43.98
|
||||
boto3>=1.43.99
|
||||
slack_sdk>=3.44.1,<4.0
|
||||
|
|
|
|||
64
terraform/.terraform.lock.hcl
generated
64
terraform/.terraform.lock.hcl
generated
|
|
@ -23,39 +23,39 @@ provider "registry.terraform.io/hashicorp/archive" {
|
|||
}
|
||||
|
||||
provider "registry.terraform.io/hashicorp/aws" {
|
||||
version = "6.65.0"
|
||||
constraints = "~> 6.65"
|
||||
version = "6.66.0"
|
||||
constraints = "~> 6.66"
|
||||
hashes = [
|
||||
"h1:/VgIzAOR/v+p135IFsJjYT7q3pA24yE3lZGBV0Otqq0=",
|
||||
"h1:1QFvuV0+K3GieeXSlb7qi9Q334XrsnMP8dCRwpM7fkc=",
|
||||
"h1:36ZBGQTzM6dW8dLQ145loI9yw6/fSbRV+fvLGCeEubc=",
|
||||
"h1:4uHlr+eDGOjf71giwLidIfGsMP6g5x2wkSGP5xq9EpM=",
|
||||
"h1:9fSxZKfaAGrNSzXIz53IP2EmC1LYdO/fGYl7T87Y36Q=",
|
||||
"h1:GJCK46UtrJMGSyByH4SiDytbwX11bg79jvTGZ27BGWU=",
|
||||
"h1:H0qzEAMrqydb8eTZdebso8nS/b8w1BNHysP8nmM4pLk=",
|
||||
"h1:RjeO6m/SvlhGUCDrwTdj99kJhKl/rC1zE9B5mi3YhVw=",
|
||||
"h1:Yx8Kv/T/BHVE8S1WEyHsFdu4HcsAQIl5e/831DeoQ5k=",
|
||||
"h1:ZFDxAUFzk1A2BPbLgu1LhAJ3erD4BbqZsF25yQobN4o=",
|
||||
"h1:anUZ356aBWvbHzQalF036qNKO+RISPmq6ycIL4OdXxk=",
|
||||
"h1:fhsSsZmfNFf4wErbcsmu1/ek1/TVUy7NCuMYeOpA1aE=",
|
||||
"h1:l+w5eqL9UqpiVZ2ll0r+YvWAPjIL/WtqV8xqfH1+apM=",
|
||||
"h1:nt0kyMKN9kDNXKHOejaAo7LQIemP3tyntYjxHY32P0M=",
|
||||
"h1:o2tj5YHQU1QNgANW2YAbjj0opl0BRJZl8W9trjYsqdA=",
|
||||
"zh:15b5bd81119965363893197b3b6065bdf46888b93c536623fd113b5505c375be",
|
||||
"zh:16409fd045116a31b28adce98fcaaa56a7c01487369713e4a2a04af1cfa96fa3",
|
||||
"zh:422ea20ef4be8e5b942118d1da61cbde818cadb512ec1132306d65120f983917",
|
||||
"zh:42cda6703a6a51585c2cb2b8b3ab3a7c80a3ee08838138be8ecaa6b97b1d74d8",
|
||||
"zh:718a880d81bfd9af7e297ed3d7bf98d1febebe8b9ebe3333854a4c17f2c4de09",
|
||||
"zh:74e538a8ff4ea27b2040be426cdd2b952725883f5b45c8c03b27eff7d82b40f8",
|
||||
"zh:876bf62e56a41e0c7a514652e22a41246e7c1d67be3b2c1b68553fa3a8dae6d7",
|
||||
"zh:8d571e06d78b91b28faa7fafee99dee920d4f7a50f07ec9cd21695a385bcc0d5",
|
||||
"zh:93154e33f4cbd39825a92a230642082e7b2b8b058c27cf93588ee6824aa1c294",
|
||||
"zh:935f9523c940dc5795ce8afac37aa8a2ed06c0012196ab39b1de2ea26acc129e",
|
||||
"h1:/yJhdVJVyi5k1KA4z1lDoYWQlTOPjR3NIAh/v4cLgLo=",
|
||||
"h1:5COqw8J20qkGI2ao1u8RTTguYEgiE3LJSbToD5fYUg4=",
|
||||
"h1:5t1vkYwqDYRN27RliDkyWRmQfQCnNHFqWxC2UDVCK78=",
|
||||
"h1:7Qtd6MjgS/ymociMyFCG9EKK6Jy5kGOy7EfXngFwsl4=",
|
||||
"h1:LgU7nnuiiD9m9YuYBNMArIgHex/RZqe4VFevYb/1kJU=",
|
||||
"h1:OnLj4nhqJnEcUzyyRKUjp1FgWG00Y8maikJEYSf9Zjw=",
|
||||
"h1:RhHqC2ugIjXrVhSu/Q6WYd/WOjjQ6rH27bh7LZuIW1w=",
|
||||
"h1:Rx4Ktpqk2eSvoPEIWB240IC67BkQxEXGmY/eRu6PIGk=",
|
||||
"h1:S8gYRM7I6/ufvF4dhBaAAGHm3f3+FKBUnEKR93Wcprs=",
|
||||
"h1:ZbkpwuEfpWTZDKdJnEZSDKN5tGEQTUYRkKuK6Cz2wcc=",
|
||||
"h1:c9A3yNQ0xB0wlJfG1XK3pfEHOEYx3HyJaQ56WnNv190=",
|
||||
"h1:cXBw4chYKvv6XlSvyVGAfSGKCAXwC0/fOAuq7xv7hME=",
|
||||
"h1:hBEaeBm9nm7A/u1nnD0nfolTPP55/BoKRFWk8zG8/fk=",
|
||||
"h1:mIolsCn33slp3F7Zd4KCTScXAWuUQsjtIzA/a6TFG6Q=",
|
||||
"h1:xehZnyesOrJ1/R9tmnRSu7FRkwoDDKelEHI3WdnJ72g=",
|
||||
"zh:156fe7164a3d26ef6b35734c43e99fb198df90575ed897d1182b8e930b8cd523",
|
||||
"zh:1af52b22b35be00f8d16e3ebebff9fa699ec4db2ef69e6032ba5c536f80c03d9",
|
||||
"zh:2545a8478bd551fdc9694f6cc1a1ad24617f6736f8bde0ad6cae90987c65380f",
|
||||
"zh:4070db1ee369ccb41cb610bfd887386bc0a9b9ecad60aeb4dbce58443d2519dd",
|
||||
"zh:53da7d3c1840ef875c7d34e967732502a64fe677af0e78824773d4c15a8fe740",
|
||||
"zh:576a93a28bf611a4de2a2e6ced697a41d5126b8fd31d30782b16797e410a9706",
|
||||
"zh:58fed5fa9a033355b9d4f3092c817b70d934100e0d8678d6e4c93f3c9493d4e4",
|
||||
"zh:6a9ca2f24e2ee9156dd785d159a850b35d190e9cf7eca21cb9582970c2db80cd",
|
||||
"zh:729edd30f99cc16009deba5c013265b0c81eda261a3d0821cbd011d3287fd230",
|
||||
"zh:7ae460049b75bd4aefee465ef7c53a01ac2df46d4d3e3ac00824afa8b5cb83fb",
|
||||
"zh:9051fa85c8034ade8a57a5c6f232fd33da28f3800bb5aa40bc8625dbc5e27632",
|
||||
"zh:906547e4319805e7acf7fbdf2bac28a4b1a7370790a2a430c7adb1b29bb934eb",
|
||||
"zh:998f27410a66158a35ee5ed142c27e5b21fe8601941da55da2157f8042d6dcca",
|
||||
"zh:9b12af85486a96aedd8d7984b0ff811a4b42e3d88dad1a3fb4c0b580d04fa425",
|
||||
"zh:9bffe18e907e04d98d7d6b9a6a4c2381448e365441df423e90e1eeaf3a79fd2f",
|
||||
"zh:9ddfdefef226c8ff3c8de03d8df23ab3199fed9f0684618a62489e0e90a21cab",
|
||||
"zh:9eab3abf041fe8e1ce5959fda9c20ddfaf331b7c29ff707e914451abce7841af",
|
||||
"zh:ed749702f6c56b26a390a52bfd31d3bdf7f0af800bc16244276ca71d6f541e87",
|
||||
"zh:f304df223a0bc3e840a806a5dffb75e6b2b75c879053dc4ebd0228484923ee59",
|
||||
"zh:9c1804eff1dda0446dc2d215231015bb65a2fc6c3b7ba24584fe45f1ddd3fa9f",
|
||||
"zh:b03ff5efdee310502aaaeb460144dc059bce72a0d8217e6b989099ef8aef9283",
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ terraform {
|
|||
required_providers {
|
||||
aws = {
|
||||
source = "hashicorp/aws"
|
||||
version = "~> 6.65"
|
||||
version = "~> 6.66"
|
||||
}
|
||||
archive = {
|
||||
source = "hashicorp/archive"
|
||||
|
|
|
|||
|
|
@ -145,7 +145,7 @@ def test_openapi_uses_redocly_recommended():
|
|||
assert '"400":' not in health
|
||||
assert "root: openapi.yaml" in redocly
|
||||
assert '"openapi:lint"' in package
|
||||
assert '"@redocly/cli": "2.52.1"' in package
|
||||
assert '"@redocly/cli":' in package
|
||||
assert "openapi: 3.1.0" in spec
|
||||
assert "required: [stage, sha]" in spec
|
||||
|
||||
|
|
|
|||
|
|
@ -218,6 +218,10 @@ def test_extract_ivr_routes_missing_key0_is_none():
|
|||
assert routes == {"key0": None, "timeout": None}
|
||||
|
||||
|
||||
def _token_posts():
|
||||
return [c for c in responses.calls if c.request.url.endswith("/connect/token")]
|
||||
|
||||
|
||||
@responses.activate
|
||||
def test_oauth_client_reuses_process_cache():
|
||||
from shared import three_cx_client as tcx
|
||||
|
|
@ -227,8 +231,170 @@ def test_oauth_client_reuses_process_cache():
|
|||
first = tcx.oauth_client("test.3cx.us", "cid", "secret")
|
||||
second = tcx.oauth_client("test.3cx.us", "cid", "secret")
|
||||
assert first is second
|
||||
token_posts = [
|
||||
c for c in responses.calls if c.request.url.endswith("/connect/token")
|
||||
]
|
||||
assert len(token_posts) == 1
|
||||
assert len(_token_posts()) == 1
|
||||
tcx._oauth_clients.clear()
|
||||
|
||||
|
||||
@responses.activate
|
||||
def test_oauth_client_refreshes_expired_token():
|
||||
from shared import three_cx_client as tcx
|
||||
|
||||
tcx._oauth_clients.clear()
|
||||
responses.add(
|
||||
responses.POST,
|
||||
f"{BASE}/connect/token",
|
||||
json={"access_token": "tok-1", "expires_in": 3600},
|
||||
status=200,
|
||||
)
|
||||
responses.add(
|
||||
responses.POST,
|
||||
f"{BASE}/connect/token",
|
||||
json={"access_token": "tok-2", "expires_in": 3600},
|
||||
status=200,
|
||||
)
|
||||
responses.add(
|
||||
responses.GET,
|
||||
f"{BASE}/xapi/v1/Queues/Pbx.GetByNumber(number='801')",
|
||||
json={"Id": 83},
|
||||
status=200,
|
||||
)
|
||||
client = tcx.oauth_client("test.3cx.us", "cid", "secret")
|
||||
client._token_expires_at = 0
|
||||
queue = client.get_queue("801")
|
||||
assert queue["Id"] == 83
|
||||
assert client.session.headers["Authorization"] == "Bearer tok-2"
|
||||
assert len(_token_posts()) == 2
|
||||
tcx._oauth_clients.clear()
|
||||
|
||||
|
||||
@responses.activate
|
||||
def test_oauth_client_reauths_when_client_secret_changes():
|
||||
from shared import three_cx_client as tcx
|
||||
|
||||
tcx._oauth_clients.clear()
|
||||
responses.add(
|
||||
responses.POST,
|
||||
f"{BASE}/connect/token",
|
||||
json={"access_token": "tok-old", "expires_in": 3600},
|
||||
status=200,
|
||||
)
|
||||
responses.add(
|
||||
responses.POST,
|
||||
f"{BASE}/connect/token",
|
||||
json={"access_token": "tok-new", "expires_in": 3600},
|
||||
status=200,
|
||||
)
|
||||
first = tcx.oauth_client("test.3cx.us", "cid", "old-secret")
|
||||
second = tcx.oauth_client("test.3cx.us", "cid", "new-secret")
|
||||
assert first is second
|
||||
assert second.session.headers["Authorization"] == "Bearer tok-new"
|
||||
posts = _token_posts()
|
||||
assert len(posts) == 2
|
||||
assert "client_secret=new-secret" in posts[1].request.body
|
||||
tcx._oauth_clients.clear()
|
||||
|
||||
|
||||
@responses.activate
|
||||
def test_oauth_client_accepts_a_reverted_client_secret():
|
||||
from shared import three_cx_client as tcx
|
||||
|
||||
tcx._oauth_clients.clear()
|
||||
for token in ("tok-a", "tok-b", "tok-a-again"):
|
||||
responses.add(
|
||||
responses.POST,
|
||||
f"{BASE}/connect/token",
|
||||
json={"access_token": token, "expires_in": 3600},
|
||||
status=200,
|
||||
)
|
||||
responses.add(
|
||||
responses.GET,
|
||||
f"{BASE}/xapi/v1/Queues/Pbx.GetByNumber(number='801')",
|
||||
json={"Id": 83},
|
||||
status=200,
|
||||
)
|
||||
client = tcx.oauth_client("test.3cx.us", "cid", "secret-a")
|
||||
tcx.oauth_client("test.3cx.us", "cid", "secret-b")
|
||||
reverted = tcx.oauth_client("test.3cx.us", "cid", "secret-a")
|
||||
assert reverted is client
|
||||
assert reverted.session.headers["Authorization"] == "Bearer tok-a-again"
|
||||
assert reverted.get_queue("801")["Id"] == 83
|
||||
posts = _token_posts()
|
||||
assert len(posts) == 3
|
||||
assert "client_secret=secret-a" in posts[2].request.body
|
||||
tcx._oauth_clients.clear()
|
||||
|
||||
|
||||
@responses.activate
|
||||
def test_oauth_client_keeps_current_secret_when_candidate_login_fails():
|
||||
from shared import three_cx_client as tcx
|
||||
|
||||
tcx._oauth_clients.clear()
|
||||
responses.add(
|
||||
responses.POST,
|
||||
f"{BASE}/connect/token",
|
||||
json={"access_token": "tok-current", "expires_in": 3600},
|
||||
status=200,
|
||||
)
|
||||
responses.add(
|
||||
responses.POST,
|
||||
f"{BASE}/connect/token",
|
||||
json={"access_token": "tok-new", "expires_in": 3600},
|
||||
status=200,
|
||||
)
|
||||
responses.add(responses.POST, f"{BASE}/connect/token", status=401)
|
||||
client = tcx.oauth_client("test.3cx.us", "cid", "current-secret")
|
||||
tcx.oauth_client("test.3cx.us", "cid", "new-secret")
|
||||
kept = tcx.oauth_client("test.3cx.us", "cid", "revoked-secret")
|
||||
assert kept is client
|
||||
assert kept.session.headers["Authorization"] == "Bearer tok-new"
|
||||
assert kept._client_secret == "new-secret"
|
||||
tcx._oauth_clients.clear()
|
||||
|
||||
|
||||
@responses.activate
|
||||
def test_oauth_request_retries_once_after_401():
|
||||
_stub_oauth()
|
||||
responses.add(
|
||||
responses.POST,
|
||||
f"{BASE}/connect/token",
|
||||
json={"access_token": "tok-refreshed", "expires_in": 3600},
|
||||
status=200,
|
||||
)
|
||||
responses.add(
|
||||
responses.GET,
|
||||
f"{BASE}/xapi/v1/Queues/Pbx.GetByNumber(number='801')",
|
||||
status=401,
|
||||
)
|
||||
responses.add(
|
||||
responses.GET,
|
||||
f"{BASE}/xapi/v1/Queues/Pbx.GetByNumber(number='801')",
|
||||
json={"Id": 83},
|
||||
status=200,
|
||||
)
|
||||
client = ThreeCXClient(
|
||||
domain="test.3cx.us", auth_mode="oauth", client_id="c", client_secret="s"
|
||||
)
|
||||
assert client.get_queue("801")["Id"] == 83
|
||||
assert client.session.headers["Authorization"] == "Bearer tok-refreshed"
|
||||
assert len(_token_posts()) == 2
|
||||
|
||||
|
||||
@responses.activate
|
||||
def test_oauth_401_returns_original_response_when_relogin_fails():
|
||||
import pytest
|
||||
from requests import HTTPError
|
||||
|
||||
_stub_oauth()
|
||||
responses.add(responses.POST, f"{BASE}/connect/token", status=401)
|
||||
responses.add(
|
||||
responses.GET,
|
||||
f"{BASE}/xapi/v1/Queues/Pbx.GetByNumber(number='801')",
|
||||
status=401,
|
||||
)
|
||||
client = ThreeCXClient(
|
||||
domain="test.3cx.us", auth_mode="oauth", client_id="c", client_secret="s"
|
||||
)
|
||||
with pytest.raises(HTTPError) as raised:
|
||||
client.get_queue("801")
|
||||
assert "Queues/Pbx.GetByNumber" in str(raised.value)
|
||||
assert raised.value.response.status_code == 401
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue