feat: keep the Tiny connection alive and show when it is not
All checks were successful
Build and deploy / Validate source (push) Successful in 8s
Build and deploy / Integration suite on a real stack (push) Successful in 2m29s
Build and deploy / Secret scan and release gate (push) Successful in 7s
Build and deploy / Publish images (push) Successful in 1m49s
All checks were successful
Build and deploy / Validate source (push) Successful in 8s
Build and deploy / Integration suite on a real stack (push) Successful in 2m29s
Build and deploy / Secret scan and release gate (push) Successful in 7s
Build and deploy / Publish images (push) Successful in 1m49s
Connecting now asks for offline_access, retrying once without it if Tiny refuses the scope. Renewal failures are stored: a refused refresh token marks the connection lost and is not sent again (the Kanban previously still said "conectado"), a transient failure shows as a warning until the next renewal, and a session grant with under 12 hours left is flagged. Tiny errors on the callback return to the Kanban instead of a 422. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -33,8 +33,12 @@ def run():
|
||||
|
||||
def exercise():
|
||||
issued = []
|
||||
server = {'mode': 'session', 'calls': 0}
|
||||
|
||||
def token_server(request):
|
||||
server['calls'] += 1
|
||||
if server['mode'] == 'down':
|
||||
return httpx.Response(503, text='unavailable')
|
||||
form = {k: v[0] for k, v in parse_qs(request.content.decode()).items()}
|
||||
assert (form['client_id'], form['client_secret']) == ('test-client', 'test-secret')
|
||||
if form['grant_type'] == 'authorization_code':
|
||||
@@ -44,8 +48,13 @@ def exercise():
|
||||
return httpx.Response(400, json={'error': 'invalid_grant'})
|
||||
n = len(issued) + 1
|
||||
issued.append(f'refresh-{n}')
|
||||
if server['mode'] == 'offline':
|
||||
return httpx.Response(200, json={'access_token': f'access-{n}', 'expires_in': 14400,
|
||||
'refresh_token': f'refresh-{n}', 'refresh_expires_in': 0,
|
||||
'scope': 'openid offline_access profile'})
|
||||
return httpx.Response(200, json={'access_token': f'access-{n}', 'expires_in': 14400,
|
||||
'refresh_token': f'refresh-{n}', 'refresh_expires_in': 86400})
|
||||
'refresh_token': f'refresh-{n}', 'refresh_expires_in': 86400,
|
||||
'scope': 'openid profile'})
|
||||
|
||||
auth = TinyAuth(transport=httpx.MockTransport(token_server))
|
||||
assert auth.status() == {'connected': False}
|
||||
@@ -59,6 +68,7 @@ def exercise():
|
||||
query = {k: v[0] for k, v in parse_qs(url.query).items()}
|
||||
assert url.netloc == 'accounts.tiny.com.br' and query['client_id'] == 'test-client'
|
||||
assert query['redirect_uri'].endswith('/api/operator/tiny/callback') and len(query['state']) > 30
|
||||
assert query['scope'] == 'openid offline_access'
|
||||
try:
|
||||
auth.complete('good-code', 'forged-state')
|
||||
raise AssertionError('a state no operator created must be refused')
|
||||
@@ -72,6 +82,7 @@ def exercise():
|
||||
pass
|
||||
status = auth.status()
|
||||
assert status['connected'] and status['connected_by'] == 'operator@example.test'
|
||||
assert status['problem'] is None and not status['offline'] and status['expires_at']
|
||||
assert auth.access_token() == 'access-1'
|
||||
print('PASS: operator-started state is required, single-use, and stores the connection')
|
||||
|
||||
@@ -85,6 +96,23 @@ def exercise():
|
||||
assert auth.access_token() == 'access-2'
|
||||
print('PASS: expiring access token refreshed once, refresh token rotated and stored')
|
||||
|
||||
# Tiny unreachable: the failure is shown, the connection kept, and the next
|
||||
# renewal clears it.
|
||||
with connect() as c:
|
||||
c.execute("UPDATE dtf_local.provider_tokens SET access_expires_at=now() WHERE provider='tiny'")
|
||||
server['mode'] = 'down'
|
||||
try:
|
||||
auth.access_token()
|
||||
raise AssertionError('an unreachable token server must fail the renewal')
|
||||
except httpx.HTTPError:
|
||||
pass
|
||||
status = auth.status()
|
||||
assert status['connected'] and status['problem'] == 'renewal-failing' and status['failed_at']
|
||||
server['mode'] = 'session'
|
||||
assert auth.access_token() == 'access-3'
|
||||
assert auth.status()['problem'] is None
|
||||
print('PASS: a failed renewal is shown and cleared by the next successful one')
|
||||
|
||||
# A refused refresh (revoked in Tiny) asks for a new connection, not a retry loop.
|
||||
with connect() as c:
|
||||
c.execute("""UPDATE dtf_local.provider_tokens SET access_expires_at=now(), refresh_token='revoked'
|
||||
@@ -94,12 +122,63 @@ def exercise():
|
||||
raise AssertionError('a refused refresh must report the connection as lost')
|
||||
except TinyNotConnected:
|
||||
pass
|
||||
status = auth.status()
|
||||
assert not status['connected'] and status['problem'] == 'refused'
|
||||
calls = server['calls']
|
||||
try:
|
||||
auth.access_token()
|
||||
raise AssertionError('a refused connection must stay refused')
|
||||
except TinyNotConnected:
|
||||
pass
|
||||
assert server['calls'] == calls, 'a refused refresh token must not be sent again'
|
||||
with connect() as c:
|
||||
c.execute("UPDATE dtf_local.provider_tokens SET refresh_expires_at=%s WHERE provider='tiny'",
|
||||
(datetime.now(timezone.utc) - timedelta(seconds=1),))
|
||||
assert not auth.status()['connected']
|
||||
print('PASS: revoked or expired connections report that Tiny must be connected again')
|
||||
|
||||
# Reconnecting with an offline grant: no daily end, and the refusal is cleared.
|
||||
server['mode'] = 'offline'
|
||||
state = parse_qs(urlparse(auth.authorize_url('operator@example.test')).query)['state'][0]
|
||||
auth.complete('good-code', state)
|
||||
status = auth.status()
|
||||
assert status['connected'] and status['offline'] and status['expires_at'] is None
|
||||
assert status['problem'] is None
|
||||
with connect() as c:
|
||||
c.execute("UPDATE dtf_local.provider_tokens SET access_expires_at=now() WHERE provider='tiny'")
|
||||
auth.access_token()
|
||||
assert auth.status()['offline'] and auth.status()['expires_at'] is None
|
||||
print('PASS: an offline grant is stored without a daily expiry and survives renewal')
|
||||
|
||||
# Tiny refusing offline_access restarts the authorisation without it, once.
|
||||
state = parse_qs(urlparse(auth.authorize_url('operator@example.test')).query)['state'][0]
|
||||
retry = parse_qs(urlparse(auth.without_offline(state)).query)
|
||||
assert retry['scope'] == ['openid'] and retry['state'][0] != state
|
||||
try:
|
||||
auth.without_offline(state)
|
||||
raise AssertionError('the refused state must be spent')
|
||||
except TinyError:
|
||||
pass
|
||||
# A session grant close to its end is flagged while renewals are not happening.
|
||||
server['mode'] = 'session'
|
||||
auth.complete('good-code', retry['state'][0])
|
||||
with connect() as c:
|
||||
c.execute("UPDATE dtf_local.provider_tokens SET refresh_expires_at=now()+interval '2 hours' WHERE provider='tiny'")
|
||||
status = auth.status()
|
||||
assert status['connected'] and status['problem'] == 'expiring' and not status['offline']
|
||||
print('PASS: a refused offline scope falls back once; a connection near its end is flagged')
|
||||
|
||||
# Tiny's redirect back with an error instead of a code.
|
||||
from app.api.operator import tiny_callback
|
||||
declined = parse_qs(urlparse(auth.authorize_url('operator@example.test')).query)['state'][0]
|
||||
assert tiny_callback('', declined, 'access_denied').headers['location'] == '/?tiny=failed'
|
||||
assert tiny_callback('', '', '').headers['location'] == '/?tiny=failed'
|
||||
scoped = parse_qs(urlparse(auth.authorize_url('operator@example.test')).query)['state'][0]
|
||||
location = urlparse(tiny_callback('', scoped, 'invalid_scope').headers['location'])
|
||||
assert location.netloc == 'accounts.tiny.com.br' and parse_qs(location.query)['scope'] == ['openid']
|
||||
assert tiny_callback('', scoped, 'invalid_scope').headers['location'] == '/?tiny=failed'
|
||||
print('PASS: a declined or malformed callback returns to the Kanban; a refused scope retries without it')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
run()
|
||||
|
||||
Reference in New Issue
Block a user