Compare commits

..

1 commit

Author SHA1 Message Date
renovate[bot]
9a6c48943e
chore(deps): update pip minor and patch 2026-09-24 16:57:17 +00:00
17 changed files with 68 additions and 308 deletions

8
package-lock.json generated
View file

@ -8,13 +8,13 @@
"name": "afterhours-shift-manager",
"version": "1.0.0",
"devDependencies": {
"@redocly/cli": "2.53.3"
"@redocly/cli": "2.52.1"
}
},
"node_modules/@redocly/cli": {
"version": "2.53.3",
"resolved": "https://registry.npmjs.org/@redocly/cli/-/cli-2.53.3.tgz",
"integrity": "sha512-hzNAWzHCOZ05vwRx0ehTxNeJaxxizjGV505eKtfs9MR/8ieD/8lYhAK4GF5FaqRRwOEpLO0PvzQwBSKYXmxhRA==",
"version": "2.52.1",
"resolved": "https://registry.npmjs.org/@redocly/cli/-/cli-2.52.1.tgz",
"integrity": "sha512-gwfx1WelVDHU/cw+GkpLV9+xksOI3obJGgL1nUVrAFUyXNDxM+aifXDUZk7BfbOIpF69Sea2ant0cOqbQfg/KQ==",
"dev": true,
"license": "MIT",
"bin": {

View file

@ -6,6 +6,6 @@
"openapi:lint": "redocly lint --config .redocly.yaml openapi.yaml"
},
"devDependencies": {
"@redocly/cli": "2.53.3"
"@redocly/cli": "2.52.1"
}
}

View file

@ -1,2 +1,2 @@
flask==3.1.3
gunicorn==26.2.0
gunicorn==23.0.0

View file

@ -1,2 +1,2 @@
boto3>=1.43.99
boto3>=1.43.101
requests>=2.34.2

View file

@ -1,3 +1,3 @@
PyJWT[crypto]==2.14.0
boto3>=1.43.99
PyJWT[crypto]==2.15.0
boto3>=1.43.101
requests>=2.34.2

View file

@ -1,2 +1,2 @@
boto3>=1.43.99
boto3>=1.43.101
requests>=2.34.2

View file

@ -1 +1 @@
boto3>=1.43.99
boto3>=1.43.101

View file

@ -1,2 +1,2 @@
boto3>=1.43.99
boto3>=1.43.101
requests>=2.34.2

View file

@ -1,4 +1,4 @@
boto3>=1.43.99
boto3>=1.43.101
requests>=2.34.2
sentry-sdk==2.69.2
sentry-sdk==2.70.0

View file

@ -86,17 +86,23 @@ 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:
"""Return the process OAuth client, refreshing it when the token or secret changed."""
global _cached_3cx
if _cached_3cx is not None:
return _cached_3cx
secret_prefix = os.environ.get("TCX_SECRET_PREFIX")
if not secret_prefix:
logger.warning("3CX env vars not set — skipping 3CX call")
return None
return oauth_client(
_cached_3cx = 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:

View file

@ -1,23 +1,13 @@
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.
Re-authenticates when the access token is near expiry, and immediately when
``client_secret`` differs from the one the cached client logged in with.
"""
"""Reuse one OAuth client per (domain, client_id) in this process."""
key = (domain, client_id)
client = _oauth_clients.get(key)
if client is None:
@ -28,8 +18,6 @@ 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
@ -44,14 +32,7 @@ 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",
@ -60,66 +41,12 @@ class ThreeCXClient:
)
if auth_mode == "oauth":
self._authenticate_oauth(self._client_id, self._client_secret)
self._authenticate_oauth(
auth_kwargs["client_id"], auth_kwargs["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(
@ -137,8 +64,7 @@ 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._raw_request(
"POST",
resp = self.session.post(
f"{self.base_url}/connect/token",
data={
"client_id": client_id,
@ -148,13 +74,7 @@ class ThreeCXClient:
headers={"Content-Type": "application/x-www-form-urlencoded"},
)
resp.raise_for_status()
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)
token = resp.json()["access_token"]
self.session.headers.update({"Authorization": f"Bearer {token}"})
logger.info("Authenticated to 3CX via OAuth2 client credentials")

View file

@ -1,2 +1,2 @@
slack_bolt>=1.30.0,<2.0
boto3>=1.43.99
boto3>=1.43.101

View file

@ -1,2 +1,2 @@
boto3>=1.43.99
boto3>=1.43.101
slack_sdk>=3.44.1,<4.0

View file

@ -23,39 +23,39 @@ provider "registry.terraform.io/hashicorp/archive" {
}
provider "registry.terraform.io/hashicorp/aws" {
version = "6.66.0"
constraints = "~> 6.66"
version = "6.65.0"
constraints = "~> 6.65"
hashes = [
"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",
"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",
"zh:9b12af85486a96aedd8d7984b0ff811a4b42e3d88dad1a3fb4c0b580d04fa425",
"zh:9c1804eff1dda0446dc2d215231015bb65a2fc6c3b7ba24584fe45f1ddd3fa9f",
"zh:b03ff5efdee310502aaaeb460144dc059bce72a0d8217e6b989099ef8aef9283",
"zh:9bffe18e907e04d98d7d6b9a6a4c2381448e365441df423e90e1eeaf3a79fd2f",
"zh:9ddfdefef226c8ff3c8de03d8df23ab3199fed9f0684618a62489e0e90a21cab",
"zh:9eab3abf041fe8e1ce5959fda9c20ddfaf331b7c29ff707e914451abce7841af",
"zh:ed749702f6c56b26a390a52bfd31d3bdf7f0af800bc16244276ca71d6f541e87",
"zh:f304df223a0bc3e840a806a5dffb75e6b2b75c879053dc4ebd0228484923ee59",
]
}

View file

@ -4,7 +4,7 @@ terraform {
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 6.66"
version = "~> 6.65"
}
archive = {
source = "hashicorp/archive"

View file

@ -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":' in package
assert '"@redocly/cli": "2.52.1"' in package
assert "openapi: 3.1.0" in spec
assert "required: [stage, sha]" in spec

View file

@ -218,10 +218,6 @@ 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
@ -231,170 +227,8 @@ 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
assert len(_token_posts()) == 1
token_posts = [
c for c in responses.calls if c.request.url.endswith("/connect/token")
]
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