Developer Tests Integrations - edmozley/freeitsm GitHub Wiki

πŸ§ͺ Developer Tests β€” Integrations & external services

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

tests/integrations/run.php

What it tests

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.

⚠️ Every negative assertion is paired with a positive control. "The asterisk did not become italics" proves nothing on its own β€” it is equally true of a renderer that dropped the text entirely. So each escaping test also asserts the surrounding content still arrived.

Run it

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.

Reading the result

Ends with a passed: / failed: block rather than the one-line summary most other tests use.

If it fails

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.


tests/workflow-contains.php

What it tests

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.

Run it

php tests/workflow-contains.php

No database, no network.


tests/integrations/templates_check.php

What it tests

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:

  1. the trigger exists
  2. every action type exists
  3. every arg name exists on that action
  4. every required arg is supplied, or is a $configure marker the user fills in
  5. it resolves against this install without throwing

Run it

php tests/integrations/templates_check.php

⚠️ Separate from 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.

If it fails

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.


tests/ai-compatible/run.php

What it tests

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.

  1. The base URL in its three spellings; http/https accepted, file://, gopher:// and a bare host refused.
  2. aiConfigured() - set up with no key, not without an address or model; OpenAI controls.
  3. Chat: the address, the model, no Authorization header without a key, Bearer with one.
  4. 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.
  5. Failures: each explained (model name, missing /v1, not an OpenAI API, key, nothing listening on localhost - 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.
  6. 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; a file:// address or a missing model is refused with nothing changed; and a control that Anthropic β†’ OpenAI still keeps the key.

πŸ”΄ Section 6 writes real settings

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.)

Run it

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. ⚠️ Green proves what FreeITSM sends and how it reads each answer; only a real model server tests a real model.


tests/azure-openai/run.php

What it tests

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.

How it works

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 Windows proc_open() must be given bypass_shell, or the server is a grandchild of cmd.exe and proc_terminate() leaves it running.

Run it

php tests/azure-openai/run.php

48 assertions. Needs nothing else running.

Reading the result

πŸ”΄ 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).

If it fails

"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.


tests/caldav-provider.php

What it tests

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

Run it

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.

Reading the result

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.

If it fails with the fixture running

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.


tests/mailbox-folder-resolve.php

What it tests

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.

How it works

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.

Run it

php tests/mailbox-folder-resolve.php

15 assertions.

If it fails

A path resolving to the wrong folder means a mailbox silently collects from somewhere else β€” which looks to the operator like mail going missing.


tests/webhook-delivery-settings.php

What it tests

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 a failed row, are left alone.
  • stop: the row goes dead straight away, and Replay queues it again.

Run it

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.

If it fails

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.


tests/agent-empty-report-guard.php

What it tests

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.

Run it

php tests/agent-empty-report-guard.php

ZZEG-prefixed, cleaned up including on failure.

If it fails

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.


tests/msteams-alerts.php

What it tests

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.

Run it

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.


tests/threecx-integration.php

What it tests

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.

Run it

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.


tests/zabbix-integration.php

What it tests

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.

Run it

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.


tests/zabbix/run.php

What it tests

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.

Run it

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.

If it fails

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.


tests/entra-sync-mapping.php

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

tests/entra-sync-identity.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.

⚠️ Writes real rows, named 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.


tests/people-groups-scope.php

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

tests/directory-managed-users.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

tests/directory-sync-schedule.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

tests/directory-identity-reuse.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.

⚠️ Writes real rows, named 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

tests/tracker-comment-import.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.

⚠️ Writes real rows (a 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
⚠️ **GitHub.com Fallback** ⚠️