Developer Tests Integrations - edmozley/freeitsm GitHub Wiki
Part of Developer Tests. Issue trackers, AI providers, CalDAV, Microsoft Graph mail folders, and the inventory agent's ingest endpoints.
π The recurring idea on this page: test the decision, not the network. Most
of these suites deliberately stop at the boundary β they prove what FreeITSM
sends and decides, using a fixture or a local stand-in, and say plainly what
they cannot prove. A green run here is not a working integration; it is a correct
request. The exceptions are caldav-provider.php and zabbix/run.php, which really do drive a server.
| Test | Needs |
|---|---|
integrations/run.php |
Nothing |
entra-sync-mapping.php |
Nothing |
entra-sync-identity.php |
A disposable database |
directory-identity-reuse.php |
A disposable database |
integrations/templates_check.php |
Database |
tracker-comment-import.php |
A disposable database created from freeitsm.sql |
azure-openai/run.php |
Nothing (starts its own server) |
ai-compatible/run.php |
Database for section 6 (starts its own server) |
caldav-provider.php |
A running Baikal fixture |
mailbox-folder-resolve.php |
Nothing |
imap-client-parse.php |
Nothing |
mailbox-send-test.php |
Nothing |
imap-client-live.php |
Dovecot and/or GreenMail in Docker - skipped, and named, if neither runs |
mailbox-sent-copy.php |
Nothing (starts its own fake SMTP and IMAP servers) |
agent-empty-report-guard.php |
Database |
zabbix-integration.php |
Database; the web server |
zabbix/run.php |
A real Zabbix 7.0 in Docker (tests/zabbix/docker-compose.yml) |
threecx-integration.php |
Database; the web server |
msteams-alerts.php |
Database; the web server |
webhook-delivery-settings.php |
Database |
people-groups-scope.php |
A disposable database, two companies besides Default |
directory-managed-users.php |
A disposable database |
directory-sync-schedule.php |
A disposable database |
IssueDoc and the provider contract for external issue trackers.
IssueDoc is the piece every later phase writes through β descriptions first,
comments next, everything after. If it is wrong, the next phase is where you find
out and where you would have to rip it up. So it gets a real suite now rather
than an eyeball.
Four renderers produce four genuinely different syntaxes from one document, and the risk is that they drift β one quietly stops escaping, or handles an empty paragraph differently. So every case runs through all four and each is asserted separately.
php tests/integrations/run.php
353 assertions. No database, no network, pure functions β fast and safe to run anywhere. This is the best test to run first on a machine you have just cloned to.
Ends with a passed: / failed: block rather than the one-line summary most
other tests use.
The label names the renderer and the case. Fix the one renderer β do not "normalise" all four to agree, because they are supposed to differ; they target different syntaxes.
The workflow contains / does not contain condition, through the engine's own evaluateOperator(): capitals in the address or the value, spaces around the value, non-English capitals, a missing value and a blank value (#214). 11 checks; with str_contains() put back, 6 fail.
php tests/workflow-contains.php
No database, no network.
That every workflow starter recipe can actually run.
workflow/includes/templates.php promises in its own header that "every
trigger_event here is a real, wired trigger β¦ and every action type is a real
handler β a recipe that can't actually run would be worse than no recipe at
all". Nothing enforced that, and the first tracker recipe shipped with an
add_note action that does not exist (it is add_ticket_note).
For every template, not just the tracker ones:
- the trigger exists
- every action type exists
- every arg name exists on that action
- every required arg is supplied, or is a
$configuremarker the user fills in - it resolves against this install without throwing
php tests/integrations/templates_check.php
run.php on purpose. WorkflowEngine::availableActions()
reads webhook formats from the database, and run.php's whole value is that it
needs no database and no network. Do not merge this back into it.
A recipe offers an action or an argument that does not exist. Someone renamed a handler without updating the templates β fix the template, since the handler name is the real one.
Custom OpenAI-compatible endpoints (GH #192) against a stand-in shaped on Ollama's /v1 API, switched by model name: ok, tools-ok (asks for one tool call), no-tools (refuses tools in Ollama's words), missing (Ollama's "not found, try pulling it first"), needs-key (401 without a key), not-chat (200 JSON that is not a completion), and a plain-text 404 for any path but /v1/chat/completions.
- The base URL in its three spellings;
http/httpsaccepted,file://,gopher://and a bare host refused. -
aiConfigured()- set up with no key, not without an address or model; OpenAI controls. - Chat: the address, the model, no
Authorizationheader without a key, Bearer with one. - Tool calling: the call is run and answered, both rounds at the custom address; a model without tool support fails with the server's words and the right explanation.
- Failures: each explained (model name, missing
/v1, not an OpenAI API, key, nothing listening onlocalhost- naming Docker), and every URL each call reached is the stand-in's, read from the provider's own trace. That is the no-fallback guarantee. - Settings, on a real feature (
lms_ai): save and reload; an OpenAI key is not carried to a custom address, nor a local key back to OpenAI; Remove works; afile://address or a missing model is refused with nothing changed; and a control that Anthropic β OpenAI still keeps the key.
It snapshots every lms_ai_* row the AI layer owns first, and on exit deletes them all and puts the snapshot back - so rows that did not exist do not exist afterwards. (Written after a demo once overwrote a real key that the restore had not captured.)
php tests/ai-compatible/run.php
61 assertions. A refused connection is retried as every provider's is, so section 5 takes a few seconds.
Azure OpenAI's deployment-based endpoints. Exactly three things are new compared
with the OpenAI path this codebase has shipped for months: the URL shape, the
api-key header, and the absence of model β and all three are things we
emit, so all three are assertable.
It also checks the three ways an administrator might paste an endpoint (with or
without a trailing slash, with or without /openai) all normalise to the same
URL, and that a deployment name containing a space is escaped β an unencoded
space breaks the request line.
Since GH #171 it also covers:
a pasted Target URI or Foundry project address cut back to the resource; the
v1 route (no query string, deployment sent as model); a model that refuses
temperature: 0, and one (gpt5-like) that refuses both that and max_tokens
and must succeed in exactly three requests - including through tool calling;
#171's exact failure (a model version date as the API version answered with a
404, not retried, and explained as the API version); and a behavioural
control that an unrelated 400 (a content filter) surfaces after one attempt.
That control used to grep the source for the old retry line, which proved
nothing about behaviour and broke the moment the retry moved.
It starts its own php -S on a free port, serving only the
tests/azure-openai/ directory, and stops it again at the end. mock.php is a
stand-in Azure endpoint that records the request it received; the test then reads
that back and asserts on the bytes we sent. The real cURL path is exercised
rather than stubbed.
The mock is the one file in tests/ allowed to answer HTTP, and it accepts
only the built-in server β it refuses under Apache, nginx and php-fpm.
β οΈ On Windowsproc_open()must be givenbypass_shell, or the server is a grandchild ofcmd.exeandproc_terminate()leaves it running.
php tests/azure-openai/run.php
48 assertions. Needs nothing else running.
π΄ It ends with a warning, and the warning is the point:
Green here means WE SEND THE RIGHT REQUEST. It does not mean Azure accepts it β that needs a real tenant.
Nobody on the project has an Azure subscription to point it at. Content filtering, api-version drift, quota and regional behaviour are all outside what this can prove. Debug tool D014 exists for that gap: it makes one live call, prints the whole exchange and reads back Azure's answer (see AI Providers).
"the mock server never came up" is an environment problem, not a code one β check nothing is blocking loopback sockets. Anything else means the request we build has changed shape.
The CalDAV provider against a real server. The cases that matter:
- an edit keeps what the analyst added (a reminder) β the rule this provider exists to keep
- a stale change token gives a baseline, never "everything was deleted"
- a calendar address on another host is refused before any request β the per-analyst address must not become a way to reach anywhere
- the analyst's own appointments are never reported, only ours
It needs the Baikal fixture, seeded:
docker compose -f docker/carddav-test/docker-compose.yml up -d
bash docker/carddav-test/seed.sh
php tests/caldav-provider.php
Touches no database. Everything it writes is a FreeITSM-named event in the
throwaway itsm and tech2 calendars, and it deletes what it made.
If you see:
FAIL the address is a calendar server <- Failed to connect to localhost port 8092
Is the Baikal fixture running and seeded?
β¦the fixture is not up. That is an environment failure, not a code failure β start the fixture and run it again.
The "stale token gives a baseline" case is the one with teeth: getting it wrong means a sync deletes a calendar's contents rather than resynchronising it.
mailboxResolveFolderId() β reading mail from a folder that is not the Inbox.
/mailFolders/<x>/messages does not take folder names. Graph accepts a short
list of well-known aliases there and treats everything else as an opaque folder
id, so a mailbox told to read freeitsm got:
400 ErrorInvalidIdMalformed β "Id is malformed."
INBOX had always worked purely because it is on the alias list. Reading a
folder by name had never worked for any name off it.
The resolver takes its HTTP fetcher as an argument, so the whole thing runs
against a fixture with no network and no mailbox. The fixture deliberately
contains a custom top-level folder and a same-named one inside the Inbox, so
freeitsm and Inbox/freeitsm must not resolve to the same place.
What it cannot prove is what Graph really returns.
php tests/mailbox-folder-resolve.php
15 assertions.
A path resolving to the wrong folder means a mailbox silently collects from somewhere else β which looks to the operator like mail going missing.
The Delivery settings on System β Webhooks - tries before giving up, the wait for the endpoint, and what happens to a send the worker never finished.
π΄ Written after a worker killed mid-send (its 120 s time limit, a restart) left
the row it had claimed in delivering for ever: never retried, never
replayable, and counted as "in flight" on the dashboard.
- The defaults (6 tries, 15 seconds, retry) are what FreeITSM did before the settings existed, and a stored value the page never offers falls back to them.
- A new delivery copies the tries setting into its row, and changing the setting later leaves a queued row alone.
-
retry: a row claimed 20 minutes ago becomes a failed try on the 1-minute backoff; a cut-off last try goes dead. Controls: a row claimed a minute ago, and afailedrow, are left alone. -
stop: the row goes dead straight away, and Replay queues it again.
php tests/webhook-delivery-settings.php
23 assertions. It never calls webhookRunQueue(), which would send the
install's real queue; everything runs in one transaction that is always rolled
back, settings rows included.
A failed retry check means interrupted sends are stuck again. A failed
control means the age check is gone, and a send still in flight would be
retried while it is being sent.
That an empty agent report does not empty the machine's inventory.
π΄ Both ingest endpoints wipe an asset's hardware tables and reinsert on every
report. The DELETE ran unconditionally while the INSERT was guarded by
!empty(...) β so a report that collected nothing for a category emptied that
category and left it empty. A WMI blip took 226 Device Manager rows with it,
with no error anywhere, until the next good run. Any client holding an API key
could do it deliberately with {"disks":{}}.
An empty list now means "I did not find out", not "there is nothing there": the wipe is skipped and the section is named in the response, because looking like a successful sync of nothing is the other half of the bug.
php tests/agent-empty-report-guard.php
ZZEG-prefixed, cleaned up including on failure.
This one destroys customer data silently and is only noticed later. Treat any red here as blocking, and check both ingest endpoints β the bug was in both.
Microsoft Teams alerts and the Microsoft Teams (Workflows card) format underneath them. The card: an Adaptive Card that stays valid JSON whatever the message holds, with its button on the ticket's address and on FreeITSM's front page when there is no ticket; {{link}}, {{app.url}}, {{ticket.url}} and the ticket's team offered where they resolve. Saving a channel: one workflow per ticked alert with the right trigger and conditions (priorities by order, each team filter on the field its event really carries) and the address encrypted; saving again updates in place, removes unticked alerts, recreates one deleted by hand, and never touches a workflow it did not make; bad input refused. The alerts fire, or do not, through the real engine and webhook queue. Test posts to a local stand-in for Teams, accepted and refused. The API is administrators-only and never returns an address.
php tests/msteams-alerts.php
39 assertions. Needs the database and the app at http://localhost/<folder>/ (or FREEITSM_URL). Dispatches no event (that would notify real people): alerts run one workflow at a time with WorkflowEngine::manualFire(). Queued cards go to .invalid addresses and are deleted with the runs, channels and workflows it made.
The 3CX integration through its real endpoint, with requests shaped exactly as the downloaded CRM template sends them (JSON, the token as a Bearer header). The token: none made, wrong, absent, or in the address as a GET - all refused. Lookups: an international number finds a person stored with a national-looking number and an extension; the last nine digits find them too; a shared number finds nobody; an extension finds nobody; and a lookup says nothing about tickets. Reports: a call in from someone with no ticket opens one assigned to the analyst who took it; the next call is an internal note written by that analyst; the same report twice is noted once; a bad recording link is dropped; a call out opens nothing; a 3CX user who is not an analyst is only listed, or noted by the fallback; the three ticket rules; a shared number; unknown numbers off and on. The template is well-formed XML, with the token only in headers and the outputs 3CX requires. Click-to-call links. The settings API is administrators-only, and Try a call writes nothing.
php tests/threecx-integration.php
60 assertions. Needs the database and the app at http://localhost/<folder>/ (or FREEITSM_URL). Makes two throwaway analysts with .invalid addresses (so no real analyst is assigned or notified) and people with numbers under a country code that does not exist, pauses the ticket.created workflows, and at the end removes every row pointing at the tickets it made and restores the 3CX settings and the ticket counters. There is no real 3CX to test against - see What it cannot prove.
The Zabbix integration through its real endpoint, with events shaped exactly as its media type sends them: the token is required (with no token made, everything is refused; a wrong one is refused; a GET is refused), a problem opens one incident at the mapped priority linked to the CMDB item named like the host, a repeat goes on the same ticket, a recovery resolves it, a recovery with no ticket opens nothing, an unknown host still raises an unlinked incident, a severity sent by name maps like its number, markup from Zabbix is stored as text and a non-web link is dropped, and every event is logged.
php tests/zabbix-integration.php
34 assertions. Needs the database and the app at http://localhost/<folder>/ (or FREEITSM_URL). Pauses the ticket.created workflows while it runs, so the auto-acknowledgement emails nobody, and restores them, the Zabbix settings and the ticket counter at the end.
The same integration against a real Zabbix 7.0 β the proof that Zabbix's own macros, its webhook runner and its pairing of a recovery with its problem ({EVENT.ID}) line up with the endpoint. It builds everything through Zabbix's API (a host named like a temporary CMDB item, a trapper item, a Disaster trigger, the media type using the script read from the settings page, an alert user and an action), then raises a problem, acknowledges it and recovers it, checking FreeITSM at each step. Both sides are cleaned up.
docker compose -f tests/zabbix/docker-compose.yml up -d
php tests/zabbix/run.php
docker compose -f tests/zabbix/docker-compose.yml down -v
12 assertions. Zabbix must reach the host's web server through host.docker.internal.
When nothing arrives, the test prints Zabbix's own view of the item, the problem and the alert, including the error β Media type disabled, a refused token, an unreachable address. See the Zabbix β Developer Guide for the two traps it found.
A Graph user becomes the right person array: field mapping, the email and name fallbacks, attribute name validation, and what an endpoint may show of an error. 69 checks, no database. It calls no Graph.
php tests/entra-sync-mapping.php
4.7.0, PR #235. Whose record is it? Through the real entraImportPerson() and oidcLinkRequester(): a different account on a reused sign-in name or email is a conflict and the old record keeps its name and link; a different object id never overwrites a link; a real sign-in link is never replaced; and a second connection cannot take the import lock. The controls: a new person is created, a rename finds the same record, and an import's placeholder is replaced. 17 checks. See Entra directory sync - developer guide.
zz- and removed at the end. It skips unless FREEITSM_TEST_DATABASE names the database config.php points at. Run it against a disposable install, never your live one.
FREEITSM_TEST_DATABASE=<a throwaway database> php tests/entra-sync-identity.php
A green run does not prove Graph still answers the same way. After a change to a query, run an import against a real directory.
Directory groups in People (PR #237): a client company's provider shows its groups only to analysts with that company; a group out of reach filters the People list to nobody, never everybody; members are limited to the reader's companies; inactive groups only on the Groups tab. 18 checks in a transaction that is always rolled back. Skips unless there are two companies besides Default.
FREEITSM_TEST_DATABASE=<a throwaway database> php tests/people-groups-scope.php
PR #238: userDeleteBlockedByDirectory() refuses a person an import keeps up to date while the source exists - switched off too - and allows them once it is deleted; entraLogMembershipChanges() writes one join / leave entry per person. 15 checks, rolled back.
FREEITSM_TEST_DATABASE=<a throwaway database> php tests/directory-managed-users.php
PR #239: the line under Run on each directory page, from jobScheduleSummary() - daily and interval schedules, fallback only, nothing running it, switched off, a source switched off, no schedule row yet. 19 checks, rolled back; it changes the schedule and the heartbeat inside the transaction.
FREEITSM_TEST_DATABASE=<a throwaway database> php tests/directory-sync-schedule.php
4.9.0, security fix GHSA-gwgh-rphf-wjcq. A reused email address or sign-in name must never hand one person another's record. Through the real functions: LDAP sign-in, for analysts and portal users, refuses an email match on an account already linked to a different directory identity (ldapLinkedToOther()); the shared LDAP and CardDAV import matcher calls a name match on a record linked to a different identity taken, and one belonging to another sign-in source other_directory (dsyncNameMatchRefused()); Entra reports the same case as a conflict. The controls: the same identity signs in again, a genuinely new person is created, and an unlinked record is still adopted by email - so a refusal means refused, not broken. 14 checks; on 4.8.0, 7 of them fail. Write-up: a directory account could be matched to someone else.
zz- and removed at the end. It skips unless FREEITSM_TEST_DATABASE names the database config.php points at. Run it against a disposable install, never your live one.
FREEITSM_TEST_DATABASE=<a throwaway database> php tests/directory-identity-reuse.php
Importing a Jira / Azure DevOps comment as a ticket note (#281): the note arrives with analyst_id NULL (System) and the comment's text; the same comment again is skipped; a note refused for another reason still raises the error and leaves no comment-map row behind; Database Verify turns an old author-0 note into NULL, removes a stuck map row and keeps a good one, and the column allows NULL.
Use a database created from freeitsm.sql, so it has the fk_notes_analysts foreign key a fresh install has - without it the original bug cannot show. On the code before the fix, 8 of its 13 checks fail.
ZZ test tracker #281 connection and its notes), removed at the end, and runs Database Verify. It skips unless FREEITSM_TEST_DATABASE names the database config.php points at.
FREEITSM_TEST_DATABASE=<a throwaway database> php tests/tracker-comment-import.php