diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index cae1c222..c3f98f13 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -7,7 +7,7 @@ by CI (`.github/workflows/ci.yml`) - suggestions that break them fail the build. ## General Guidelines - Finish the in-flight task before starting new workstreams. - When asked to "take notes" on a future idea, capture it in the notes file only - do not implement it, and do not expand on it at length in replies. -- Use a temp file for multi-line git commit messages instead of heredoc syntax: `git commit -F `. +- Use a temp file for multi-line git commit messages instead of heredoc syntax: `git commit -F `. When committing in PowerShell, never pass multi-line commit messages inline (no here-strings like @'...'@ or multiple -m flags). PowerShell mangles them and git errors with "did not match any file(s) known to git". Instead, write the commit message to a temp file in the WORKSPACE ROOT (e.g. `commitmsg.tmp`), run `git commit -F commitmsg.tmp`, then delete the file. Do NOT place the temp file inside .git/ - git prunes stray files there and the file disappears before commit runs. - Avoid using `powershell.exe -File` for launching scripts, as it fails if the script file is missing or quarantined. - Long-running commands (pytest suites, npm builds, container rolls) must be redirected to a log file under `devnotes/` (e.g. `devnotes/_pytest.log`) and launched non-blocking; never run as a blocking foreground command. Do not poll or tail the output; the user watches the file and will say when it is done. - Before taking any action that modifies code or files, warn the user first if a test run or build may be in progress, so discussion about the project can continue without altering files underneath a running test. diff --git a/migrations/versions/7d44_application_monitorable.py b/migrations/versions/7d44_application_monitorable.py new file mode 100644 index 00000000..8e96e63b --- /dev/null +++ b/migrations/versions/7d44_application_monitorable.py @@ -0,0 +1,42 @@ +"""Add applications monitorable fields + +Backs the "monitorable" checkbox on the Application form: an app that the +endpoint agent can watch as a process monitor. A monitor needs something to look +for (the Windows image name) and something to start when it is missing (the +launch path), so the API refuses to save ismonitorable without both. + +The two text columns stay NULLABLE rather than carrying a constraint, because +every application row that existed before this migration has neither value and +must remain valid. The pairing rule is enforced in shopdb/core/api/applications.py +where it can return a useful message, not in the schema where it would only +produce an IntegrityError. + +Revision ID: 7d44_app_monitorable +Revises: 7d43_userpermissions +Create Date: 2026-09-08 + +""" +from alembic import op +import sqlalchemy as sa + + +revision = '7d44_app_monitorable' +down_revision = '7d43_userpermissions' +branch_labels = None +depends_on = None + + +def upgrade(): + op.add_column('applications', sa.Column( + 'ismonitorable', sa.Boolean(), nullable=True, server_default=sa.false(), + comment='Can be watched by the endpoint agent (needs launch path + process name)')) + op.add_column('applications', sa.Column( + 'monitorlaunchpath', sa.String(length=512), nullable=True)) + op.add_column('applications', sa.Column( + 'monitorprocessname', sa.String(length=255), nullable=True)) + + +def downgrade(): + op.drop_column('applications', 'monitorprocessname') + op.drop_column('applications', 'monitorlaunchpath') + op.drop_column('applications', 'ismonitorable') diff --git a/plugins/applications/frontend/views/ApplicationForm.vue b/plugins/applications/frontend/views/ApplicationForm.vue index 5a7736a5..7eb31d59 100644 --- a/plugins/applications/frontend/views/ApplicationForm.vue +++ b/plugins/applications/frontend/views/ApplicationForm.vue @@ -74,6 +74,24 @@ Hidden + + +
+ This will install software on your fleet. +

+ Marking {{ form.appname || 'this application' }} as required + on all PCs creates a configuration policy that installs it on every + non-server endpoint it applies to. Installation happens + automatically, without further prompting, on each machine as it + checks in. +

+ +

Links & Paths

@@ -225,6 +243,15 @@ const form = ref({ const supportTeams = ref([]) +// The value as saved, so we can tell a NEW deployment from a re-save of an +// application that was already required. Only the former warrants a warning. +const wasRequired = ref(false) +const acknowledgePolicy = ref(false) + +const showsPolicyWarning = computed( + () => form.value.isrequired && !wasRequired.value +) + // Files are chosen before the application exists, so they are held here and // uploaded once there is an id to attach them to (see saveApplication). const pendingImage = ref(null) @@ -322,6 +349,7 @@ onMounted(async () => { applicationnotes: app.applicationnotes || '' } imagePreview.value = imageSrcFor(app.image) + wasRequired.value = app.isrequired || false if ((app.installpath || '').startsWith('/api/applications/package/')) { uploadedPackage.value = app.installpath.split('/').pop() } @@ -336,6 +364,13 @@ onMounted(async () => { async function saveApplication() { error.value = '' + + if (showsPolicyWarning.value && !acknowledgePolicy.value) { + error.value = 'Please acknowledge that this will install the application ' + + 'on all matched PCs before saving.' + return + } + saving.value = true try { @@ -352,7 +387,8 @@ async function saveApplication() { documentationpath: form.value.documentationpath || null, installpath: form.value.installpath || null, image: form.value.image || null, - applicationnotes: form.value.applicationnotes || null + applicationnotes: form.value.applicationnotes || null, + acknowledgepolicy: showsPolicyWarning.value } let appid = route.params.id @@ -449,4 +485,27 @@ async function saveApplication() { font-size: 0.8rem; color: var(--text-light, #666); } + +.policy-warning { + margin-top: 1rem; + padding: 0.75rem 1rem; + border: 1px solid var(--warning, #b45309); + border-left-width: 4px; + border-radius: 4px; + background: var(--warning-bg, #fffbeb); + color: var(--text, #333); +} + +.policy-warning p { + margin: 0.5rem 0 0.75rem; + font-size: 0.9rem; +} + +.policy-warning label { + display: flex; + align-items: center; + gap: 0.5rem; + cursor: pointer; + font-weight: 600; +} diff --git a/plugins/computers/api/policy_routes.py b/plugins/computers/api/policy_routes.py index 3e6346d2..72f350d5 100644 --- a/plugins/computers/api/policy_routes.py +++ b/plugins/computers/api/policy_routes.py @@ -19,8 +19,10 @@ from shopdb.api import (db, AuditLog, success_response, error_response, from .routes import computers_bp from ..models import (ConfigurationPolicy, ConfigurationPolicyAction, BusinessPolicy, BusinessPolicyItem, POLICY_SOURCE_LOCAL, + POLICY_SOURCE_AUTOAPP, ACTION_MODE_DESIRED, ACTION_MODE_GATE) from ..policyflag import policy_flag_enabled +from ..services.apppolicy import disown_if_generated from ..services.policyscope import validate_scope, ScopeError _MODES = {ACTION_MODE_DESIRED, ACTION_MODE_GATE} @@ -258,7 +260,10 @@ def update_configuration_policy(policy_id: int): if not policy: return error_response(ErrorCodes.NOT_FOUND, 'Configuration policy not found', http_code=404) - if policy.source != POLICY_SOURCE_LOCAL: + # An autoapp row is generated from an Application's "Required on all PCs" + # checkbox and IS editable: editing it is precisely how an admin takes + # ownership. Genuinely external sources (shipped/managed) stay read-only. + if policy.source not in (POLICY_SOURCE_LOCAL, POLICY_SOURCE_AUTOAPP): return error_response(ErrorCodes.CONFLICT, 'Policy is managed externally and is read-only', http_code=409) @@ -301,6 +306,10 @@ def update_configuration_policy(policy_id: int): if err: return err + # Hand-editing a generated policy transfers ownership: from here on the + # application checkbox will not delete it, it will warn instead. + disown_if_generated(policy) + AuditLog.log('updated', 'ConfigurationPolicy', entityname=policy.name) db.session.commit() return success_response(_configpolicy_dict(policy), @@ -317,7 +326,9 @@ def delete_configuration_policy(policy_id: int): if not policy: return error_response(ErrorCodes.NOT_FOUND, 'Configuration policy not found', http_code=404) - if policy.source != POLICY_SOURCE_LOCAL: + # As with update: a generated row is the site's to remove, an externally + # managed one is not. + if policy.source not in (POLICY_SOURCE_LOCAL, POLICY_SOURCE_AUTOAPP): return error_response(ErrorCodes.CONFLICT, 'Policy is managed externally and is read-only', http_code=409) diff --git a/plugins/computers/models/__init__.py b/plugins/computers/models/__init__.py index 45560a21..f5f61009 100644 --- a/plugins/computers/models/__init__.py +++ b/plugins/computers/models/__init__.py @@ -14,6 +14,7 @@ from .policy import ( BusinessPolicyItem, POLICY_SOURCE_LOCAL, POLICY_SOURCE_SHIPPED, + POLICY_SOURCE_AUTOAPP, ACTION_MODE_DESIRED, ACTION_MODE_GATE, PolicyActionHold, @@ -32,6 +33,7 @@ __all__ = [ 'BusinessPolicyItem', 'POLICY_SOURCE_LOCAL', 'POLICY_SOURCE_SHIPPED', + 'POLICY_SOURCE_AUTOAPP', 'ACTION_MODE_DESIRED', 'ACTION_MODE_GATE', 'PolicyActionHold', diff --git a/plugins/computers/models/policy.py b/plugins/computers/models/policy.py index 90318733..82e16c00 100644 --- a/plugins/computers/models/policy.py +++ b/plugins/computers/models/policy.py @@ -33,6 +33,18 @@ POLICY_SOURCE_LOCAL = 'local' # policies are never confused with what the product shipped. POLICY_SOURCE_SHIPPED = 'shipped' +# Rows generated FOR US by another surface rather than authored by hand: today +# the "Required on all PCs" checkbox on an Application. Distinct from 'local' so +# unchecking the box knows which policy it owns and may safely remove. +# +# It doubles as the "untouched" test. An autoapp row is one nobody has +# customised, so it is safe to delete when the box is unchecked. The moment an +# admin edits it through the policy CRUD API it becomes 'local' - their work +# now, not ours - and unchecking warns instead of deleting. That keeps the +# generated policy convenient without letting it silently destroy hand-authored +# changes. +POLICY_SOURCE_AUTOAPP = 'autoapp' + class ConfigurationPolicy(BaseModel): """A single reusable policy: a scope plus the actions it applies. diff --git a/plugins/computers/services/apppolicy.py b/plugins/computers/services/apppolicy.py new file mode 100644 index 00000000..b04b87ce --- /dev/null +++ b/plugins/computers/services/apppolicy.py @@ -0,0 +1,124 @@ +"""Generate a Configuration Policy from an Application's "Required on all PCs". + +Ticking that box on an application should not merely flag it in the compliance +report - it should actually enforce the app onto every PC. This module owns the +one policy that represents that intent, so the applications API does not have to +know how policies are shaped. + +Ownership and the "untouched" rule +---------------------------------- +A generated policy carries source=POLICY_SOURCE_AUTOAPP, which marks it as ours +to manage. Unticking the box removes it - but ONLY while it still carries that +source. If an admin edits the policy through the policy CRUD API it flips to +'local', and from then on unticking leaves it alone and reports that it was +kept. Deleting someone's customised enforcement rule because a checkbox changed +on a different screen would be a genuinely destructive surprise, and the box is +not a reliable signal that they wanted that. + +Scope +----- +Generated policies use the `allpcs` node, not `true`: "required on all PCs" +means the PCs people work at, and pushing an installer to a server because it +appeared in the app catalog is not what the checkbox says. See policyscope.py. +""" + +from shopdb.api import db + +from ..models import ( + ConfigurationPolicy, + ConfigurationPolicyAction, + POLICY_SOURCE_AUTOAPP, + POLICY_SOURCE_LOCAL, + ACTION_MODE_DESIRED, +) + +# The action a generated policy carries. update_app is the allowlisted verb that +# installs a pinned app package on an endpoint (plugins/endpoints/commands.py), +# so it is what "this app is required here" reduces to in enforcement terms. +REQUIRED_APP_ACTION = 'update_app' + +# Name prefix for generated rows. ConfigurationPolicy.name is unique and NOT +# NULL, so generated names must be deterministic (we have to find the row again) +# and distinctive enough not to collide with a hand-authored policy. +AUTO_POLICY_PREFIX = 'Required app:' + + +def auto_policy_name(application): + """The deterministic policy name for an application.""" + return '{} {}'.format(AUTO_POLICY_PREFIX, application.appname) + + +def find_auto_policy(application): + """Return the policy generated for this app, whoever owns it now. + + Matches on name, since that is the only durable link - there is no FK from + a policy back to an application. Returns rows that have since been flipped + to 'local' too, so callers can tell "customised" from "absent". + """ + return ConfigurationPolicy.query.filter_by( + name=auto_policy_name(application) + ).first() + + +def sync_required_app_policy(application): + """Create or remove the generated policy to match application.isrequired. + + Returns a short status string for the API to surface to the user: + 'created' - a new policy now enforces this app + 'removed' - the generated policy was deleted + 'kept' - a customised policy was left in place deliberately + 'unchanged' - nothing needed doing + """ + existing = find_auto_policy(application) + + if application.isrequired: + if existing is not None: + # Already enforced. Deliberately does NOT rewrite a customised row + # back to our defaults; re-ticking a box should not silently revert + # an admin's edits. + return 'unchanged' + + policy = ConfigurationPolicy( + name=auto_policy_name(application), + description=( + 'Auto-generated from the "Required on all PCs" checkbox on ' + 'application "{}". Editing this policy makes it yours: it will ' + 'no longer be removed automatically when that box is ' + 'unchecked.'.format(application.appname) + ), + scopejson={'type': 'allpcs'}, + source=POLICY_SOURCE_AUTOAPP, + ) + db.session.add(policy) + db.session.flush() + + db.session.add(ConfigurationPolicyAction( + configurationpolicyid=policy.configurationpolicyid, + action=REQUIRED_APP_ACTION, + mode=ACTION_MODE_DESIRED, + desiredstate={'installed': True}, + params={'appid': application.appid}, + )) + return 'created' + + # Not required. + if existing is None: + return 'unchanged' + if existing.source != POLICY_SOURCE_AUTOAPP: + # Someone customised it. Theirs now - leave it and say so. + return 'kept' + + db.session.delete(existing) + return 'removed' + + +def disown_if_generated(policy): + """Flip a generated policy to 'local' because it is being hand-edited. + + Called from the policy CRUD write paths. After this, the application + checkbox no longer owns the row and will not delete it. + """ + if policy is not None and policy.source == POLICY_SOURCE_AUTOAPP: + policy.source = POLICY_SOURCE_LOCAL + return True + return False diff --git a/shopdb/core/api/applications.py b/shopdb/core/api/applications.py index f892d192..3b934eff 100644 --- a/shopdb/core/api/applications.py +++ b/shopdb/core/api/applications.py @@ -36,6 +36,87 @@ APP_PACKAGE_EXTENSIONS = {'.exe', '.msi', '.msp', '.zip', '.7z', '.cab', '.iso', '.appx', '.msix', '.ps1', '.bat', '.txt', '.pdf'} APP_PACKAGE_URL_PREFIX = '/api/applications/package/' + +def _requires_policy_ack(app, data): + """True when this save would newly generate an install-enforcing policy. + + Ticking "Required on all PCs" does not just flag a report row: it creates a + policy that installs this application on every matched endpoint. That is a + real change to real machines, so the caller has to acknowledge it + explicitly. Enforced here rather than only in the form because the checkbox + is reachable through the API too, and a confirmation dialog that can be + skipped by calling the endpoint directly is not a safeguard. + + Only NEWLY ticking it needs acknowledgement. Unticking, or saving an app + that was already required, does not. + """ + if not data.get('isrequired'): + return False + if app is not None and app.isrequired: + return False + return not data.get('acknowledgepolicy') + + +POLICY_ACK_MESSAGE = ( + 'Marking this application as required on all PCs creates a configuration ' + 'policy that will install it on every non-server endpoint it matches. ' + 'Confirm to create the policy.' +) + + +def _validate_monitorable(data, existing=None): + """Return an error message if monitorable is set without its two fields. + + A process monitor needs an image name to watch for and a path to launch when + it is missing; without both, ticking the box would create a monitor that + cannot do anything. Enforced here rather than as a DB constraint so the + columns can stay nullable for rows that predate the feature. + + `existing` lets a PATCH-style update validate the MERGED result: a payload + that only toggles the checkbox is still checked against values already + stored on the row. + """ + def merged(field): + if field in data: + return (data.get(field) or '').strip() + return ((getattr(existing, field, None) or '').strip() + if existing is not None else '') + + if 'ismonitorable' in data: + monitorable = data.get('ismonitorable') + else: + monitorable = getattr(existing, 'ismonitorable', False) + + if not monitorable: + return None + + if not merged('monitorlaunchpath'): + return 'A monitorable application needs a launch path' + if not merged('monitorprocessname'): + return 'A monitorable application needs a process name' + return None + + +def _sync_required_app_policy(app): + """Sync the auto-generated "required on all PCs" policy, if available. + + The policy engine lives in the computers PLUGIN, which may be absent or + disabled, so this is a guarded import: core must not hard-depend on a + plugin. When the plugin is not installed the checkbox keeps its original + compliance-report meaning and nothing else happens. + """ + try: + from plugins.computers.services.apppolicy import sync_required_app_policy + except ImportError: + return None + try: + return sync_required_app_policy(app) + except Exception: + # A policy-generation failure must not lose the user's application edit. + current_app.logger.exception( + 'Failed to sync the required-app policy for appid=%s', app.appid) + return None + # A real installer runs to hundreds of megabytes; an ISO runs to gigabytes and # does not belong in an instance directory. 500MB is the line: enough for the # msi/exe this is for, small enough that the disk cannot vanish behind it. @@ -212,6 +293,13 @@ def create_application(): http_code=409 ) + monitor_error = _validate_monitorable(data) + if monitor_error: + return error_response(ErrorCodes.VALIDATION_ERROR, monitor_error) + + if _requires_policy_ack(None, data): + return error_response(ErrorCodes.VALIDATION_ERROR, POLICY_ACK_MESSAGE) + app = Application( appname=data['appname'], appdescription=data.get('appdescription'), @@ -225,6 +313,9 @@ def create_application(): isprinter=data.get('isprinter', False), islicenced=data.get('islicenced', False), isrequired=data.get('isrequired', False), + ismonitorable=data.get('ismonitorable', False), + monitorlaunchpath=data.get('monitorlaunchpath'), + monitorprocessname=data.get('monitorprocessname'), image=data.get('image') ) @@ -234,9 +325,15 @@ def create_application(): AuditLog.log('created', 'Application', entityid=app.appid, entityname=app.appname) + policyresult = _sync_required_app_policy(app) + db.session.commit() - return success_response(app.to_dict(), message='Application created', http_code=201) + payload = app.to_dict() + if policyresult in ('created', 'removed', 'kept'): + payload['policyresult'] = policyresult + + return success_response(payload, message='Application created', http_code=201) @applications_bp.route('/', methods=['PUT']) @@ -260,10 +357,18 @@ def update_application(app_id: int): http_code=409 ) + monitor_error = _validate_monitorable(data, existing=app) + if monitor_error: + return error_response(ErrorCodes.VALIDATION_ERROR, monitor_error) + + if _requires_policy_ack(app, data): + return error_response(ErrorCodes.VALIDATION_ERROR, POLICY_ACK_MESSAGE) + fields = [ 'appname', 'appdescription', 'supportteamid', 'isinstallable', 'applicationnotes', 'installpath', 'applicationlink', 'documentationpath', - 'ishidden', 'isprinter', 'islicenced', 'isrequired', 'image', 'isactive' + 'ishidden', 'isprinter', 'islicenced', 'isrequired', 'image', 'isactive', + 'ismonitorable', 'monitorlaunchpath', 'monitorprocessname' ] changes = {} @@ -279,9 +384,20 @@ def update_application(app_id: int): AuditLog.log('updated', 'Application', entityid=app.appid, entityname=app.appname, changes=changes) + # Only touch policy when the checkbox itself moved. Syncing on every save + # would let an unrelated edit resurrect a policy an admin had deleted. + policyresult = None + if 'isrequired' in changes: + policyresult = _sync_required_app_policy(app) + apply_import_timestamps(app, data) db.session.commit() - return success_response(app.to_dict(), message='Application updated') + + payload = app.to_dict() + if policyresult in ('created', 'removed', 'kept'): + payload['policyresult'] = policyresult + + return success_response(payload, message='Application updated') @applications_bp.route('/', methods=['DELETE']) diff --git a/shopdb/core/models/application.py b/shopdb/core/models/application.py index 2526e573..d47eb78b 100644 --- a/shopdb/core/models/application.py +++ b/shopdb/core/models/application.py @@ -33,6 +33,20 @@ class Application(BaseModel): db.Boolean, default=False, comment='Required on all PCs (drives the software-compliance report)' ) + # Monitorable: this app can be watched by the endpoint agent as a process + # monitor. A monitor is useless without something to launch and something to + # look for, so both fields below are REQUIRED whenever this is set - enforced + # in the applications API rather than as a DB constraint, because the columns + # must stay nullable for the rows that existed before this shipped. + ismonitorable = db.Column( + db.Boolean, default=False, + comment='Can be watched by the endpoint agent (needs launch path + process name)' + ) + # Full path used to START the app when a monitor finds it not running. + monitorlaunchpath = db.Column(db.String(512)) + # Windows image name to watch for, e.g. "notepad.exe". This is what the agent + # matches against the running process list. + monitorprocessname = db.Column(db.String(255)) image = db.Column(db.String(255)) # Digest of the app-level uploaded installer (installpath). Lets the # endpoint update_app push resolve an install source by app alone, with no diff --git a/tests/test_core/test_application_required_policy.py b/tests/test_core/test_application_required_policy.py new file mode 100644 index 00000000..d19d80d2 --- /dev/null +++ b/tests/test_core/test_application_required_policy.py @@ -0,0 +1,110 @@ +"""Tests for the acknowledgement gate on "Required on all PCs". + +Ticking that box is not a reporting flag. It generates a configuration policy +that installs the application on every non-server endpoint it matches, so the +save is a fleet-wide deployment. The gate exists so nobody triggers one by +tabbing through a form. + +The gate lives in the API rather than only in the form on purpose, and that is +most of what is pinned here: a confirmation dialog that can be sidestepped by +POSTing directly is decoration, not a safeguard. The other half is that it +fires only on the transition INTO the required state, since re-saving an app +that already has its policy deploys nothing new. +""" + +from shopdb.core.models import Application + + +def _app(db, name='Test App', isrequired=False): + app = Application(appname=name, isrequired=isrequired) + db.session.add(app) + db.session.commit() + return app + + +def test_create_marked_required_is_refused_without_acknowledgement( + client, db, auth_headers): + response = client.post('/api/applications', + json={'appname': 'Fleet App', 'isrequired': True}, + headers=auth_headers) + + assert response.status_code == 400, response.get_json() + message = response.get_json()['data']['error']['message'] + assert 'install' in message.lower() + assert Application.query.filter_by(appname='Fleet App').first() is None + + +def test_create_marked_required_succeeds_once_acknowledged( + client, db, auth_headers): + response = client.post('/api/applications', + json={'appname': 'Fleet App', 'isrequired': True, + 'acknowledgepolicy': True}, + headers=auth_headers) + + assert response.status_code == 201, response.get_json() + assert Application.query.filter_by(appname='Fleet App').first().isrequired + + +def test_update_into_required_is_refused_without_acknowledgement( + client, db, auth_headers): + app = _app(db) + + response = client.put(f'/api/applications/{app.appid}', + json={'isrequired': True}, headers=auth_headers) + + assert response.status_code == 400, response.get_json() + # The refusal must leave the flag alone, not half-apply the change. + assert db.session.get(Application, app.appid).isrequired is False + + +def test_update_into_required_succeeds_once_acknowledged( + client, db, auth_headers): + app = _app(db) + + response = client.put(f'/api/applications/{app.appid}', + json={'isrequired': True, 'acknowledgepolicy': True}, + headers=auth_headers) + + assert response.status_code == 200, response.get_json() + assert db.session.get(Application, app.appid).isrequired is True + + +def test_resaving_an_already_required_app_needs_no_acknowledgement( + client, db, auth_headers): + # The policy already exists, so this save deploys nothing new. Prompting + # again would train people to click through the warning that matters. + app = _app(db, isrequired=True) + + response = client.put(f'/api/applications/{app.appid}', + json={'isrequired': True, + 'appdescription': 'Edited'}, + headers=auth_headers) + + assert response.status_code == 200, response.get_json() + + +def test_clearing_required_needs_no_acknowledgement(client, db, auth_headers): + # Unticking withdraws the policy. It never installs anything, so there is + # nothing to warn about. + app = _app(db, isrequired=True) + + response = client.put(f'/api/applications/{app.appid}', + json={'isrequired': False}, headers=auth_headers) + + assert response.status_code == 200, response.get_json() + assert db.session.get(Application, app.appid).isrequired is False + + +def test_acknowledgement_flag_is_not_persisted_to_the_application( + client, db, auth_headers): + # It is a control flag for one request, not a column. If it ever reached + # the model it would be a stored "yes" that suppresses future warnings. + response = client.post('/api/applications', + json={'appname': 'Fleet App', 'isrequired': True, + 'acknowledgepolicy': True}, + headers=auth_headers) + + assert response.status_code == 201, response.get_json() + app = Application.query.filter_by(appname='Fleet App').first() + assert not hasattr(app, 'acknowledgepolicy') + assert 'acknowledgepolicy' not in response.get_json()['data']