Add personal API tokens; wire measuring tools into remaining surfaces
Some checks failed
CI / naming (push) Has been cancelled
CI / frontend (push) Has been cancelled
CI / backend (push) Has been cancelled

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:
cproudlock
2026-07-12 08:33:02 -04:00
parent 64a5abdb08
commit da86b3ae0c
31 changed files with 1197 additions and 38 deletions

View 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)

View File

@@ -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)

View File

@@ -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):