Developer Tests System - edmozley/freeitsm GitHub Wiki

๐Ÿงช Developer Tests โ€” System & infrastructure

Part of Developer Tests. Database verification, config.php, containers, clocks and the status portal.

๐Ÿ”‘ Every test on this page exists because the fault was invisible on a developer's machine. A UTC bug does not show on a server whose clock is already UTC. An open_basedir bug does not show where open_basedir is unset. A config.php bug does not show on the one install whose config.php is the repository's. So these tests deliberately do not ask "does it work here" โ€” they ask the structural question, or they recreate the hostile condition on purpose.

Test Needs
db-verify-indexes/run.php Nothing โ€” this is the one CI runs
config-not-load-bearing.php Nothing
container-detection.php Nothing
docker-php-extensions.php Nothing - CI runs it
php-version-guard.php Docker (PHP 7.4, 8.4); section 0 needs nothing
cap-enum-usage.php Nothing
ai-provider-enum.php Nothing (a local stand-in server)
ticket-link-relation.php Database (one transaction, rolled back)
utc-connection.php Database (temporary table only)
service-status-portal.php Database
cost-centres.php Database (rolled back)
cost-centre-assign.php Database (rolled back)
system-settings-search/run.php Nothing
analyst-lifecycle.php Database
money.php Nothing
display-order.php Nothing
project-plan-order.php Database (a ZZPO project, removed)
job-health.php Database (a made-up job key, removed)
domain-transfer-lock.php Nothing
domain-cost-centre.php Database (zzcc domains and a ZZCC cost centre, removed)
job-scheduler.php Database (job_schedules rows; never starts a real job)
domains-internal-endpoints.php Nothing for parts 1-2 (local TLS servers it starts); part 3 the database (zz- rows, removed)
domains-expected-records.php Nothing for part 1 (a stub resolver); part 2 the database (zz- rows, removed)
pdf-writer.php The web server and Chrome or Edge - no database
domains-split-dns.php Database - one transaction, always rolled back
tenant-support.php Database - one transaction, always rolled back
api-domain-monitoring.php Database and the install's URL (zz- domain and API keys, removed)

tests/db-verify-indexes/run.php

What it tests

That the index backfill list understands FULLTEXT.

database/freeitsm.sql builds a new install; Database Verification upgrades an existing one. Indexes bridge the two through a generated mirror (includes/db_verify_indexes.php), so a grown install can be given an index it never received. That mirror used to record a single boolean per index โ€” unique or not โ€” which had nowhere to put a third kind.

Full-text search needs that third kind, and getting it wrong fails quietly:

  1. Type loss. A FULLTEXT KEY parsed as an ordinary KEY still produces the right number of indexes, so a count check passes while every full-text search silently returns nothing on upgraded installs.
  2. The truthy trap. The old third element was a bool read as $unique ? 'UNIQUE KEY' : 'KEY'. The new one is a string โ€” and the string 'key' is truthy, so any code path still using that ternary would build a UNIQUE index over columns full of duplicates.
  3. Drift. The mirror is generated, so the generator and the drift self-check must agree about what an index "is". They share one parser precisely so they cannot disagree, and this suite pins that.

Run it

php tests/db-verify-indexes/run.php

32 assertions, needs nothing.

๐Ÿ”‘ This is the only test currently wired into CI, via schema-drift.yml, which also runs php scripts/gen_db_verify_indexes.php --check.

If it fails

The CI job will have failed too. A drift failure means the generated mirror and freeitsm.sql disagree โ€” regenerate the mirror rather than hand-editing it, and work out which of the two files is actually wrong before you do.


tests/config-not-load-bearing.php

What it tests

That config.php is not load-bearing.

๐Ÿ”ด A change once put dbConnectionOptions() into config.php and pointed eleven callers at it. But config.php is the operator's file: it ships as a template carrying a credentials path, so every install edits it once and keeps that copy, and the Docker image copies docker/config.php straight over the top. Upgrading therefore delivered the callers and left the definition behind โ€” HTTP 500, empty body, every page in the product.

๐Ÿ”‘ The rule: config.php is for values the operator chooses. Behaviour lives in includes/, which upgrades with the product.

How it works

โš ๏ธ Why the ordinary suite could not catch it: the development machine's config.php is the repository's config.php. On the one install where that file is not customised, the function was present and everything worked.

So this test never asks "does it work here". It asks the structural question: does config.php declare any functions at all?

Run it

php tests/config-not-load-bearing.php

13 assertions, needs nothing.

If it fails

Something executable has been added to config.php. Move it into includes/ and have config.php call it. Remember docker/config.php is a different file with different contents โ€” it defines DB_PASSWORD and other things a hand install must never have โ€” so do not try to reconcile the two.


tests/container-detection.php

What it tests

storagePersistenceInContainer(), which gates the warning that tells an operator that docker compose up -d --build is about to destroy their uploads, unrecoverably. If it wrongly answers "no", the warning silently stops appearing for the only people it was ever written for.

๐Ÿ”ด The failure mode is invisible on a development machine. It only appears when open_basedir is set, which no developer here has, and its symptom is not an error but silence. That is exactly how the reported bug reached a user.

How it works

It drives PHP as a subprocess with the restriction switched on, rather than testing the function in-process โ€” the condition cannot be created any other way.

โš ๏ธ It also guards against the wrong fix. The reported warning goes away if you simply re-point the check at a path open_basedir allows โ€” and that tests clean on any machine not running Docker, because there the right answer is false anyway. Case 5 fails if anybody does that.

Run it

php tests/container-detection.php

12 assertions. Reads only: no database, no writes, nothing outside the repo.

If it fails

If case 5 is the red one, read the warning above before "fixing" it โ€” you are probably about to make the test pass and the feature stop working.


tests/utc-connection.php

What it tests

The database connection's clock.

MySQL evaluates NOW(), CURRENT_TIMESTAMP and CURDATE() in the connection's session time zone. That defaulted to SYSTEM โ€” the database server's own clock โ€” while FreeITSM stores every instant in UTC. So any INSERT that did not name a datetime column wrote a local wall clock through the column's DEFAULT CURRENT_TIMESTAMP, and the screen read it back as a UTC instant. A note came out the server's own offset into the future.

๐Ÿ”ด The fault is invisible on a server whose clock is already UTC, which is most of them and every sensible container. A suite that only ever runs on such a machine agrees with the bug.

So the assertions do not ask "is the value right". They ask "is the connection's clock UTC", and then prove the consequence on a scratch table.

Run it

php tests/utc-connection.php

Safe: writes only to a TEMPORARY table, which exists for this connection only and disappears when the script ends. Nothing in the real schema is touched.

If it fails

The connection is not being set to UTC. Every DEFAULT CURRENT_TIMESTAMP on the install is now writing local time into a column everything else reads as UTC โ€” and on a UTC server you will not see it. Fix the connection setup, not the columns.


tests/service-status-portal.php

What it tests

Internal versus external incident updates. The point of this file is that an internal update never reaches the portal. Everything else is secondary, so each check is written from the side that must be refused as well as the side that must work.

How it works

โš ๏ธ It puts the portal settings back exactly as it found them. They are global, and a test that leaves the portal publishing would be worse than no test. ZZSS-named rows, removed including on failure.

Run it

php tests/service-status-portal.php

If it fails

An internal update reaching the portal is a disclosure to every customer at once โ€” internal updates are where engineers write frankly about what broke. Treat as blocking.

If the run dies part-way, check the portal settings by hand before doing anything else; the restore may not have run.

tests/module-availability.php

#217. Switching a module off for the install: every REST API resource and every workflow trigger belongs to a module (drop one from API_V1_RESOURCE_MODULES or TRIGGER_MODULES and it fails); the maps and Watchtower cards name real modules; and the setting reader โ€” no row means every module on, a list switches those off, Tickets and System can never be off, a broken value fails open โ€” each case in its own process against SQLite. 16 checks (with the Tickets features: a ticket's SLA and time entries need their feature, no feature key collides with a module), no database. See Module availability โ€” developer guide.

php tests/module-availability.php

tests/cost-centres.php

3.1.0, #160. CostCentresService: codes kept as text and unique per company ignoring case, a parent in the same company and never a loop, delete refused with children, company scope (404 outside), and sync() - matched on code, all or nothing with every error listed, dry_run writing nothing, deactivate_missing. 38 checks inside one transaction that is always rolled back. See Cost centres โ€” Developer Guide.

php tests/cost-centres.php

tests/cost-centre-assign.php

3.5.0, #160 stage 2. Charging assets, contracts and people to a cost centre (includes/cost_centre_assign.php), 30 checks, rolled back:

  • only active cost centres of the record's own company (none means the Default company);
  • an inactive one already held is kept;
  • moving company needs the new company's cost centre or none;
  • the asset import matches by code, never treating digits as an id;
  • the contracts API's code field and the old free text;
  • delete is refused while anything is charged.

See Cost centres โ€” Developer Guide.

php tests/cost-centre-assign.php

tests/system-settings-search/run.php

3.5.0. The System landing search finds settings inside cards (systemAreaSettings() in system/includes/areas.php). For every listed setting, the heading id must exist on the card's page and its label key must exist in lang/en; every card heading must be listed. It needs nothing, and fails when a heading is renamed or added without updating the list.

php tests/system-settings-search/run.php

tests/analyst-lifecycle.php

What it tests

When an analyst leaves - Deactivate, Anonymise, Delete (#178, 3.7.0). See When an analyst leaves.

  1. The map is complete. Every column that refers to an analyst - found three ways: a foreign key to analysts in freeitsm.sql, a person-shaped integer column name, and a principal/member/owner/target type + id pair - must be in analystReferenceRegistry(), and every map entry must be a real column. This is the guard: a new table that points at analysts fails here until somebody decides what leaving does to it.
  2. The actions do what they say, on throwaway analysts (zz-test-lc-*):
    • an analyst with only personal settings deletes cleanly, dashboard widgets and preferences with them (the #178 case);
    • an analyst with one ticket note cannot be deleted, and nothing changes;
    • open work blocks Delete; a ticket is unassigned through the ticket service (its history records it) and an article moves to another analyst; moving work to an inactive analyst is refused;
    • Deactivate keeps the name; Anonymise replaces the name, username and email, removes sign-in links, keeps their note credited to them, replaces the audit's copy of the name - and leaves a text that only contains it, and another analyst's name, alone;
    • you cannot do any of it to yourself.

How it works

The throwaway tickets are written straight into the table with no priority and no requester, so creating them fires no ticket.created workflow and no email; work is moved to nobody for the ticket (an assignment can email the new assignee) and to a person only for the article. Every row - analysts, tickets, notes, audit rows, articles and the system_logs lines - is removed by id in a finally and checked gone.

Run it

php tests/analyst-lifecycle.php

If it fails

"every column that refers to an analyst is in the map" names the column. Add it to analystReferenceRegistry() in includes/analyst_lifecycle.php with the kind that fits - personal, secret, live, setup or history - or, if it does not refer to an analyst at all, to the test's $notAnalysts list with the reason.


tests/money.php

What it tests

Currencies (#189) - see Currencies - Developer Guide.

  1. The registry: more than 150 currencies, every code three capital letters, every row named, decimals only 0, 2 or 3; IDR present with no decimals.
  2. Recognising a code: typos, fund codes and gold refused; a retired currency (BGN) recognised but not offered; normalise() upper-cases, trims, and names the code it refuses.
  3. Cleaning settings as they are read: unknown, duplicate and retired favourites dropped, at most five, the default moved to the front; a bad format key in any of its three parts refused; junk JSON survived.
  4. Writing an amount: 20-odd cases - Indian grouping to a crore, three-decimal and no-decimal currencies, rounding that carries (999.999 โ†’ $1,000.00) and rounds a half the PHP way (1.005 โ†’ $1.01), a tiny negative not shown as -ยฃ0.00, multi-byte separators, an unknown code, blank and non-numeric input.

How it works

No database. Every format is passed in explicitly, so an install's own settings can never change a result.

assets/js/money.js is the same algorithm. It cannot be run from the command line here, so when a case changes, check the JS too - the parity check for #189 ran these cases through both in headless Chrome.

Run it

php tests/money.php

If it fails

A formatting case names the input and both outputs. Fix Money::format() in includes/money.php and format() in assets/js/money.js together - a fix in one only makes the screen and the PDFs disagree.


tests/display-order.php

What it tests

Saved orders (#187) - the personal module order and the shared Watchtower card order. See Display Order - Developer Guide.

  1. The merge rule: a new key lands in its default place after the saved ones, a removed key drops out, duplicates and non-strings are ignored, every key comes back exactly once.
  2. Reading a stored order: blank, null, junk and a bare number all mean "no order".
  3. One Watchtower card list: every card in wtCardRegistry() has its element on the dashboard, a module colour and a settings label; every card on the dashboard is in the list (with a positive control that the dashboard's cards were found at all).
  4. One module list: the home page, the waffle (drawer and Ctrl+K) and the Files start menu all draw from analystVisibleModules().

Run it

php tests/display-order.php

If it fails

"every card on the dashboard is in the list" - a card was added to watchtower/index.php but not to wtCardRegistry(). Add it there, in its default place.


tests/project-plan-order.php

What it tests

The order of tasks within a project stage (#184) - tasks.project_position, see Projects schema.

  • A plan nobody has reordered keeps its old order (board_position).
  • orderTasks() puts a lane in the order given; a task left out goes after the numbered ones (this one caught a real bug: the lane was not cleared first, so a left-out task kept its old number and tied).
  • An id from another lane changes nothing.
  • Moving a task to another stage clears its number, so it lands at the end of a numbered lane.
  • Someone outside the project team is refused, and nothing changes.
  • Before Database Verification it checks instead that ordering is refused politely and moving between stages still works.

How it works

It creates a throwaway project, two stages and three tasks named ZZPO. The tasks are INSERTed directly, not through TasksService, so no workflow, notification or email fires. Everything is removed in a finally, and the last check is that nothing named ZZPO is left.

Run it

php tests/project-plan-order.php

tests/domain-transfer-lock.php

What a registry's statuses say about a domain's transfer lock (#188): on only with a transfer-prohibited status, off only with evidence (a generic TLD, or another prohibition in the reply), otherwise not known; .uk and .de unchanged. 15 made-up replies, no network. Run against the code before the fix it fails 5 cases - the reported bug.

php tests/domain-transfer-lock.php

tests/domain-cost-centre.php

A domain's cost centre from System โ†’ Cost centres (#188): choosing one drops old text; text sent the old way is a code (linked, refused if unknown, '' clears); choosing none keeps old text; an inactive one cannot be chosen but is kept if held; the delete-guard counts the domain; the page gets the label and the options. Creates throwaway zzcc-*.example domains and one inactive ZZCC cost centre, removed in a finally. Before Database Verification it checks instead that the old text still saves as before.

php tests/domain-cost-centre.php

tests/tenant-support.php

"Company B supports company A" (#228): with no links nothing changes; Don't warn is one way; Portal offers the requester's own company first then B, and only then accepts B as the ticket's company - any other id falls back; an inactive supporter is neither offered nor accepted; no company = no picker; a company cannot support itself; both ticks off deletes the link; the mailboxes offered follow the choice; a manager's ticket companies include supporters; deleting a company cascades. 27 checks in one transaction, always rolled back. Negative control: a resolver that accepts any company fails three. See Company support links - Developer Guide.

php tests/tenant-support.php

tests/job-health.php

What it tests

Background job health (#182) - the four rules agreed on the issue, each with a positive control: freshness reads only scheduled runs (a page load alone is never seen; a scheduled success two hours ago is overdue although Run now and a page ran since); a skip never clears a failure; a failing source stays failing until that source succeeds or stops being expected; not used here and not needed. Also stuck runs, the "never finished" sweep, and JobRun::clean() removing credentials, query strings and tokens from messages.

How it works

It uses a made-up job key, zz_test_job, with its own definition passed straight to jobHealthOne(), so nothing it writes affects a real job's health. With a base URL it mints a temporary API key, checks GET /api/v1/jobs/health (200, every job, no command lines), that ?strict=1 answers 503 while the install is unhealthy, and that a key without job_health.read is refused - then deletes the key.

Run it

php tests/job-health.php http://localhost/freeitsm-app

See Background jobs - Developer Guide.

tests/docker-php-extensions.php

What it tests

That every PHP extension the code uses is in the official Docker image (#207). Until 4.0.0 the image installed only pdo_mysql, so LDAP sign-in, SCORM course upload and reading .docx files could not work in it - and nothing noticed, because a developer's own PHP had the extensions.

docker/php-extensions.txt is the single list: the Dockerfile installs every extension in it, then refuses to finish the build if one does not load. This test checks the other direction. It scans the code for the functions and classes of each extension the base image does not build in (ldap_*, ZipArchive, idn_to_ascii, imap_*, gd, bcmath and others), and fails when the code uses one that the list neither installs nor names under NOT IN THE IMAGE with a reason. imap is the one excluded: see Basic IMAP mailboxes.

It also checks that the Dockerfile's docker-php-ext-install line names every listed extension. Two controls make sure the scan really finds ldap and zip, so a broken pattern cannot pass everything.

The other half - that the extensions actually load - is checked inside the image: by the Dockerfile's own smoke-check step, and by the Docker build check workflow, which builds the image (without publishing) on every change to the Dockerfile or docker/, asks it for each extension, and starts it.

๐Ÿ”‘ Using a new extension? Add it to docker/php-extensions.txt and to the docker-php-ext-install line (with its -dev package), or explain under NOT IN THE IMAGE why the container cannot have it. Add a row to OPTIONAL_EXTENSIONS in the test if it is not there yet.

Run it

php tests/docker-php-extensions.php

Needs nothing. CI runs it in the Web exposure workflow.

tests/php-version-guard.php

What it tests

That a server on PHP older than 8.1 gets a sentence, not a parse error. The floor became 8.1 in 4.0.0; see Raising the PHP floor and Upgrading PHP.

It runs the real old PHP in throwaway containers - the development machine's oldest PHP is 8.0, which is how 8.x syntax crept in unnoticed in the first place - with the repository mounted read-only:

  1. includes/php_version_guard.php itself parses on PHP 7.4.
  2. PHP 7.4 from the command line, through includes/functions.php: the message, exit code 1.
  3. PHP 7.4 serving pages - setup, a page, two API endpoints, the portal catalogue endpoint, the home page: HTTP 500 and the message, never Parse error.
  4. Control: PHP 8.4 serving the same pages never shows the message, so the guard is not simply refusing everything.
  5. Every file the web server or cron runs directly (2,181 of them - all .php outside an includes/ folder and tests/) parses on PHP 7.4. PHP reads a whole file before running any of it, so PHP 8 syntax in an endpoint itself beats the guard. A control checks more than 1,000 files were actually examined.

Section 5 failed on its first run, on the two endpoints that broke the rule (api/self-service/submit_catalogue_form.php, api/rfp-builder/upload_document.php), before they were rewritten - so it is known to catch it.

โš ๏ธ Harness trap: exec() runs through cmd.exe on Windows, which eats % and nested double quotes - so the script avoids php -r "..." and curl -w '%{http_code}', and uses set -f so the shell does not expand find patterns.

CI covers sections 1 and 5 without Docker, in the PHP floor workflow, which also checks every file parses on PHP 8.1.

Run it

php tests/php-version-guard.php

Needs Docker running. Exits 2 (skipped) if it is not.

tests/cap-enum-usage.php

What it tests

That every Cap:: in the codebase is used the safe way (4.0). Cap is an enum: echoed or used as an array key it errors loudly, but compared with a string it is silently false - a check quietly denies, or a safeguard is quietly skipped. So, with PHP's tokenizer:

  1. A bare Cap::X only as the direct argument of a guard; anywhere else capKey(Cap::X). Cap::X->value is flagged too - it is PHP 8 syntax and would stop a settings page parsing on an old PHP.
  2. A guard is handed Cap::X, Cap::from(...), or (inside the guard wrappers) its own Cap parameter - never a raw string.
  3. Every guard function declares its parameter as Cap.
  4. capSelfCheck() is clean and the enum's methods agree with the string helpers.

Controls prove the scanner flags an array value, a comparison and ->value, that Cap::from() throws on an unknown key, and that a Cap parameter refuses a string. See Roles โ€” Developer Guide.

Run it

php tests/cap-enum-usage.php

tests/ai-provider-enum.php

What it tests

That the AiProvider enum (4.0) sends what the old branches sent. Four providers now share one OpenAI-format path; tests/azure-openai and tests/ai-compatible cover Azure and a custom endpoint, and this covers OpenAI and OpenRouter by pointing them at the same local stand-in and reading back what arrived: the address, the Bearer key, the model, OpenRouter's attribution headers (and their absence for OpenAI), on both the chat and the tool-calling paths. Plus the enum itself: stored values, the Anthropic default, the error for an unknown provider.

Run it

php tests/ai-provider-enum.php

No network: every request goes to 127.0.0.1.

tests/ticket-link-relation.php

What it tests

Ticket links through the LinkRelation enum (4.0): each of the four choices stores what it always stored (child_of as the inverse parent), the four guards still refuse (second parent, loop, second master, reciprocal related), the audit lines read the same, and an unknown stored type is left out rather than shown as related.

Run it

php tests/ticket-link-relation.php

Uses three real tickets inside one transaction that is always rolled back - no link or audit row survives, and the last check proves it.


tests/job-scheduler.php

4.8.0, #233. The built-in scheduler - when a job is due, who gets to start it, and run locks. 38 checks, each with a positive control: interval and daily times across midnight and a clock change; two dispatchers racing for one job, exactly one claiming it; a job started twice running once (JobRun::lock), and two databases never sharing a lock; a runner started by the fallback recording itself as a page run; only php cron/x.php --flag commands ever being run; a job with no schedule row getting one with the registry's interval; a started job really starting, without the dispatcher waiting for it.

Writes job_schedules rows, so it runs only when FREEITSM_TEST_DATABASE names the database config.php points at. It never starts a real job.

FREEITSM_TEST_DATABASE=<throwaway db> php tests/job-scheduler.php

See Background jobs - Developer Guide.


tests/domains-internal-endpoints.php

4.8.0, #203 slice 2; part 2b 4.9.0, #243. Certificates on internal HTTPS endpoints, and the optional HTTP check. 38 checks without a database, 60 with one.

  • Part 1: the rules with no connection - network ranges, ports, trust profiles.
  • Part 2: real TLS. It makes a throwaway certificate authority and four small TLS servers on 127.0.0.1 (a good certificate, one inside its warning window, one for the wrong name, a closed port) and checks endpoints against them. A pretend DNS answer points the test names at 127.0.0.1, because a Windows server's resolver does not answer for localhost. Every refusal is proved to make no connection, from the trace.
  • Part 2b: the HTTP check. The test servers answer /ok with 200, /moved with a 301 to an outside address and anything else with 404. It proves the page is asked once, at the resolved address, after the certificate; a wrong status is a problem naming it; an expected redirect is healthy and never followed (nothing else in the trace); an untrusted certificate stops the check before the page is asked.
  • Part 3 (FREEITSM_TEST_DATABASE): through DomainsService - saving endpoints and what is refused (including a bad HTTP path or status code, once Database Verification has added the columns), the check storing results and the domain's health, the expiry feeding the existing certificate alerts, a person without the permission not probing. Rows named zz-..., removed at the end.

Controls throughout: the good endpoint really is healthy and really is connected to, so a refusal means "refused", not "broken".

php tests/domains-internal-endpoints.php
FREEITSM_TEST_DATABASE=<throwaway db> php tests/domains-internal-endpoints.php

tests/domains-expected-records.php

4.9.0, #243 (#203 slice 3). Expected DNS records on an internal domain. 12 checks without a database, 23 with one.

  • Part 1: the comparison, with a stub resolver - never real DNS. Values are compared normalised and sorted (order, case, a trailing dot and IPv6 spelling are never a change); an invalid value is dropped, never compared; no values means the type only has to exist; an SOA is compared by its primary name server; a missing record and different values are problems, the mismatch naming what was found; a resolver that could not be asked is "not checked" - never a pass or a problem - and leaves the run incomplete.
  • Part 2 (FREEITSM_TEST_DATABASE): through DomainsService on a throwaway internal domain and a public one - a valid record saved normalised, empty values stored as "only has to exist", both in the domain's history; then refused: a name outside the domain, an invalid value (named in the message), an unknown type, two CNAMEs, the same name and type twice, a public domain. Rows named zz-..., removed at the end.

Control: a domain with no expected records produces exactly the findings it did before slice 3.

php tests/domains-expected-records.php
FREEITSM_TEST_DATABASE=<throwaway db> php tests/domains-expected-records.php

See Internal Domain Monitoring - Developer Guide.


tests/api-domain-monitoring.php

4.9.0, #243. The REST routes for endpoints, expected records and trust profiles, through real HTTP. 17 checks.

It proves: reading needs only domains.read; changing needs domains.internal and the key's analyst holding Internal monitoring (a key with both permissions acting as an analyst without it gets a 403 naming it); trust profiles need domains.internal even to list; a company-limited key cannot change trust profiles; PATCH is partial (a changed warning window keeps the HTTP path; emptied values keep the host); the screens' validation applies as 422s; a deleted endpoint is then a 404; every change is in the domain's history with source api. The analyst and company checks skip, saying so, when the install has no analyst without the permission or only one company.

It writes API keys into the database and calls the install at the URL, so both variables are required and must be the same install. Without FREEITSM_BASE_URL it refuses to run rather than call another install with keys that exist only here. Its domain, keys and their rate-limit rows are removed at the end.

FREEITSM_TEST_DATABASE=<throwaway db> FREEITSM_BASE_URL=http://localhost/<that install> php tests/api-domain-monitoring.php

See Internal Domain Monitoring - Developer Guide.