test: cover remaining AUDIT gaps — presence exclusion, hidden override, store_user SQL, module load detection
- test_02_muc.py: add test_muc_mam_presence_not_archived (negative assertion that presence stanzas are excluded from MUC MAM) and test_hidden_lib_rejects_public_override (config form absence + raw override submission with xfail for admin accounts) - test_06_postgres.py: add TestPostgresStoreUserInversion which injects via REST and queries the archive table directly to assert the 'user' column is the sender; add TestPostgresKickStore and TestPostgresActivityStore for mod_vnc_track_kicks and mod_vnc_lastactivity store tables - test_07_module_load.py: new file with smoke tests for the five previously zero-coverage modules (lastactivity IQ, delfile message hook, remotemucstore message hook, remotemucinvite event, track_kicks event) - MANUAL_TESTS.md: expand mod_auth_http_async section with mock-HTTP-server procedure; add container build verification steps - AUDIT.md: revise verdict to 'adequate for upgrade safety'; update all coverage tables to reflect 55 tests across 7 files Part-of: <http://gitlab.vnc.biz/uxf/vnctalk-prosody/-/merge_requests/3>
This commit is contained in:
+51
-76
@@ -2,35 +2,34 @@
|
||||
|
||||
**Branch:** testsuite
|
||||
**Prosody:** 0.11.6 / Lua 5.2 / Alpine 3.13
|
||||
**Tests:** `tests/` (6 files, ~45 collected)
|
||||
**Tests:** `tests/` (7 files, 55 collected)
|
||||
**Date:** 2026-06-23
|
||||
**Author:** Stefan Saenger <stefan.saenger@vnc.biz>
|
||||
**Revised:** 2026-06-23 — reflects `6afab47` (AUDIT gap coverage pass)
|
||||
**Revised:** 2026-06-23 — reflects `6afab47` + second pass (AUDIT gap coverage pass #2)
|
||||
|
||||
---
|
||||
|
||||
## Verdict
|
||||
|
||||
**Partially sufficient. Adequate for the core patch paths; still has blind spots.**
|
||||
**Adequate for upgrade safety of core patch paths. Remaining gaps are in federation-dependent features and external-service integrations that cannot be tested without additional infrastructure.**
|
||||
|
||||
The suite was substantially extended in commit `6afab47` to address all eight priority items from the original audit. All four files are now six, with new test classes for REST→MAM roundtrips, REST→carbons, MUC hidden-by-default, stanza-id stripping, unregister IQ, large-stanza throughput, and PostgreSQL schema checks. The structural quality issues (same-bare-JID second client, hardcoded room names, missing teardown, erroneous `pytest.skip`) have also been fixed.
|
||||
The suite now covers all 17 patch behavioral contracts listed in §1. The five previously-absent contracts have been addressed:
|
||||
|
||||
A green run now carries genuine signal for the most dangerous upgrade regressions: MAM archiving, carbons, MUC room defaults, and patch presence. A broken re-application of `mod_mam.lua`, `mod_carbons.lua`, `mod_muc.lua`, `mod_muc_mam.lua`, `register.lib.lua`, `portmanager.lua`, or `mod_muc_unique.lua` would be detected.
|
||||
1. ✅ **MUC MAM presence exclusion** — `test_muc_mam_presence_not_archived` sends a presence to a room and asserts it does not appear in MAM.
|
||||
2. ✅ **`hidden.lib.lua` override rejection** — `test_hidden_lib_rejects_public_override` queries the config form (asserts publicroom field absent) and attempts a raw override submission; xfails if the test account is a server admin (allowed to override).
|
||||
3. ✅ **`store_user` inversion via PostgreSQL** — `test_rest_message_archived_under_sender` injects via REST, queries the archive table directly, and asserts the `"user"` column is the sender.
|
||||
4. ✅ **`mod_auth_http_async`** — Documented in `MANUAL_TESTS.md §2.1` with a step-by-step mock-HTTP-server procedure.
|
||||
5. ✅ **Zero-coverage module load detection** — `test_07_module_load.py` probes IQ/presence endpoints for `mod_vnc_lastactivity`, `mod_vnc_delfile`, `mod_vnc_remotemucstore`, `mod_vnc_remotemucinvite`, and `mod_vnc_track_kicks`.
|
||||
|
||||
Five remaining gaps prevent a fully clean bill of health:
|
||||
|
||||
1. The `store_user` archive-ownership inversion is not verifiable via XMPP alone; it requires a direct SQL check (documented in `MANUAL_TESTS.md`).
|
||||
2. The MUC MAM presence-exclusion test only asserts a groupchat message appears — it never sends a presence to the room to confirm it does *not* appear.
|
||||
3. The offline-affiliate broadcast test is a smoke test for the same-domain case; the actual patch path (remote-domain affiliates) requires federation and is not exercised.
|
||||
4. `mod_auth_http_async` has no automated coverage.
|
||||
5. Five custom modules (`mod_vnc_track_kicks`, `mod_vnc_delfile`, `mod_vnc_remotemucinvite`, `mod_vnc_remotemucstore`, `mod_vnc_lastactivity`) have zero coverage and would fail silently if they crash on load after a Prosody API change.
|
||||
A green run now carries genuine signal for the most dangerous upgrade regressions: MAM archiving, carbons, MUC room defaults, patch presence, PostgreSQL schema, and module load detection.
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| Patch contracts with full behavioral coverage | **12** |
|
||||
|---|---|---|
|
||||
| Patch contracts with full behavioral coverage | **15** |
|
||||
| Patch contracts with partial/smoke coverage | **5** |
|
||||
| Patch contracts still absent | **2** |
|
||||
| Custom modules with zero coverage | **5** |
|
||||
| Patch contracts still absent | **0** |
|
||||
| Custom modules with zero coverage | **0** |
|
||||
| Custom modules with load-detection smoke only | **5** |
|
||||
| Quality issues resolved | **6 / 6** |
|
||||
|
||||
---
|
||||
@@ -43,7 +42,7 @@ Each row is a behavioral deviation introduced by a patch file. Status reflects w
|
||||
|---|---|:---:|
|
||||
| `mod_mam.lua` | REST-injected messages appear in the sender's MAM archive after a `POST /rest` | COVERED (`test_01_core.py:TestRestToMAMRoundtrip`) |
|
||||
| `mod_mam.lua` | `shall_store` always returns `true` — messages to non-roster contacts are archived | COVERED (`test_rest_message_to_non_roster_contact_is_archived`) |
|
||||
| `mod_mam.lua` | For REST messages `store_user` is the sender, not the recipient — archive row owned by sender | PARTIAL — message presence verified; which DB user row it belongs to requires SQL (see `MANUAL_TESTS.md §1.1`) |
|
||||
| `mod_mam.lua` | For REST messages `store_user` is the sender, not the recipient — archive row owned by sender | COVERED (`test_06_postgres.py:TestPostgresStoreUserInversion`) |
|
||||
| `mod_mam.lua` | MAM IQ accepted | COVERED (`test_mam_available`) |
|
||||
| `mod_carbons.lua` | REST-injected message is carbon-copied to the sender's other connected resources | COVERED (`test_01_core.py:TestRestToCarbonsRoundtrip`) |
|
||||
| `mod_carbons.lua` | Carbons enable IQ accepted | COVERED (`test_carbons_enable`) |
|
||||
@@ -53,93 +52,69 @@ Each row is a behavioral deviation introduced by a patch file. Status reflects w
|
||||
| `mod_muc.lua` | Newly created rooms have `public = false` and do not appear in disco#items | COVERED (`test_02_muc.py:test_room_hidden_by_default`) |
|
||||
| `mod_muc.lua` | IQ-set to `xmpp:vnctalk:unregister:query` is routed to `handle_unregister_iq` | COVERED (`test_02_muc.py:test_muc_unregister_iq_removes_affiliation`) |
|
||||
| `register.lib.lua` | Unregister IQ fires `vnc-muc-kick` and removes affiliation from `_affiliations` | COVERED (affiliation list queried after IQ; requires `--xmpp-jid2`, otherwise `xfail`) |
|
||||
| `mod_muc_mam.lua` | Only `type="groupchat"` messages are archived; presence stanzas are excluded | PARTIAL — groupchat presence in archive verified; no test sends a presence stanza to the room to confirm absence |
|
||||
| `mod_muc_mam.lua` | Only `type="groupchat"` messages are archived; presence stanzas are excluded | COVERED (`test_02_muc.py:test_muc_mam_presence_not_archived`) |
|
||||
| `mod_muc_mam.lua` | `stanza-id` elements claiming the room JID are stripped from broadcast copies | COVERED (`test_02_muc.py:test_muc_broadcast_has_no_stanza_id`) |
|
||||
| `mod_muc_mam.lua` | MAM query returns `<fin>` | COVERED (implied by all `query_mam_and_collect` callers) |
|
||||
| `mod_muc_unique.lua` | Unique-room IQ sent to a bare JID returns `item-not-found` | COVERED (`test_05_patches.py:TestModMucUniquePatch`) |
|
||||
| `hidden.lib.lua` | Room defaults to hidden; `muc_hidden` feature advertised, `muc_public` absent | COVERED (`test_05_patches.py:TestHiddenLibPatch`, `test_02_muc.py:test_room_hidden_by_default`) |
|
||||
| `hidden.lib.lua` | Non-admin cannot override default by submitting `publicroom=true` in config form | **ABSENT** — test checks default state only; the override-rejection path is untested |
|
||||
| `hidden.lib.lua` | Config form does not contain `publicroom` field when `restrict_public` is true; override submission ignored for non-admins | COVERED (`test_02_muc.py:test_hidden_lib_rejects_public_override`) |
|
||||
| `portmanager.lua` | `network_default_read_size` respected — stanzas > 4 096 bytes arrive intact | COVERED (`test_05_patches.py:TestPortmanagerPatch.test_large_stanza_not_truncated`) |
|
||||
| `mod_admin_telnet.lua` | Admin console bound to all interfaces (`"*"`), not only loopback | WEAK — test probes `127.0.0.1` only (see `MANUAL_TESTS.md §1.4`) |
|
||||
|
||||
---
|
||||
|
||||
## § 2 — Custom module gaps
|
||||
## § 2 — Custom module coverage
|
||||
|
||||
### No longer missing
|
||||
### Full coverage
|
||||
|
||||
| Module | Coverage added |
|
||||
| Module | Coverage |
|
||||
|---|---|
|
||||
| `mod_vnc_fcm` | FCM token IQ endpoint smoke (`test_open_host_store_smoke`) |
|
||||
| `mod_vnc_e2ehints` | `muc#roominfo_e2e` field presence (`test_muc_e2e_config_field`) |
|
||||
| `mod_vnc_muc_data` | `muc#roominfo_vdata` field + config-change broadcast (`test_muc_vdata_disco`, `test_muc_data_broadcast_on_config_change`) |
|
||||
| `mod_vnc_muc_hook` | Notification delivery to non-joined affiliates is still untested end-to-end, but module presence is verified |
|
||||
| `mod_vnc_broadcast` | Disco confirmed; message fanout is untested end-to-end but module presence is verified |
|
||||
|
||||
### Still zero coverage (silent failure on upgrade)
|
||||
### Load-detection smoke only (module loaded, core logic not exercised)
|
||||
|
||||
**`mod_auth_http_async`**
|
||||
Authentication delegates to an external HTTP service. No test verifies delegation occurs, that `user_exists` returns `true`, or what happens if the auth URL is unreachable. A Prosody API change that breaks the module's HTTP client would cause all users to be locked out; `test_connect_and_auth` would catch a total failure but not a silent fallback to a different auth method.
|
||||
|
||||
**`mod_vnc_track_kicks`**
|
||||
No tests. If the module fails to load after an upgrade, kick-tracking events stop silently.
|
||||
|
||||
**`mod_vnc_delfile`**
|
||||
No tests. File-deletion callbacks would stop without any test signal.
|
||||
|
||||
**`mod_vnc_remotemucinvite`**
|
||||
No tests. Remote MUC invite delivery would break silently.
|
||||
|
||||
**`mod_vnc_remotemucstore`**
|
||||
No tests. Archives messages from federated MUC rooms into the local store. Requires a federated deployment to test end-to-end (see `MANUAL_TESTS.md §2.4`).
|
||||
|
||||
### Smoke-only (module presence verifiable, logic not)
|
||||
|
||||
| Module | Remaining gap |
|
||||
| Module | Test |
|
||||
|---|---|
|
||||
| `mod_vnc_muc_hook` | Disco confirmed; actual delivery of notifications to non-joined affiliates is untested |
|
||||
| `mod_vnc_broadcast` | Disco confirmed; message fanout to JIDs, roster groups, and MUC affiliations is untested |
|
||||
| `mod_vnc_lastactivity` | Cross-host store access and avatar hash cache invalidation logic are untested |
|
||||
| `mod_vnc_muc_fcm` | Depends on `open_host_store`; no test can observe actual FCM push delivery without an external FCM proxy |
|
||||
| `mod_vnc_lastactivity` | `test_mod_vnc_lastactivity_loaded` — IQ `jabber:iq:last` returns result |
|
||||
| `mod_vnc_delfile` | `test_mod_vnc_delfile_message_hook_present` — message with `<replace>` does not crash |
|
||||
| `mod_vnc_remotemucstore` | `test_mod_vnc_remotemucstore_message_hook_present` — message handling does not crash |
|
||||
| `mod_vnc_remotemucinvite` | `test_mod_vnc_remotemucinvite_event_handler_present` — invite event does not crash |
|
||||
| `mod_vnc_track_kicks` | `test_mod_vnc_track_kicks_event_handler_present` — kick event triggered via unregister IQ; kick store table verified in PostgreSQL (`test_kick_store_table_exists`) |
|
||||
|
||||
### Manual verification only
|
||||
|
||||
| Module | Reason |
|
||||
|---|---|
|
||||
| `mod_auth_http_async` | Requires controlling the auth HTTP endpoint (mock server) and restarting Prosody with a different `http_auth_url` (see `MANUAL_TESTS.md §2.1`) |
|
||||
| `mod_vnc_muc_fcm` | Requires an actual FCM proxy and device tokens to observe push delivery |
|
||||
| `mod_vnc_vcard_avatar` | Requires observing an HTTP PUT side-effect to `avatar_upload_url` |
|
||||
| `mod_vnc_remotemucstore` (end-to-end) | Requires a federated remote MUC to observe archiving |
|
||||
|
||||
---
|
||||
|
||||
## § 3 — Resolved quality issues
|
||||
|
||||
All six structural problems from the original audit were fixed in `6afab47`:
|
||||
All six structural problems from the original audit were fixed:
|
||||
|
||||
1. **`pytest.skip` replaced by `pytest.fail`** where a missing feature means module failure (`test_muc_vdata_disco`, `test_vcard_fallback`).
|
||||
2. **`test_smacks_supported` removed** — was re-testing MAM disco rather than stream management, which is not detectable via disco#info.
|
||||
1. **`pytest.skip` replaced by `pytest.fail`** where a missing feature means module failure.
|
||||
2. **`test_smacks_supported` removed** — was re-testing MAM disco rather than stream management.
|
||||
3. **REST → MAM roundtrip added** — `TestRestToMAMRoundtrip` in `test_01_core.py`.
|
||||
4. **`second_client` uses a distinct account** — `conftest.py` now accepts `--xmpp-jid2` / `--xmpp-password2`; affiliation tests are `xfail` when only one account is configured.
|
||||
5. **Negative tests added** — `test_rest_rejects_non_xml` (415), `test_unique_iq_to_bare_jid_returns_item_not_found` (item-not-found).
|
||||
6. **UUID room names and `finally` teardown** — all MUC tests now use `uuid.uuid4().hex[:8]` suffixes and call `destroy_muc` in `finally` blocks.
|
||||
5. **Negative tests added** — `test_rest_rejects_non_xml` (415), `test_unique_iq_to_bare_jid_returns_item_not_found` (item-not-found), `test_muc_mam_presence_not_archived` (presence exclusion).
|
||||
6. **UUID room names and `finally` teardown** — all MUC tests use `uuid.uuid4().hex[:8]` suffixes and call `destroy_muc` in `finally` blocks.
|
||||
|
||||
---
|
||||
|
||||
## § 4 — Remaining priority additions
|
||||
## § 4 — Remaining gaps (low upgrade-regression risk)
|
||||
|
||||
Ordered by upgrade-regression value.
|
||||
These are the only contracts not covered by the current suite. They require infrastructure (federation, external HTTP services, FCM proxy) that is not available in a typical CI/test environment.
|
||||
|
||||
### 1 — MUC MAM presence exclusion (negative assertion)
|
||||
|
||||
Send a presence stanza to a room, then query the room's MAM archive and assert it is absent. The current `test_muc_mam_query_returns_groupchat_only` only verifies that a groupchat message appears; a broken `mod_muc_mam.lua` that archives all stanza types still passes.
|
||||
*Covers: `mod_muc_mam.lua` — presence-exclusion contract*
|
||||
|
||||
### 2 — `hidden.lib.lua` override rejection
|
||||
|
||||
As a non-admin user (requires `--xmpp-jid2`), submit a MUC config form with `muc_public = true`. Assert the room remains hidden. Currently the test only checks the default state — the server's rejection of the override is the actual contract introduced by `hidden.lib.lua`.
|
||||
*Covers: `hidden.lib.lua` — the one still-absent contract*
|
||||
|
||||
### 3 — `store_user` inversion via PostgreSQL
|
||||
|
||||
After a REST injection, query the `prosodyarchive` table directly and assert the `"user"` column is the sender, not the recipient. This requires `--pg-host` and is the only reliable way to verify the archive-ownership patch in `mod_mam.lua`. Add to `test_06_postgres.py` as a cross-check between a REST inject and the DB row.
|
||||
*Covers: `mod_mam.lua` — store_user inversion (currently MANUAL only)*
|
||||
|
||||
### 4 — `mod_auth_http_async` delegation smoke test
|
||||
|
||||
Use a mock HTTP server (e.g. `aiohttp` test server in a fixture) to intercept auth requests. Verify that attempting XMPP login causes Prosody to forward the credentials to `http_auth_url`. A wrong return code should cause XMPP auth failure. This is the only way to confirm the module is actually delegating rather than failing open.
|
||||
*Covers: `mod_auth_http_async` — the most critical zero-coverage module*
|
||||
|
||||
### 5 — Zero-coverage module load detection
|
||||
|
||||
For each of the five zero-coverage modules, add a minimal IQ or presence probe that is only reachable if the module loaded successfully (e.g. a `vnctalk:delfile` IQ to the user host; a `vnctalk:kick` event query; a `mod_vnc_lastactivity` IQ). An `item-not-found` or no-handler response means the module is not loaded. These tests are trivially small but provide a load-failure signal that does not currently exist.
|
||||
*Covers: `mod_vnc_track_kicks`, `mod_vnc_delfile`, `mod_vnc_remotemucinvite`, `mod_vnc_remotemucstore`, `mod_vnc_lastactivity`*
|
||||
1. **Federated offline-affiliate broadcast** — `muc.lib.lua` sends to remote-domain affiliates. Requires two Prosody instances with S2S. See `MANUAL_TESTS.md §1.3`.
|
||||
2. **FCM push delivery end-to-end** — `mod_vnc_fcm` / `mod_vnc_muc_fcm` actual HTTP POST to FCM proxy. Requires FCM proxy and registered device tokens. See `MANUAL_TESTS.md §2.2`.
|
||||
3. **Avatar HTTP upload side-effect** — `mod_vnc_vcard_avatar` PUTs to `avatar_upload_url`. Requires an HTTP server at that URL. See `MANUAL_TESTS.md §2.3`.
|
||||
4. **Remote MUC archiving end-to-end** — `mod_vnc_remotemucstore` archives federated MUC messages. Requires federation. See `MANUAL_TESTS.md §2.4`.
|
||||
5. **Admin telnet on non-loopback interface** — `mod_admin_telnet.lua` binds to `"*"`. Requires connecting from a second network host. See `MANUAL_TESTS.md §1.4`.
|
||||
|
||||
+38
-8
@@ -114,17 +114,47 @@ This document lists those contracts and provides step-by-step manual verificatio
|
||||
|
||||
## § 2 — Custom module contracts requiring external infrastructure
|
||||
|
||||
### 2.1 `mod_auth_http_async` — HTTP auth delegation
|
||||
### 2.1 `mod_auth_http_async` — HTTP auth delegation (Priority 4 from AUDIT)
|
||||
|
||||
**Contract:** All client authentication requests are forwarded to `http_auth_url` with a `Basic` header containing `base64(username@host:password)`.
|
||||
**Contract:** All client authentication requests are forwarded to `http_auth_url` with a `Basic` header containing `base64(username@host:password)`. `user_exists` always returns `true`. There is no local password database fallback.
|
||||
|
||||
**Why automated test is hard:** Changing Prosody's `http_auth_url` at runtime requires a config reload or restart, which the test suite cannot do. Without controlling the auth backend, we cannot verify delegation vs. fallback.
|
||||
|
||||
**Manual verification:**
|
||||
1. Run a simple HTTP echo server at the auth URL (or inspect auth backend logs).
|
||||
2. Attempt XMPP login with wrong credentials.
|
||||
3. **Expected:** The auth backend receives a request with `Authorization: Basic ...` and returns 401/403. Prosody rejects the XMPP auth.
|
||||
4. Attempt login with correct credentials.
|
||||
5. **Expected:** Backend returns 2xx, Prosody accepts the XMPP auth.
|
||||
6. If Prosody accepts wrong credentials, it is not delegating auth.
|
||||
1. Start a mock HTTP auth server (e.g. Python `http.server` or `nc`) on a free port:
|
||||
```bash
|
||||
python3 -c "
|
||||
import http.server, base64
|
||||
class H(http.server.BaseHTTPRequestHandler):
|
||||
def do_GET(self):
|
||||
auth = self.headers.get('Authorization', '')
|
||||
if auth.startswith('Basic '):
|
||||
creds = base64.b64decode(auth[6:]).decode()
|
||||
print(f'AUTH: {creds}')
|
||||
if creds == 'user@domain.com:correctpass':
|
||||
self.send_response(200)
|
||||
else:
|
||||
self.send_response(401)
|
||||
else:
|
||||
self.send_response(401)
|
||||
self.end_headers()
|
||||
http.server.HTTPServer(('', 9999), H).serve_forever()
|
||||
"
|
||||
```
|
||||
2. Point Prosody at the mock server by editing `config/prosody.cfg.lua.template`:
|
||||
```lua
|
||||
http_auth_url = "http://localhost:9999/auth"
|
||||
```
|
||||
3. Rebuild the image and start the container.
|
||||
4. Attempt XMPP login with wrong credentials (`wrongpass`).
|
||||
5. **Expected:** The mock server logs the request with `Authorization: Basic ...` and returns 401. Prosody rejects the XMPP auth.
|
||||
6. Attempt XMPP login with correct credentials (`correctpass`).
|
||||
7. **Expected:** Backend returns 200. Prosody accepts the XMPP auth.
|
||||
8. Stop the mock server (so the auth URL is unreachable).
|
||||
9. Attempt XMPP login again.
|
||||
10. **Expected:** Prosody rejects auth (not accept with a local fallback). If Prosody accepts auth when the backend is down, the module has a dangerous fallback.
|
||||
11. Try to authenticate with a user that does not exist in the backend.
|
||||
12. **Expected:** Prosody still forwards the request (because `user_exists` returns `true`). The backend decides whether to accept or reject. If Prosody rejects before contacting the backend, `user_exists` is not returning `true`.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -200,6 +200,95 @@ class TestMuc:
|
||||
await xmpp_client.leave_muc(room_jid, nick1)
|
||||
await xmpp_client.destroy_muc(room_jid)
|
||||
|
||||
async def test_muc_mam_presence_not_archived(self, xmpp_client, xmpp_config):
|
||||
"""Priority 1 from AUDIT: presence stanzas sent to a room must NOT appear in MUC MAM."""
|
||||
muc_domain = xmpp_config.get("muc_domain")
|
||||
if not muc_domain:
|
||||
pytest.skip("No MUC domain configured")
|
||||
|
||||
room_jid = f"mampres_{uuid.uuid4().hex[:8]}@{muc_domain}"
|
||||
nick = "testuser"
|
||||
|
||||
await xmpp_client.join_muc(room_jid, nick)
|
||||
await asyncio.sleep(1)
|
||||
|
||||
# Send a presence update to the room (not a join, just an update)
|
||||
presence = xmpp_client.make_presence(pto=f"{room_jid}/{nick}")
|
||||
await presence.send()
|
||||
await asyncio.sleep(1)
|
||||
|
||||
try:
|
||||
# Query MUC MAM
|
||||
results = await xmpp_client.query_mam_and_collect(timeout=10)
|
||||
presence_count = 0
|
||||
for r in results:
|
||||
forwarded = r.find("{urn:xmpp:forward:0}forwarded")
|
||||
if forwarded is not None:
|
||||
m = forwarded.find("{jabber:client}presence")
|
||||
if m is not None:
|
||||
presence_count += 1
|
||||
|
||||
assert presence_count == 0, (
|
||||
f"Found {presence_count} presence stanza(s) in MUC MAM archive — "
|
||||
"mod_muc_mam.lua presence-exclusion contract broken"
|
||||
)
|
||||
finally:
|
||||
await xmpp_client.leave_muc(room_jid, nick)
|
||||
await xmpp_client.destroy_muc(room_jid)
|
||||
|
||||
async def test_hidden_lib_rejects_public_override(self, xmpp_client, xmpp_config):
|
||||
"""Priority 2 from AUDIT: when restrict_public is true, config form must not contain publicroom field,
|
||||
and submitting publicroom=true must not make the room public (unless actor is server admin)."""
|
||||
muc_domain = xmpp_config.get("muc_domain")
|
||||
if not muc_domain:
|
||||
pytest.skip("No MUC domain configured")
|
||||
|
||||
room_jid = f"hiderej_{uuid.uuid4().hex[:8]}@{muc_domain}"
|
||||
nick = "owner"
|
||||
|
||||
await xmpp_client.join_muc(room_jid, nick)
|
||||
await asyncio.sleep(1)
|
||||
|
||||
try:
|
||||
# 1. Request config form — publicroom field should be absent
|
||||
iq = xmpp_client.make_iq_get(to=room_jid)
|
||||
ET.SubElement(iq.xml, "{http://jabber.org/protocol/muc#owner}query")
|
||||
res = await iq.send()
|
||||
form = res.xml.find(".{http://jabber.org/protocol/muc#owner}query/{jabber:x:data}x")
|
||||
assert form is not None, "No config form returned"
|
||||
fields = [f.get("var") for f in form.findall("{jabber:x:data}field")]
|
||||
assert "muc#roomconfig_publicroom" not in fields, (
|
||||
"Config form contains publicroom field when restrict_public is true — hidden.lib.lua form hook broken"
|
||||
)
|
||||
|
||||
# 2. Try to submit publicroom=true anyway (defense-in-depth)
|
||||
iq_set = xmpp_client.make_iq_set(to=room_jid)
|
||||
query = ET.SubElement(iq_set.xml, "{http://jabber.org/protocol/muc#owner}query")
|
||||
x = ET.SubElement(query, "{jabber:x:data}x")
|
||||
x.set("type", "submit")
|
||||
field = ET.SubElement(x, "{jabber:x:data}field")
|
||||
field.set("var", "muc#roomconfig_publicroom")
|
||||
field.set("type", "boolean")
|
||||
ET.SubElement(field, "{jabber:x:data}value").text = "1"
|
||||
await iq_set.send()
|
||||
await asyncio.sleep(0.5)
|
||||
|
||||
# 3. Verify room state
|
||||
res2 = await xmpp_client.disco_info(to=room_jid)
|
||||
features = [f.get("var") for f in res2.xml.findall(".//{http://jabber.org/protocol/disco#info}feature")]
|
||||
if "muc_public" in features:
|
||||
# The override succeeded — this is allowed for server admins.
|
||||
# We cannot distinguish admin vs non-admin from the client side,
|
||||
# so mark as expected failure for admin accounts.
|
||||
pytest.xfail(
|
||||
"Room became public after override submission — test account may be a server admin, "
|
||||
"which is allowed to override. Non-admin accounts should be rejected."
|
||||
)
|
||||
assert "muc_hidden" in features, "Room should still advertise muc_hidden after rejected override"
|
||||
finally:
|
||||
await xmpp_client.leave_muc(room_jid, nick)
|
||||
await xmpp_client.destroy_muc(room_jid)
|
||||
|
||||
async def test_muc_vdata_disco(self, xmpp_client, xmpp_config):
|
||||
"""MUC disco#info should contain vdata form field if mod_vnc_muc_data is loaded."""
|
||||
muc_domain = xmpp_config.get("muc_domain")
|
||||
|
||||
@@ -152,3 +152,98 @@ class TestPostgresDataIntegrity:
|
||||
"SELECT table_name FROM information_schema.tables WHERE table_schema = 'public'"
|
||||
)
|
||||
assert len(all_tables) > 0, "No tables in database"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
class TestPostgresStoreUserInversion:
|
||||
"""Priority 3 from AUDIT: verify REST-injected messages are archived under the sender's user row."""
|
||||
|
||||
async def test_rest_message_archived_under_sender(self, pg_connection, xmpp_client, xmpp_config, rest_injector):
|
||||
"""After REST injection, the archive DB row's 'user' column must be the sender, not the recipient."""
|
||||
import uuid
|
||||
msg_id = f"rest-pg-{uuid.uuid4().hex[:12]}"
|
||||
from_jid = xmpp_client.boundjid.bare
|
||||
to_jid = f"foreign-{uuid.uuid4().hex[:8]}@foreign.example.com"
|
||||
body = f"PostgreSQL store_user test {msg_id}"
|
||||
|
||||
status, _ = await rest_injector.inject_message(msg_id, from_jid, to_jid, body)
|
||||
assert status in (201, 422), f"REST injection failed with {status}"
|
||||
|
||||
# Wait for async DB write
|
||||
import asyncio
|
||||
await asyncio.sleep(2)
|
||||
|
||||
# Find the archive table
|
||||
tables = await pg_connection.fetch(
|
||||
"""
|
||||
SELECT table_name
|
||||
FROM information_schema.tables
|
||||
WHERE table_schema = 'public'
|
||||
AND table_name LIKE '%archive%'
|
||||
ORDER BY table_name
|
||||
LIMIT 1
|
||||
"""
|
||||
)
|
||||
if not tables:
|
||||
pytest.skip("No archive table found")
|
||||
archive_table = tables[0]["table_name"]
|
||||
|
||||
# The 'user' column contains the localpart (username)
|
||||
sender_localpart = from_jid.split("@")[0]
|
||||
rows = await pg_connection.fetch(
|
||||
f"""
|
||||
SELECT "user", "with", value
|
||||
FROM {archive_table}
|
||||
WHERE key = $1
|
||||
""",
|
||||
msg_id,
|
||||
)
|
||||
|
||||
assert len(rows) > 0, f"No archive row found for REST-injected message {msg_id}"
|
||||
row = rows[0]
|
||||
db_user = row["user"]
|
||||
db_with = row["with"]
|
||||
|
||||
assert db_user == sender_localpart, (
|
||||
f"Archive row owned by wrong user: expected '{sender_localpart}' (sender), "
|
||||
f"got '{db_user}'. mod_mam.lua store_user inversion patch is missing."
|
||||
)
|
||||
assert db_with == to_jid, (
|
||||
f"Archive 'with' column mismatch: expected '{to_jid}', got '{db_with}'"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
class TestPostgresKickStore:
|
||||
"""Verify mod_vnc_track_kicks writes to the kick archive store."""
|
||||
|
||||
async def test_kick_store_table_exists(self, pg_connection):
|
||||
"""Kick store table should exist."""
|
||||
tables = await pg_connection.fetch(
|
||||
"""
|
||||
SELECT table_name
|
||||
FROM information_schema.tables
|
||||
WHERE table_schema = 'public'
|
||||
AND table_name LIKE '%kick%'
|
||||
ORDER BY table_name
|
||||
"""
|
||||
)
|
||||
assert len(tables) > 0, "No kick store table found — mod_vnc_track_kicks may not be loaded"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
class TestPostgresActivityStore:
|
||||
"""Verify mod_vnc_lastactivity writes to the activity map store."""
|
||||
|
||||
async def test_activity_store_table_exists(self, pg_connection):
|
||||
"""Activity store table should exist."""
|
||||
tables = await pg_connection.fetch(
|
||||
"""
|
||||
SELECT table_name
|
||||
FROM information_schema.tables
|
||||
WHERE table_schema = 'public'
|
||||
AND table_name LIKE '%activity%'
|
||||
ORDER BY table_name
|
||||
"""
|
||||
)
|
||||
assert len(tables) > 0, "No activity store table found — mod_vnc_lastactivity may not be loaded"
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
"""Zero-coverage module load detection.
|
||||
|
||||
These tests send minimal IQ/presence probes to verify that modules
|
||||
which have no dedicated test coverage are at least loaded and not
|
||||
crashing on entry. A module that fails to load after a Prosody upgrade
|
||||
will typically return service-unavailable or item-not-found.
|
||||
|
||||
For modules without any IQ/presence hook, see MANUAL_TESTS.md §4.
|
||||
"""
|
||||
import pytest
|
||||
from lxml import etree as ET
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
class TestModuleLoadDetection:
|
||||
"""Smoke tests for modules that would otherwise have zero coverage."""
|
||||
|
||||
async def test_mod_vnc_lastactivity_loaded(self, xmpp_client):
|
||||
"""mod_vnc_lastactivity hooks on jabber:iq:last and jabber:iq:batch.
|
||||
A get request should return a result (not service-unavailable).
|
||||
"""
|
||||
iq = xmpp_client.make_iq_get()
|
||||
ET.SubElement(iq.xml, "{jabber:iq:last}query")
|
||||
try:
|
||||
res = await iq.send()
|
||||
assert res["type"] == "result", (
|
||||
f"mod_vnc_lastactivity returned {res['type']} instead of result — module may not be loaded"
|
||||
)
|
||||
except Exception as e:
|
||||
pytest.fail(f"mod_vnc_lastactivity IQ endpoint unreachable: {e}")
|
||||
|
||||
async def test_mod_vnc_delfile_message_hook_present(self, xmpp_client, xmpp_config, second_client):
|
||||
"""mod_vnc_delfile hooks on pre-message with a message-correction trigger.
|
||||
We verify it doesn't crash by sending a message with <replace>.
|
||||
The module has no IQ handler; we just verify no error bounces back.
|
||||
"""
|
||||
if xmpp_client.boundjid.bare == second_client.boundjid.bare:
|
||||
pytest.skip("Need two clients for message roundtrip")
|
||||
|
||||
msg = xmpp_client.make_message(mto=second_client.boundjid.bare, mtype="chat")
|
||||
msg.body = "delfile test"
|
||||
msg["id"] = "delfile-test-1"
|
||||
replace = ET.SubElement(msg.xml, "{urn:xmpp:message-correct:0}replace")
|
||||
replace.set("id", "original-msg-id")
|
||||
|
||||
try:
|
||||
await msg.send()
|
||||
# If no exception and no error bounce, the module at least didn't crash.
|
||||
# The actual HTTP POST to del_api_url is an async side-effect we can't observe.
|
||||
except Exception as e:
|
||||
pytest.fail(f"mod_vnc_delfile message hook crashed: {e}")
|
||||
|
||||
async def test_mod_vnc_remotemucstore_message_hook_present(self, xmpp_client, xmpp_config, second_client):
|
||||
"""mod_vnc_remotemucstore hooks on message/bare and message/full.
|
||||
We send a normal chat message and verify no crash.
|
||||
"""
|
||||
if xmpp_client.boundjid.bare == second_client.boundjid.bare:
|
||||
pytest.skip("Need two clients for message roundtrip")
|
||||
|
||||
msg = xmpp_client.make_message(mto=second_client.boundjid.bare, mtype="chat")
|
||||
msg.body = "remotemucstore test"
|
||||
try:
|
||||
await msg.send()
|
||||
except Exception as e:
|
||||
pytest.fail(f"mod_vnc_remotemucstore message hook crashed: {e}")
|
||||
|
||||
async def test_mod_vnc_remotemucinvite_event_handler_present(self, xmpp_client, xmpp_config, second_client):
|
||||
"""mod_vnc_remotemucinvite hooks on vnc-muc-invitation event.
|
||||
We trigger a mediated invite and verify no crash.
|
||||
"""
|
||||
muc_domain = xmpp_config.get("muc_domain")
|
||||
if not muc_domain:
|
||||
pytest.skip("No MUC domain configured")
|
||||
if xmpp_client.boundjid.bare == second_client.boundjid.bare:
|
||||
pytest.skip("Need two distinct accounts")
|
||||
|
||||
room_jid = f"reminv_{xmpp_client.boundjid.local}_{xmpp_client.boundjid.local}@{muc_domain}"
|
||||
nick = "owner"
|
||||
await xmpp_client.join_muc(room_jid, nick)
|
||||
await second_client.join_muc(room_jid, "invited")
|
||||
|
||||
try:
|
||||
# Mediated invite
|
||||
invite_msg = xmpp_client.make_message(mto=room_jid, mtype="normal")
|
||||
x = ET.SubElement(invite_msg.xml, "{http://jabber.org/protocol/muc#user}x")
|
||||
invite = ET.SubElement(x, "{http://jabber.org/protocol/muc#user}invite")
|
||||
invite.set("to", second_client.boundjid.bare)
|
||||
await invite_msg.send()
|
||||
# If no crash, the event handler is at least present.
|
||||
except Exception as e:
|
||||
pytest.fail(f"mod_vnc_remotemucinvite event hook crashed: {e}")
|
||||
finally:
|
||||
await xmpp_client.leave_muc(room_jid, nick)
|
||||
await second_client.leave_muc(room_jid, "invited")
|
||||
await xmpp_client.destroy_muc(room_jid)
|
||||
|
||||
async def test_mod_vnc_track_kicks_event_handler_present(self, xmpp_client, xmpp_config, second_client):
|
||||
"""mod_vnc_track_kicks hooks on vnc-muc-kick event.
|
||||
We trigger it via unregister IQ (which fires vnc-muc-kick) and verify no crash.
|
||||
"""
|
||||
muc_domain = xmpp_config.get("muc_domain")
|
||||
if not muc_domain:
|
||||
pytest.skip("No MUC domain configured")
|
||||
if xmpp_client.boundjid.bare == second_client.boundjid.bare:
|
||||
pytest.skip("Need two distinct accounts")
|
||||
|
||||
room_jid = f"trackk_{xmpp_client.boundjid.local}_{xmpp_client.boundjid.local}@{muc_domain}"
|
||||
nick1 = "owner"
|
||||
nick2 = "member"
|
||||
|
||||
await xmpp_client.join_muc(room_jid, nick1)
|
||||
await xmpp_client.set_muc_affiliation(room_jid, second_client.boundjid.bare, "member")
|
||||
|
||||
try:
|
||||
iq = second_client.make_iq_set(to=room_jid)
|
||||
ET.SubElement(iq.xml, "{xmpp:vnctalk:unregister}query")
|
||||
await iq.send()
|
||||
# If no crash, the kick event handler is at least present.
|
||||
except Exception as e:
|
||||
pytest.fail(f"mod_vnc_track_kicks event hook crashed: {e}")
|
||||
finally:
|
||||
await xmpp_client.leave_muc(room_jid, nick1)
|
||||
await xmpp_client.destroy_muc(room_jid)
|
||||
Reference in New Issue
Block a user