"""The Tiny OAuth connection against the real database, with a fake token server. Run inside the API container: python -m tests.tiny_oauth_test It saves any existing Tiny connection first and restores it afterwards. """ import os from datetime import datetime, timedelta, timezone from urllib.parse import parse_qs, urlparse import httpx from app.core.db import connect from app.tiny import TinyAuth, TinyError, TinyNotConnected os.environ.update(TINY_CLIENT_ID='test-client', TINY_CLIENT_SECRET='test-secret', TINY_REDIRECT_URI='http://localhost:8081/api/operator/tiny/callback') def run(): with connect() as c: saved = c.execute("SELECT * FROM dtf_local.provider_tokens WHERE provider='tiny'").fetchone() c.execute("DELETE FROM dtf_local.provider_tokens WHERE provider='tiny'") try: exercise() finally: with connect() as c: c.execute("DELETE FROM dtf_local.provider_tokens WHERE provider='tiny'") if saved: columns = ','.join(saved) c.execute(f'INSERT INTO dtf_local.provider_tokens({columns}) VALUES({",".join(["%s"] * len(saved))})', tuple(saved.values())) 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': assert form['code'] == 'good-code' and form['redirect_uri'].endswith('/tiny/callback') elif form['grant_type'] == 'refresh_token': if form['refresh_token'] != issued[-1]: 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, 'scope': 'openid profile'}) auth = TinyAuth(transport=httpx.MockTransport(token_server)) assert auth.status() == {'connected': False} try: auth.access_token() raise AssertionError('an unconnected Tiny must not yield a token') except TinyNotConnected: pass url = urlparse(auth.authorize_url('operator@example.test')) 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') except TinyError: pass assert auth.complete('good-code', query['state']) == 'operator@example.test' try: auth.complete('good-code', query['state']) raise AssertionError('a state must be single-use') except TinyError: 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') # An access token about to expire is refreshed, and the refresh token rotates. with connect() as c: c.execute("UPDATE dtf_local.provider_tokens SET access_expires_at=now() WHERE provider='tiny'") assert auth.access_token() == 'access-2' with connect() as c: row = c.execute("SELECT refresh_token,connected_by FROM dtf_local.provider_tokens WHERE provider='tiny'").fetchone() assert row == {'refresh_token': 'refresh-2', 'connected_by': 'operator@example.test'} 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' WHERE provider='tiny'""") try: auth.access_token() 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()