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

View File

@@ -19,7 +19,8 @@ from werkzeug.security import generate_password_hash
from shopdb import create_app
from shopdb.extensions import db as _db
from shopdb.plugins import plugin_manager
from plugins.measuringtools.models import derive_status, DUESOON_WINDOW_DAYS
from plugins.measuringtools.models import (
derive_status, DUESOON_WINDOW_DAYS, STATUS_COLORS)
# =============================================================================
@@ -320,3 +321,126 @@ def test_calibration_report_shape(client, auth_headers):
for key, rows in data['buckets'].items():
assert data['counts'][key] == len(rows)
assert 'statuscolors' in data
# -- Core integration: asset serialization ------------------------------------
def _caliper_id(client):
types = client.get('/api/measuringtools/types').get_json()['data']
return next(t['measuringtooltypeid'] for t in types if t['name'] == 'Caliper')
def test_asset_todict_carries_measuringtool_typedata_and_pluginid(mt_app, client, auth_headers):
"""Asset.to_dict resolves the measuringtool extension (typedata + pluginid)."""
from shopdb.core.models import Asset
created = client.post('/api/measuringtools', headers=auth_headers, json={
'assetnumber': 'MT-TD-1', 'statusid': _status_id(client),
'measuringtooltypeid': _caliper_id(client),
})
assert created.status_code == 201, created.get_json()
tool_id = created.get_json()['data']['measuringtool']['measuringtoolid']
assetid = created.get_json()['data']['assetid']
with mt_app.app_context():
asset = _db.session.get(Asset, assetid)
result = asset.to_dict(include_type_data=True)
assert result['pluginid'] == tool_id
assert result['typedata']['measuringtooltypename'] == 'Caliper'
assert result['typedata']['measuringtoolid'] == tool_id
# -- Core integration: shop-floor map -----------------------------------------
def test_map_lists_measuringtool_subtypes(client, auth_headers):
"""The map filter dropdown carries a Measuring Tool subtype list with color."""
response = client.get('/api/assets/map')
assert response.status_code == 200, response.get_json()
subtypes = response.get_json()['data']['filters']['subtypes']
assert 'Measuring Tool' in subtypes
names = {s['name'] for s in subtypes['Measuring Tool']}
assert 'Caliper' in names
assert all('color' in s for s in subtypes['Measuring Tool'])
def test_map_honors_measuringtool_subtype_filter(client, auth_headers):
"""?assettype=measuring_tool&subtype=<id> returns only that subtype's tools."""
types = client.get('/api/measuringtools/types').get_json()['data']
caliper_id = next(t['measuringtooltypeid'] for t in types if t['name'] == 'Caliper')
micrometer_id = next(t['measuringtooltypeid'] for t in types if t['name'] == 'Micrometer')
client.post('/api/measuringtools', headers=auth_headers, json={
'assetnumber': 'MT-MAP-CAL', 'statusid': _status_id(client),
'measuringtooltypeid': caliper_id, 'mapx': 10, 'mapy': 20})
client.post('/api/measuringtools', headers=auth_headers, json={
'assetnumber': 'MT-MAP-MIC', 'statusid': _status_id(client),
'measuringtooltypeid': micrometer_id, 'mapx': 30, 'mapy': 40})
response = client.get(
f'/api/assets/map?assettype=measuring_tool&subtype={caliper_id}')
assert response.status_code == 200, response.get_json()
numbers = {a['assetnumber'] for a in response.get_json()['data']['assets']}
assert 'MT-MAP-CAL' in numbers
assert 'MT-MAP-MIC' not in numbers
def test_map_item_carries_measuringtool_typedata(client, auth_headers):
"""A mapped tool's item carries the extension typedata for marker coloring."""
client.post('/api/measuringtools', headers=auth_headers, json={
'assetnumber': 'MT-MAP-TD', 'statusid': _status_id(client),
'measuringtooltypeid': _caliper_id(client), 'mapx': 55, 'mapy': 66})
response = client.get('/api/assets/map?assettype=measuring_tool')
item = next(a for a in response.get_json()['data']['assets']
if a['assetnumber'] == 'MT-MAP-TD')
assert item['typedata']['measuringtooltypename'] == 'Caliper'
# -- Core integration: dashboard ----------------------------------------------
def test_dashboard_counts_include_measuringtools(client, auth_headers):
"""Dashboard total and counts include active measuring tools."""
before = client.get('/api/dashboard').get_json()['data']
client.post('/api/measuringtools', headers=auth_headers, json={
'assetnumber': 'MT-DASH-1', 'statusid': _status_id(client)})
after = client.get('/api/dashboard').get_json()['data']
assert after['counts']['measuringtools'] == before['counts']['measuringtools'] + 1
assert after['totalmeasuringtool'] == before['totalmeasuringtool'] + 1
assert after['counts']['total'] == before['counts']['total'] + 1
# -- Map overlay endpoint (ADR-010) -------------------------------------------
def test_map_overlay_shape_and_derivation(client, auth_headers):
"""map-overlay returns per-asset derived calibration status + color."""
created = client.post('/api/measuringtools', headers=auth_headers, json={
'assetnumber': 'MT-OVL-1', 'statusid': _status_id(client),
'nextcalibrationdate': str(date.today() - timedelta(days=3))}) # overdue
assetid = created.get_json()['data']['assetid']
response = client.get('/api/measuringtools/map-overlay')
assert response.status_code == 200, response.get_json()
rows = response.get_json()['data']
row = next(r for r in rows if r['assetid'] == assetid)
assert set(row) == {'assetid', 'calibrationstatus', 'statuscolor'}
assert row['calibrationstatus'] == 'overdue'
assert row['statuscolor'] == STATUS_COLORS['overdue']
def test_map_overlay_excludes_inactive(client, auth_headers):
"""A soft-deleted tool drops out of the overlay."""
created = client.post('/api/measuringtools', headers=auth_headers, json={
'assetnumber': 'MT-OVL-DEL', 'statusid': _status_id(client)})
tool_id = created.get_json()['data']['measuringtool']['measuringtoolid']
assetid = created.get_json()['data']['assetid']
client.delete(f'/api/measuringtools/{tool_id}', headers=auth_headers)
rows = client.get('/api/measuringtools/map-overlay').get_json()['data']
assert all(r['assetid'] != assetid for r in rows)
# -- Presentation route token (ADR-010) ---------------------------------------
def test_asset_presentation_route_token():
"""Presentation route links through the by-asset resolver (only {assetid})."""
from plugins.measuringtools.plugin import MeasuringToolsPlugin
entries = MeasuringToolsPlugin().get_asset_presentation()
entry = next(e for e in entries if e['assettype'] == 'measuring_tool')
assert entry['route'] == '/measuringtools/by-asset/{assetid}'