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):
|
||||
|
||||
@@ -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}'
|
||||
|
||||
Reference in New Issue
Block a user