Add authenticated finance REST API

This commit is contained in:
kai
2026-09-09 09:47:56 +02:00
parent afc6f74f36
commit ad452045be
13 changed files with 760 additions and 44 deletions
+211
View File
@@ -0,0 +1,211 @@
from datetime import date
from decimal import Decimal
import os
from pathlib import Path
import secrets
import sqlite3
import sys
import tempfile
import unittest
from unittest.mock import patch
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / 'app'))
from fastapi.testclient import TestClient
from database import connect, initialize
from main import app
from models import decimal_string
from services.income_service import dashboard
class APITests(unittest.TestCase):
def setUp(self):
self.temp = tempfile.TemporaryDirectory()
self.token = secrets.token_urlsafe(32)
self.env = patch.dict(os.environ, {'FINANCE_DB_PATH': str(Path(self.temp.name) / 'finance.db'),
'FINANCE_API_TOKEN': self.token})
self.env.start()
self.client = TestClient(app)
self.client.__enter__()
self.headers = {'Authorization': f'Bearer {self.token}'}
with connect() as db:
self.asset_id = db.execute("SELECT id FROM assets WHERE name='Enbridge'").fetchone()[0]
def tearDown(self):
self.client.__exit__(None, None, None)
self.env.stop()
self.temp.cleanup()
def request(self, method, path, **kwargs):
return self.client.request(method, '/api/v1' + path, headers=self.headers, **kwargs)
def create_income(self, **changes):
data = dict(date=date.today().isoformat(), asset_id=self.asset_id, category='dividend',
amount='0.04', note='Enbridge', expected=False, received=True)
data.update(changes)
response = self.request('POST', '/income', json=data)
self.assertEqual(response.status_code, 201, response.text)
return response.json()
def test_all_routes_require_auth(self):
routes = [('GET','/assets'), ('POST','/assets'), ('GET','/assets/1'), ('PATCH','/assets/1'), ('DELETE','/assets/1'),
('GET','/income'), ('POST','/income'), ('GET','/income/1'), ('PATCH','/income/1'), ('DELETE','/income/1'),
('GET','/stats/summary'), ('GET','/stats/monthly'), ('GET','/stats/by-asset'), ('GET','/stats/by-category'), ('GET','/meta')]
for method, path in routes:
for headers in [{}, {'Authorization':'Bearer incorrect'}, {'Authorization':'Basic incorrect'}]:
response = self.client.request(method, '/api/v1'+path, headers=headers)
self.assertEqual(response.status_code, 401, (method,path,response.text))
self.assertEqual(response.headers['www-authenticate'], 'Bearer')
self.assertEqual(self.request('GET', '/assets').status_code, 200)
self.assertEqual(self.client.get('/api/v1/assets?token='+self.token).status_code, 401)
def test_unconfigured_api_keeps_web_available(self):
for value in ['', ' ', 'change-me']:
with patch.dict(os.environ, {'FINANCE_API_TOKEN':value}):
self.assertEqual(self.request('GET','/assets').status_code,503)
self.assertEqual(self.client.get('/').status_code,200)
self.assertEqual(self.client.get('/health').json(), {'status':'ok'})
with patch.dict(os.environ):
del os.environ['FINANCE_API_TOKEN']
self.assertEqual(self.client.get('/api/v1/meta').status_code,503)
def test_assets_crud_duplicate_and_partial_updates(self):
data = dict(name='Test Position', ticker='TP', asset_type='etf', active=True)
response = self.request('POST','/assets',json=data)
self.assertEqual(response.status_code,201)
asset = response.json()
self.assertNotIn('normalized_name',asset)
self.assertEqual(self.request('GET',f"/assets/{asset['id']}").json(),asset)
self.assertEqual(self.request('POST','/assets',json={**data,'name':' TEST POSITION '}).status_code,409)
self.assertEqual(self.request('POST','/assets',json={**data,'name':'MSC'}).status_code,409)
result = self.request('PATCH',f"/assets/{asset['id']}",json={'ticker':None,'active':False}).json()
self.assertEqual(result['name'],asset['name'])
self.assertIsNone(result['ticker'])
self.assertFalse(result['active'])
self.assertIn(result,self.request('GET','/assets?active=false').json())
self.assertNotIn(result,self.request('GET','/assets?active=true').json())
self.assertEqual(self.request('PATCH',f"/assets/{asset['id']}",json={'name':'Enbridge'}).status_code,409)
self.assertEqual(self.request('DELETE',f"/assets/{asset['id']}").status_code,204)
def test_asset_soft_delete_preserves_history_and_renames(self):
entry = self.create_income()
self.assertEqual(self.request('DELETE',f'/assets/{self.asset_id}').content,b'')
self.assertFalse(self.request('GET',f'/assets/{self.asset_id}').json()['active'])
self.assertEqual(self.request('GET',f"/income/{entry['id']}").status_code,200)
self.assertEqual(self.request('PATCH',f"/income/{entry['id']}",json={'note':'Korrektur'}).status_code,200)
self.assertEqual(self.request('POST','/income',json=dict(date='2026-09-02',asset_id=self.asset_id,category='dividend',amount='1')).status_code,422)
self.request('PATCH',f'/assets/{self.asset_id}',json={'name':'Enbridge umbenannt'})
initialize()
names = [asset['name'] for asset in self.request('GET','/assets').json()]
self.assertIn('Enbridge umbenannt',names)
self.assertNotIn('Enbridge',names)
def test_income_crud_partial_and_decimal(self):
entry = self.create_income(amount='0,04')
self.assertEqual(entry['amount'],'0.04')
self.assertEqual(entry['asset'],'Enbridge')
self.assertEqual(self.request('GET',f"/income/{entry['id']}").json(),entry)
changed = self.request('PATCH',f"/income/{entry['id']}",json={'amount':'28.00','note':None}).json()
self.assertEqual(changed['date'],entry['date'])
self.assertEqual(changed['amount'],'28.00')
self.assertTrue(changed['received'])
self.assertFalse(changed['expected'])
self.assertIsNone(changed['note'])
with connect() as db:
self.assertEqual(db.execute('SELECT amount FROM income_entries WHERE id=?',(entry['id'],)).fetchone()[0],2800)
response = self.request('DELETE',f"/income/{entry['id']}")
self.assertEqual(response.status_code,204)
self.assertEqual(response.content,b'')
self.assertEqual(self.request('GET',f"/income/{entry['id']}").status_code,404)
def test_filters_and_pagination(self):
first = self.create_income(date='2025-09-02')
second = self.create_income(date='2026-09-02',received=False,expected=True,category='interest')
self.assertEqual(self.request('GET','/income?limit=1&offset=1').json()[0]['id'],first['id'])
result = self.request('GET',f'/income?year=2026&month=9&asset_id={self.asset_id}&category=interest&received=false&expected=true').json()
self.assertEqual([row['id'] for row in result],[second['id']])
self.assertEqual(len(self.request('GET','/income?received=true&expected=false').json()),1)
for query in ['limit=1001','limit=0','offset=-1','month=13','year=0','received=unknown','category=invalid']:
self.assertEqual(self.request('GET','/income?'+query).status_code,422)
def test_stats_share_dashboard_rules(self):
today = date.today()
self.create_income(amount='0.10')
self.create_income(amount='0.20',category='distribution')
self.create_income(amount='0.30',category='interest')
self.create_income(amount='0.40',category='other')
self.create_income(date=f'{today.year-1}-{today.month:02}-01',amount='0.50')
self.create_income(amount='28.00',expected=True,received=False)
stats = dashboard()
result = self.request('GET','/stats/summary').json()
self.assertEqual(result['current_month'],'1.00')
self.assertEqual(result['current_year'],'1.00')
self.assertEqual(result['previous_year'],'0.50')
self.assertEqual(result['current_month_yoy_percent'],'100.00')
self.assertEqual(result['current_year_yoy_percent'],'100.00')
self.assertEqual(result['all_time'],decimal_string(stats['all_time']))
self.assertEqual(result['current_year_payment_count'],4)
monthly = self.request('GET',f'/stats/monthly?year={today.year}').json()[0]
self.assertEqual(len(monthly['months']),12)
self.assertEqual(monthly['months'][today.month-1]['amount'],'1.00')
shares = self.request('GET','/stats/by-asset').json()
self.assertEqual(shares[0]['amount'],'1.50')
self.assertEqual(shares[0]['percentage'],'100.00')
kinds = self.request('GET','/stats/by-category').json()
self.assertEqual({row['category'] for row in kinds},{'dividend','interest','distribution','other'})
self.assertEqual(sum(Decimal(row['amount']) for row in kinds),Decimal('1.50'))
def test_empty_stats_and_zero_denominator(self):
result = self.request('GET','/stats/summary').json()
self.assertEqual(result['all_time'],'0.00')
self.assertIsNone(result['current_year_yoy_percent'])
self.assertEqual(self.request('GET','/stats/by-asset').json(),[])
self.assertTrue(all(row['percentage'] is None for row in self.request('GET','/stats/by-category').json()))
self.assertEqual(self.request('GET','/stats/monthly?year=2024').json()[0]['total'],'0.00')
def test_validation_and_unknown_ids(self):
base = dict(date='2026-09-02',asset_id=self.asset_id,category='dividend',amount='0.04')
for change in [{'amount':0.04},{'amount':'0.001'},{'amount':'NaN'},{'amount':'Infinity'},{'date':'2026-02-30'},
{'asset_id':99999},{'asset_id':True},{'category':'invalid'},{'received':None},{'received':'false'}, {'surprise':'field'}]:
response = self.request('POST','/income',json={**base,**change})
self.assertEqual(response.status_code,422,response.text)
for change in [{'name':' '},{'name':'!!!'},{'name':None},{'asset_type':'invalid'}]:
self.assertEqual(self.request('POST','/assets',json={'name':'Valid','asset_type':'stock',**change}).status_code,422)
entry = self.create_income()
for change in [{'amount':None},{'date':None},{'received':None}]:
self.assertEqual(self.request('PATCH',f"/income/{entry['id']}",json=change).status_code,422)
for resource in ['assets','income']:
for method in ['GET','PATCH','DELETE']:
kwargs = {'json':{}} if method=='PATCH' else {}
self.assertEqual(self.request(method,f'/{resource}/999999',**kwargs).status_code,404)
def test_web_and_api_use_same_entries(self):
response = self.client.post('/income/new',data=dict(date='2026-09-02',asset_id=self.asset_id,
category='dividend',amount='0,04',received='1'),follow_redirects=False)
self.assertEqual(response.status_code,303)
entry = self.request('GET','/income').json()[0]
self.request('PATCH',f"/income/{entry['id']}",json={'amount':'12.34'})
self.assertIn('12,34 €',self.client.get('/income').text)
self.client.post(f"/income/{entry['id']}/delete")
self.assertEqual(self.request('GET','/income').json(),[])
def test_openapi_meta_errors_and_no_cors(self):
schema = self.client.get('/openapi.json').json()
for path, methods in schema['paths'].items():
if path.startswith('/api/v1/'):
for operation in methods.values():
self.assertEqual(operation['security'],[{'FinanceAPIToken':[]}])
self.assertEqual(self.request('GET','/meta').json(),{'name':'Finance Dashboard','api_version':'v1','database':'ok'})
response = self.client.get('/api/v1/assets',headers={**self.headers,'Origin':'https://example.com'})
self.assertNotIn('access-control-allow-origin',response.headers)
with patch('api.routes.asset_service.list_assets',side_effect=RuntimeError('private-internal-information')):
response = self.request('GET','/assets')
self.assertEqual(response.status_code,500)
self.assertNotIn('private-internal-information',response.text)
with patch('api.routes.asset_service.list_assets',side_effect=sqlite3.OperationalError('secret-path')):
response = self.request('GET','/assets')
self.assertEqual(response.status_code,503)
self.assertNotIn('secret-path',response.text)
if __name__ == '__main__':
unittest.main()