#!/usr/bin/env python3 """ Tests that verify global LLM token budget counters cannot be tampered with via the API (watch PUT) or via form submissions (settings page POST). This is critical for hosted deployments where the operator sets LLM_TOKEN_BUDGET_MONTH in the container — tenants must not be able to reset or inflate the counter themselves. """ import json import os import pytest from flask import url_for from changedetectionio.tests.util import live_server_setup, delete_all_watches # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- def _seed_token_counters(datastore, this_month=5000, total=12000): """Pre-load token counters into the datastore's llm settings dict.""" from changedetectionio.llm.evaluator import _get_month_key app_settings = datastore.data['settings']['application'] if 'llm' not in app_settings: app_settings['llm'] = {} app_settings['llm'].update({ 'model': 'gpt-4o-mini', 'api_key': 'sk-test', 'tokens_this_month': this_month, 'tokens_total_cumulative': total, 'tokens_month_key': _get_month_key(), }) def _get_counters(datastore): llm_cfg = datastore.data['settings']['application'].get('llm') or {} return { 'tokens_this_month': llm_cfg.get('tokens_this_month', 0), 'tokens_total_cumulative': llm_cfg.get('tokens_total_cumulative', 0), 'tokens_month_key': llm_cfg.get('tokens_month_key'), } # --------------------------------------------------------------------------- # API tamper tests # --------------------------------------------------------------------------- def test_api_cannot_reset_token_counters_via_watch_put( client, live_server, measure_memory_usage, datastore_path): """ A PUT to /api/v1/watch/ must NOT be able to reset or change the global token counters stored in settings.application.llm. The counters live on the datastore settings, not the watch object, so this test confirms they remain intact regardless of API activity. """ ds = client.application.config.get('DATASTORE') api_key = ds.data['settings']['application'].get('api_access_token') _seed_token_counters(ds, this_month=7000, total=20000) test_url = url_for('test_endpoint', _external=True) # Create a watch via API res = client.post( url_for("createwatch"), data=json.dumps({"url": test_url}), headers={'content-type': 'application/json', 'x-api-key': api_key}, follow_redirects=True, ) assert res.status_code == 201 uuid = res.json.get('uuid') # Attempt to PUT the watch with llm_tokens_used_cumulative set to 0 # (trying to "reset" the per-watch counter — this field is readOnly on watches, # but more importantly the global counters on settings must be unaffected) res = client.put( url_for("watch", uuid=uuid), headers={'x-api-key': api_key, 'content-type': 'application/json'}, data=json.dumps({ "url": test_url, "llm_tokens_used_cumulative": 0, # readOnly on Watch — should be silently ignored "llm_last_tokens_used": 0, # readOnly on Watch — should be silently ignored }), ) assert res.status_code == 200, f"PUT failed: {res.data}" # Global counters on settings must be completely unchanged after = _get_counters(ds) assert after['tokens_this_month'] == 7000, \ "API PUT must not reset tokens_this_month" assert after['tokens_total_cumulative'] == 20000, \ "API PUT must not reset tokens_total_cumulative" delete_all_watches(client) def test_api_watch_put_llm_readonly_fields_are_ignored( client, live_server, measure_memory_usage, datastore_path): """ llm_prefilter, llm_evaluation_cache, llm_last_tokens_used, llm_tokens_used_cumulative are all readOnly in the API spec. Sending them in a PUT must not raise an error (they should be silently stripped) and must not modify the watch's stored values. """ ds = client.application.config.get('DATASTORE') api_key = ds.data['settings']['application'].get('api_access_token') test_url = url_for('test_endpoint', _external=True) res = client.post( url_for("createwatch"), data=json.dumps({"url": test_url}), headers={'content-type': 'application/json', 'x-api-key': api_key}, follow_redirects=True, ) assert res.status_code == 201 uuid = res.json.get('uuid') # Try to set readOnly LLM fields via PUT res = client.put( url_for("watch", uuid=uuid), headers={'x-api-key': api_key, 'content-type': 'application/json'}, data=json.dumps({ "url": test_url, "llm_tokens_used_cumulative": 999999, "llm_last_tokens_used": 888888, "llm_prefilter": "div.hacked", "llm_evaluation_cache": {"fake_key": {"important": True}}, }), ) # Must succeed (not 400) — readOnly fields are silently stripped assert res.status_code == 200, f"Expected 200, got {res.status_code}: {res.data}" # Fetch back and confirm readOnly values were NOT stored res = client.get( url_for("watch", uuid=uuid), headers={'x-api-key': api_key}, ) assert res.json.get('llm_tokens_used_cumulative') != 999999 assert res.json.get('llm_last_tokens_used') != 888888 delete_all_watches(client) # --------------------------------------------------------------------------- # Settings form tamper tests # --------------------------------------------------------------------------- def test_settings_form_preserves_token_counters( client, live_server, measure_memory_usage, datastore_path): """ Submitting the settings form (POST /settings) must preserve existing token counters even when the LLM model/key fields change. A malicious or accidental form submission must not zero the counters. """ ds = client.application.config.get('DATASTORE') _seed_token_counters(ds, this_month=3000, total=9000) before = _get_counters(ds) assert before['tokens_this_month'] == 3000 # Submit settings form with a different model — simulates a normal settings save res = client.post( url_for('settings.settings_page'), data={ # LLM sub-form fields 'llm-model': 'gpt-4o', 'llm-api_key': 'sk-different-key', 'llm-api_base': '', # Minimal required fields to pass form validation 'application-pager_size': '50', 'application-notification_format': 'System default', 'requests-time_between_check-days': '0', 'requests-time_between_check-hours': '0', 'requests-time_between_check-minutes': '5', 'requests-time_between_check-seconds': '0', 'requests-time_between_check-weeks': '0', 'requests-workers': '10', 'requests-timeout': '60', }, follow_redirects=True, ) # Settings save may redirect; we just need it to not crash assert res.status_code == 200 after = _get_counters(ds) assert after['tokens_this_month'] == 3000, \ f"Settings form save must not reset tokens_this_month (got {after['tokens_this_month']})" assert after['tokens_total_cumulative'] == 9000, \ f"Settings form save must not reset tokens_total_cumulative (got {after['tokens_total_cumulative']})" delete_all_watches(client) def test_settings_form_blank_llm_integer_fields_preserve_stored_values( client, live_server, measure_memory_usage, datastore_path): """ Empty IntegerField submissions come back as None from WTForms. LLMSettings declares token_budget_month / max_input_chars / max_tokens_per_count_period / local_token_multiplier as strict `int`, so a None passed through to model_validate raises ValidationError and 500s the settings save. Regression for settings/__init__.py — the LLM merge must drop None values (treat them like absent keys) so blank IntegerField submissions preserve the stored value instead of crashing the form. """ ds = client.application.config.get('DATASTORE') ds.data['settings']['application']['llm'] = { 'model': 'gpt-4o', 'api_key': 'sk-existing', 'token_budget_month': 50000, 'max_input_chars': 200000, 'max_tokens_per_count_period': 1000, 'local_token_multiplier': 3, } res = client.post( url_for('settings.settings_page'), data={ 'llm-model': 'gpt-4o', 'llm-api_key': '', 'llm-api_base': '', # The bug-trigger: every LLM IntegerField submitted blank 'llm-token_budget_month': '', 'llm-max_input_chars': '', 'llm-max_tokens_per_count_period': '', 'llm-local_token_multiplier': '', # Minimal required fields for the rest of the form to validate. # 'System default' is popped from notification_format choices for the # global form, so it must be one of the real codes (e.g. 'html'). 'application-pager_size': '50', 'application-notification_format': 'html', 'application-fetch_backend': 'html_requests', 'application-rss_diff_length': '5', 'application-filter_failure_notification_threshold_attempts': '0', 'requests-time_between_check-days': '0', 'requests-time_between_check-hours': '0', 'requests-time_between_check-minutes': '5', 'requests-time_between_check-seconds': '0', 'requests-time_between_check-weeks': '0', 'requests-jitter_seconds': '0', 'requests-workers': '10', 'requests-timeout': '60', }, follow_redirects=True, ) assert res.status_code == 200, \ f"Settings save crashed on blank LLM IntegerField submission (got {res.status_code})" # Sanity: the form must have actually validated and reached the LLM save path # — without this the test would trivially pass because the buggy code never ran. assert b'Settings updated.' in res.data, \ "Settings form did not validate — the bug-path was never exercised. Check fixture fields." body = res.data.decode('utf-8', errors='replace') assert 'ValidationError' not in body, \ "Pydantic ValidationError leaked into the response — blank IntegerField wasn't filtered" llm = ds.data['settings']['application'].get('llm') or {} assert llm.get('token_budget_month') == 50000, \ f"Blank submission must preserve stored token_budget_month (got {llm.get('token_budget_month')!r})" assert llm.get('max_input_chars') == 200000, \ f"Blank submission must preserve stored max_input_chars (got {llm.get('max_input_chars')!r})" assert llm.get('max_tokens_per_count_period') == 1000, \ f"Blank submission must preserve stored max_tokens_per_count_period (got {llm.get('max_tokens_per_count_period')!r})" assert llm.get('local_token_multiplier') == 3, \ f"Blank submission must preserve stored local_token_multiplier (got {llm.get('local_token_multiplier')!r})" delete_all_watches(client) def test_settings_form_cannot_inject_fake_token_counts( client, live_server, measure_memory_usage, datastore_path): """ Even if a form POST includes hidden fields for token counters, those values must be ignored and the real counters must remain intact. """ ds = client.application.config.get('DATASTORE') _seed_token_counters(ds, this_month=1500, total=4000) # Attempt to inject inflated or zeroed counters via form POST res = client.post( url_for('settings.settings_page'), data={ 'llm-model': 'gpt-4o-mini', 'llm-api_key': 'sk-test', 'llm-api_base': '', # Attempted injection of token counter fields 'llm-tokens_this_month': '0', 'llm-tokens_total_cumulative': '0', 'llm-tokens_month_key': '1970-01', # Minimal required fields 'application-pager_size': '50', 'application-notification_format': 'System default', 'requests-time_between_check-days': '0', 'requests-time_between_check-hours': '0', 'requests-time_between_check-minutes': '5', 'requests-time_between_check-seconds': '0', 'requests-time_between_check-weeks': '0', 'requests-workers': '10', 'requests-timeout': '60', }, follow_redirects=True, ) assert res.status_code == 200 after = _get_counters(ds) assert after['tokens_this_month'] == 1500, \ f"Form injection must not alter tokens_this_month (got {after['tokens_this_month']})" assert after['tokens_total_cumulative'] == 4000, \ f"Form injection must not alter tokens_total_cumulative (got {after['tokens_total_cumulative']})" delete_all_watches(client) # --------------------------------------------------------------------------- # accumulate_global_tokens unit tests # --------------------------------------------------------------------------- def test_accumulate_global_tokens_month_rollover( client, live_server, measure_memory_usage, datastore_path): """ When tokens_month_key is stale (different month), tokens_this_month must reset to zero before accumulating, and the key must update. """ from changedetectionio.llm.evaluator import accumulate_global_tokens, _get_month_key from unittest.mock import patch ds = client.application.config.get('DATASTORE') ds.data['settings']['application']['llm'] = { 'model': 'gpt-4o-mini', 'tokens_this_month': 500, 'tokens_total_cumulative': 1000, 'tokens_month_key': '2024-01', # stale — previous month } # accumulate_global_tokens must detect the rollover and reset the monthly counter accumulate_global_tokens(ds, 100) llm_cfg = ds.data['settings']['application']['llm'] assert llm_cfg['tokens_month_key'] == _get_month_key(), "Month key must be current" assert llm_cfg['tokens_this_month'] == 100, \ "Monthly counter must reset on rollover, then add new tokens" assert llm_cfg['tokens_total_cumulative'] == 1100, \ "All-time counter must never reset" delete_all_watches(client) def test_accumulate_global_tokens_same_month( client, live_server, measure_memory_usage, datastore_path): """Within the same month, both counters accumulate additively.""" from changedetectionio.llm.evaluator import accumulate_global_tokens, _get_month_key ds = client.application.config.get('DATASTORE') current_month = _get_month_key() ds.data['settings']['application']['llm'] = { 'model': 'gpt-4o-mini', 'tokens_this_month': 200, 'tokens_total_cumulative': 800, 'tokens_month_key': current_month, } accumulate_global_tokens(ds, 50) llm_cfg = ds.data['settings']['application']['llm'] assert llm_cfg['tokens_this_month'] == 250 assert llm_cfg['tokens_total_cumulative'] == 850 delete_all_watches(client) def test_is_global_token_budget_exceeded( client, live_server, measure_memory_usage, datastore_path): """is_global_token_budget_exceeded returns True only when budget is set and reached.""" from changedetectionio.llm.evaluator import is_global_token_budget_exceeded, _get_month_key ds = client.application.config.get('DATASTORE') current_month = _get_month_key() # No budget env var → never exceeded with pytest.MonkeyPatch().context() as mp: mp.delenv('LLM_TOKEN_BUDGET_MONTH', raising=False) ds.data['settings']['application']['llm'] = { 'tokens_this_month': 999999, 'tokens_month_key': current_month, } assert not is_global_token_budget_exceeded(ds) # Budget set, under limit with pytest.MonkeyPatch().context() as mp: mp.setenv('LLM_TOKEN_BUDGET_MONTH', '10000') ds.data['settings']['application']['llm'] = { 'tokens_this_month': 5000, 'tokens_month_key': current_month, } assert not is_global_token_budget_exceeded(ds) # Budget set, at limit (exact) with pytest.MonkeyPatch().context() as mp: mp.setenv('LLM_TOKEN_BUDGET_MONTH', '10000') ds.data['settings']['application']['llm'] = { 'tokens_this_month': 10000, 'tokens_month_key': current_month, } assert is_global_token_budget_exceeded(ds) # Budget set, over limit with pytest.MonkeyPatch().context() as mp: mp.setenv('LLM_TOKEN_BUDGET_MONTH', '10000') ds.data['settings']['application']['llm'] = { 'tokens_this_month': 12345, 'tokens_month_key': current_month, } assert is_global_token_budget_exceeded(ds) # Budget set but stale month key → counter is 0 for current month → not exceeded with pytest.MonkeyPatch().context() as mp: mp.setenv('LLM_TOKEN_BUDGET_MONTH', '100') ds.data['settings']['application']['llm'] = { 'tokens_this_month': 9999, 'tokens_month_key': '2020-01', # stale } assert not is_global_token_budget_exceeded(ds) delete_all_watches(client) # --------------------------------------------------------------------------- # Cost accumulation tests # --------------------------------------------------------------------------- def test_accumulate_global_tokens_tracks_cost_for_known_model( client, live_server, measure_memory_usage, datastore_path): """ When input/output tokens and a known model are supplied, cost_usd_this_month and cost_usd_total_cumulative must be accumulated as positive floats. Uses litellm's real pricing db — exact value may change but must be > 0 for a model that has known pricing (gpt-4o-mini). """ from changedetectionio.llm.evaluator import accumulate_global_tokens, _get_month_key ds = client.application.config.get('DATASTORE') ds.data['settings']['application']['llm'] = { 'model': 'gpt-4o-mini', 'tokens_this_month': 0, 'tokens_total_cumulative': 0, 'tokens_month_key': _get_month_key(), 'cost_usd_this_month': 0.0, 'cost_usd_total_cumulative': 0.0, } accumulate_global_tokens(ds, tokens=1000, input_tokens=800, output_tokens=200, model='gpt-4o-mini') llm_cfg = ds.data['settings']['application']['llm'] assert llm_cfg['tokens_this_month'] == 1000 assert llm_cfg['tokens_total_cumulative'] == 1000 # gpt-4o-mini has known pricing in litellm — cost must be > 0 assert llm_cfg.get('cost_usd_this_month', 0) > 0, \ "cost_usd_this_month must be positive for a model with known pricing" assert llm_cfg.get('cost_usd_total_cumulative', 0) > 0 delete_all_watches(client) def test_accumulate_global_tokens_cost_rollover( client, live_server, measure_memory_usage, datastore_path): """ On month rollover, cost_usd_this_month must reset to zero (fresh month), while cost_usd_total_cumulative keeps growing. """ from changedetectionio.llm.evaluator import accumulate_global_tokens, _get_month_key ds = client.application.config.get('DATASTORE') ds.data['settings']['application']['llm'] = { 'model': 'gpt-4o-mini', 'tokens_this_month': 500, 'tokens_total_cumulative': 1000, 'tokens_month_key': '2024-01', # stale month 'cost_usd_this_month': 0.05, 'cost_usd_total_cumulative': 0.20, } accumulate_global_tokens(ds, tokens=50, input_tokens=40, output_tokens=10, model='gpt-4o-mini') llm_cfg = ds.data['settings']['application']['llm'] assert llm_cfg['tokens_month_key'] == _get_month_key(), "Month key must update" assert llm_cfg['tokens_this_month'] == 50, "Monthly token counter must reset then add" assert llm_cfg['tokens_total_cumulative'] == 1050, "All-time counter must not reset" # Monthly cost must reset (old 0.05 discarded) then add new cost assert llm_cfg['cost_usd_this_month'] >= 0.0 assert llm_cfg['cost_usd_this_month'] < 0.05, \ "Monthly cost must have reset (new cost for 50 tokens is less than old 0.05)" # All-time cost must keep growing from 0.20 assert llm_cfg['cost_usd_total_cumulative'] >= 0.20 delete_all_watches(client) def test_accumulate_global_tokens_no_cost_for_unknown_model( client, live_server, measure_memory_usage, datastore_path): """ When model is unknown (e.g. custom endpoint) or no input/output split is provided, cost stays at 0.0 — no error raised. """ from changedetectionio.llm.evaluator import accumulate_global_tokens, _get_month_key ds = client.application.config.get('DATASTORE') ds.data['settings']['application']['llm'] = { 'tokens_this_month': 0, 'tokens_total_cumulative': 0, 'tokens_month_key': _get_month_key(), 'cost_usd_this_month': 0.0, 'cost_usd_total_cumulative': 0.0, } # No model, no input/output split → no cost accumulate_global_tokens(ds, tokens=200) llm_cfg = ds.data['settings']['application']['llm'] assert llm_cfg['tokens_this_month'] == 200 assert llm_cfg['cost_usd_this_month'] == 0.0 assert llm_cfg['cost_usd_total_cumulative'] == 0.0 delete_all_watches(client) def test_cost_fields_are_tamper_proof_via_settings_form( client, live_server, measure_memory_usage, datastore_path): """ Submitting the settings form must not be able to set cost_usd_this_month or cost_usd_total_cumulative — those are operator-controlled counters. """ from flask import url_for ds = client.application.config.get('DATASTORE') ds.data['settings']['application']['llm'] = { 'model': 'gpt-4o-mini', 'api_key': 'sk-test', 'cost_usd_this_month': 1.23, 'cost_usd_total_cumulative': 9.99, } client.post( url_for('settings.settings_page'), data={ 'llm-model': 'gpt-4o', 'llm-api_key': 'sk-test', 'llm-api_base': '', 'llm-cost_usd_this_month': '0', # injection attempt 'llm-cost_usd_total_cumulative': '0', # injection attempt 'application-pager_size': '50', 'application-notification_format': 'System default', 'requests-time_between_check-days': '0', 'requests-time_between_check-hours': '0', 'requests-time_between_check-minutes': '5', 'requests-time_between_check-seconds': '0', 'requests-time_between_check-weeks': '0', 'requests-workers': '10', 'requests-timeout': '60', }, follow_redirects=True, ) llm_cfg = ds.data['settings']['application'].get('llm', {}) assert llm_cfg.get('cost_usd_this_month') == 1.23, \ "cost_usd_this_month must be tamper-proof" assert llm_cfg.get('cost_usd_total_cumulative') == 9.99, \ "cost_usd_total_cumulative must be tamper-proof" delete_all_watches(client)