Add personal API tokens; wire measuring tools into remaining surfaces
API tokens: any user mints named, optionally-expiring tokens (shopdb_pat_..., sha256-stored, secret shown once) at Settings > API Tokens; a before-request shim swaps a valid PAT for a request-scoped JWT of its owner, so the entire existing auth/authz/import-mode stack works unchanged and revoked/expired tokens 401 cleanly. Built for long-running scripts - the legacy import no longer dies when a login JWT expires. Migration 7d21_apitokens; create/revoke audit-logged. Audited integration gaps fixed: Asset.to_dict serializes measuring tools (typedata + pluginid - relationship links to tools resolve); map subtype filter/colors and MapEditor include them; dashboard totals count them; warranty links use a new by-asset route; the measuringtools ADR-010 hooks are real (corrected presentation token, implemented map-overlay endpoint); the login avatar resolves through the employee-photo helper. 737 tests pass; naming green; frontend builds; both features verified live end-to-end. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
178
tests/test_core/test_apitokens.py
Normal file
178
tests/test_core/test_apitokens.py
Normal file
@@ -0,0 +1,178 @@
|
||||
"""Personal API token tests.
|
||||
|
||||
Covers: create returns the secret once and stores only a hash; a PAT
|
||||
authenticates a permission-gated write as its owner; a PAT is rejected when its
|
||||
owner lacks the permission; expired and revoked tokens are rejected; lastusedat
|
||||
updates on use; a non-owner member cannot revoke someone else's token; an admin
|
||||
lists everyone's tokens with ?all=true; and import mode works over a PAT for an
|
||||
admin.
|
||||
"""
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from shopdb.core.models import ApiToken, Vendor
|
||||
from shopdb.extensions import db as _db
|
||||
|
||||
|
||||
def _naive_utcnow():
|
||||
return datetime.now(timezone.utc).replace(tzinfo=None)
|
||||
|
||||
|
||||
def _create_token(client, headers, name='test token', expiresat=None):
|
||||
body = {'name': name}
|
||||
if expiresat is not None:
|
||||
body['expiresat'] = expiresat
|
||||
response = client.post('/api/apitokens', json=body, headers=headers)
|
||||
return response
|
||||
|
||||
|
||||
def _pat_headers(secret):
|
||||
return {'Authorization': f'Bearer {secret}'}
|
||||
|
||||
|
||||
def test_create_returns_secret_once_and_stores_hash(client, db, auth_headers):
|
||||
response = _create_token(client, auth_headers, name='import runner')
|
||||
assert response.status_code == 201
|
||||
data = response.get_json()['data']
|
||||
|
||||
secret = data['secret']
|
||||
assert secret.startswith('shopdb_pat_')
|
||||
assert 'warning' in data
|
||||
# The stored row must not carry the raw secret; only its hash.
|
||||
token = ApiToken.query.filter_by(tokenid=data['tokenid']).first()
|
||||
assert token is not None
|
||||
assert token.tokenhash == ApiToken.hash_secret(secret)
|
||||
assert secret not in (token.tokenhash, token.tokenprefix or '')
|
||||
assert token.tokenprefix and token.tokenprefix in secret
|
||||
|
||||
|
||||
def test_pat_authenticates_permission_write_as_owner(client, db, admin_user,
|
||||
auth_headers):
|
||||
"""A PAT owned by an admin can create a vendor (admin-gated write)."""
|
||||
secret = _create_token(client, auth_headers).get_json()['data']['secret']
|
||||
|
||||
response = client.post('/api/vendors', json={'vendor': 'PAT Vendor'},
|
||||
headers=_pat_headers(secret))
|
||||
assert response.status_code == 201
|
||||
assert Vendor.query.filter_by(vendor='PAT Vendor').first() is not None
|
||||
|
||||
|
||||
def test_pat_403_when_owner_lacks_permission(client, db, member_user,
|
||||
member_headers):
|
||||
"""A PAT owned by a role-less member is forbidden from an admin write."""
|
||||
secret = _create_token(client, member_headers).get_json()['data']['secret']
|
||||
|
||||
response = client.post('/api/vendors', json={'vendor': 'Nope'},
|
||||
headers=_pat_headers(secret))
|
||||
assert response.status_code == 403
|
||||
assert Vendor.query.filter_by(vendor='Nope').first() is None
|
||||
|
||||
|
||||
def test_expired_token_rejected(client, db, admin_user, auth_headers):
|
||||
secret = _create_token(client, auth_headers).get_json()['data']['secret']
|
||||
token = ApiToken.query.filter_by(
|
||||
tokenhash=ApiToken.hash_secret(secret)).first()
|
||||
token.expiresat = _naive_utcnow() - timedelta(days=1)
|
||||
_db.session.commit()
|
||||
|
||||
response = client.post('/api/vendors', json={'vendor': 'Expired'},
|
||||
headers=_pat_headers(secret))
|
||||
assert response.status_code == 401
|
||||
assert Vendor.query.filter_by(vendor='Expired').first() is None
|
||||
|
||||
|
||||
def test_revoked_token_rejected(client, db, admin_user, auth_headers):
|
||||
secret = _create_token(client, auth_headers).get_json()['data']['secret']
|
||||
token = ApiToken.query.filter_by(
|
||||
tokenhash=ApiToken.hash_secret(secret)).first()
|
||||
tokenid = token.tokenid
|
||||
|
||||
revoke = client.delete(f'/api/apitokens/{tokenid}', headers=auth_headers)
|
||||
assert revoke.status_code == 200
|
||||
|
||||
response = client.post('/api/vendors', json={'vendor': 'Revoked'},
|
||||
headers=_pat_headers(secret))
|
||||
assert response.status_code == 401
|
||||
assert Vendor.query.filter_by(vendor='Revoked').first() is None
|
||||
|
||||
|
||||
def test_lastusedat_updates_on_use(client, db, admin_user, auth_headers):
|
||||
secret = _create_token(client, auth_headers).get_json()['data']['secret']
|
||||
token = ApiToken.query.filter_by(
|
||||
tokenhash=ApiToken.hash_secret(secret)).first()
|
||||
assert token.lastusedat is None
|
||||
|
||||
client.get('/api/apitokens', headers=_pat_headers(secret))
|
||||
_db.session.expire_all()
|
||||
token = ApiToken.query.filter_by(
|
||||
tokenhash=ApiToken.hash_secret(secret)).first()
|
||||
assert token.lastusedat is not None
|
||||
|
||||
|
||||
def test_member_cannot_revoke_other_users_token(client, db, admin_user,
|
||||
auth_headers, member_headers):
|
||||
"""A role-less member cannot revoke a token owned by a different user."""
|
||||
secret = _create_token(client, auth_headers).get_json()['data']['secret']
|
||||
tokenid = ApiToken.query.filter_by(
|
||||
tokenhash=ApiToken.hash_secret(secret)).first().tokenid
|
||||
|
||||
response = client.delete(f'/api/apitokens/{tokenid}', headers=member_headers)
|
||||
assert response.status_code == 403
|
||||
# Still active.
|
||||
assert _db.session.get(ApiToken, tokenid).isactive is True
|
||||
|
||||
|
||||
def test_member_can_manage_own_token(client, db, member_user, member_headers):
|
||||
"""By design any authed user manages their OWN tokens."""
|
||||
create = _create_token(client, member_headers, name='mine')
|
||||
assert create.status_code == 201
|
||||
tokenid = create.get_json()['data']['tokenid']
|
||||
|
||||
revoke = client.delete(f'/api/apitokens/{tokenid}', headers=member_headers)
|
||||
assert revoke.status_code == 200
|
||||
assert _db.session.get(ApiToken, tokenid).isactive is False
|
||||
|
||||
|
||||
def test_admin_all_true_lists_everyone(client, db, admin_user, auth_headers,
|
||||
member_user, member_headers):
|
||||
_create_token(client, auth_headers, name='admin token')
|
||||
_create_token(client, member_headers, name='member token')
|
||||
|
||||
# Own-only (default) for admin: just the admin's token.
|
||||
own = client.get('/api/apitokens', headers=auth_headers).get_json()['data']
|
||||
assert all(t['userid'] == admin_user.userid for t in own)
|
||||
|
||||
# all=true: both users' tokens, with owner usernames.
|
||||
everyone = client.get('/api/apitokens?all=true',
|
||||
headers=auth_headers).get_json()['data']
|
||||
userids = {t['userid'] for t in everyone}
|
||||
assert admin_user.userid in userids and member_user.userid in userids
|
||||
assert any(t.get('username') for t in everyone)
|
||||
|
||||
|
||||
def test_member_all_true_ignored(client, db, member_user, member_headers,
|
||||
admin_user, auth_headers):
|
||||
"""A non-admin passing ?all=true still only sees their own tokens."""
|
||||
_create_token(client, auth_headers, name='admin token')
|
||||
_create_token(client, member_headers, name='member token')
|
||||
|
||||
result = client.get('/api/apitokens?all=true',
|
||||
headers=member_headers).get_json()['data']
|
||||
assert all(t['userid'] == member_user.userid for t in result)
|
||||
|
||||
|
||||
def test_import_mode_works_over_pat(client, db, admin_user, auth_headers):
|
||||
"""An admin PAT plus X-Import-Mode backdates createddate on a write."""
|
||||
secret = _create_token(client, auth_headers).get_json()['data']['secret']
|
||||
|
||||
headers = _pat_headers(secret)
|
||||
headers['X-Import-Mode'] = 'true'
|
||||
response = client.post(
|
||||
'/api/vendors',
|
||||
json={'vendor': 'Legacy Vendor', 'createddate': '2019-01-02 03:04:05'},
|
||||
headers=headers)
|
||||
assert response.status_code == 201
|
||||
|
||||
vendor = Vendor.query.filter_by(vendor='Legacy Vendor').first()
|
||||
assert vendor is not None
|
||||
assert vendor.createddate == datetime(2019, 1, 2, 3, 4, 5)
|
||||
@@ -37,7 +37,14 @@ EXEMPT_BLUEPRINTS = {'auth', 'collector', 'setup'}
|
||||
# role-less member MAY edit their own record, so it does not fit the
|
||||
# 403-for-every-member contract this sweep asserts. The other-user 403 is
|
||||
# covered by test_member_cannot_update_other_user below.
|
||||
EXEMPT_ENDPOINTS = {'knowledgebase.track_click', 'users.update_user'}
|
||||
# apitokens.create_apitoken / update_apitoken / revoke_apitoken - personal
|
||||
# API tokens. By design ANY authenticated user may create and manage their
|
||||
# OWN tokens (own-resource logic, not a flat deny), so a role-less member
|
||||
# gets 201/200 here, not the 403 this sweep asserts. The non-owner 403 is
|
||||
# covered by test_apitokens.py (member cannot revoke another user's token).
|
||||
EXEMPT_ENDPOINTS = {'knowledgebase.track_click', 'users.update_user',
|
||||
'apitokens.create_apitoken', 'apitokens.update_apitoken',
|
||||
'apitokens.revoke_apitoken'}
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
|
||||
@@ -133,7 +133,7 @@ def test_asset_presentation_aggregate_enabled_plugins(app, client, auth_headers,
|
||||
entry = next((e for e in entries if e.get('assettype') == 'measuring_tool'), None)
|
||||
assert entry is not None
|
||||
assert entry['plugin'] == 'measuringtools'
|
||||
assert entry['route'] == '/measuringtools/{assetid}'
|
||||
assert entry['route'] == '/measuringtools/by-asset/{assetid}'
|
||||
|
||||
|
||||
def test_asset_presentation_skip_disabled_plugin(app, client, auth_headers, monkeypatch):
|
||||
|
||||
Reference in New Issue
Block a user