Compare commits

..

13 Commits

Author SHA1 Message Date
9183ec7c2a Add functional and unit tests for DB export/import
All checks were successful
CI Code / Check spelling (pull_request) Successful in 18s
CI Code / Check coding style (pull_request) Successful in 32s
CI Code / Code Coverage (pull_request) Successful in 6m23s
CI Code / Linux (debian) (pull_request) Successful in 7m47s
CI Code / Linux (ubuntu) (pull_request) Successful in 7m51s
CI Code / Linux (arch) (pull_request) Successful in 8m5s
- 2 functional tests: export_sqlite_to_flatfile, import_flatfile_to_sqlite
  with XEP-0203 delay timestamps for deterministic verification
- 27 unit tests for database_export covering edge cases
- Merge test/db-functional-tests: 12 DB persistence tests (test_history)
- Group 2 rebalanced: 25 tests (23 base + 2 SQLite-only export/import)
- #ifdef HAVE_SQLITE guards for export/import tests
- prof_stop() helper in proftest.c
- Makefile.am: source ordering cleanup, new test files added
2026-03-04 18:56:15 +03:00
b3e4860512 tests: add functional tests for DB message persistence, rebalance groups
Add backend-agnostic functional tests for database message persistence.
All tests work with both SQLite and flat-file backends.

New tests in test_history.c:
- message_db_history_on_reopen: basic incoming write+read round-trip
- message_db_history_multiple: 3 messages from different resources
- message_db_history_contact_isolation: buddy1 msg absent from buddy2
- message_db_history_special_chars: XML entities survive decode+DB
- message_db_history_outgoing: sent message persists across reopen
- message_db_history_dialog: outgoing + incoming both in history
- message_db_history_empty: no crash on contact with no history
- message_db_history_long_message: 1000+ char body not truncated
- message_db_history_newline: embedded LF stored correctly
- message_db_history_service_chars: backslash pipe percent braces etc
- message_db_history_verify: /history verify (ifdef HAVE_HISTORY_VERIFY)
- message_db_history_lmc: XEP-0308 correction replaces original in DB

Rebalance test groups for parallel execution (19/22/22/21):
- Group 1: Connect, Ping, Rooms, Software, LastActivity
- Group 2: Message, Receipts, Roster, DB History
- Group 3: Chat Session, Presence, Disconnect
- Group 4: MUC, Carbons
2026-03-04 18:55:18 +03:00
4ddbd1e34c feat: add /history switch for runtime database backend switching
Add log_database_switch_backend() that closes the current backend,
updates PREF_DBLOG preference, and reinitializes with the new backend
without requiring a reconnect or restart.

New command: /history switch sqlite|flatfile
- Validates connection state and backend name
- Skips if already using the requested backend
- Autocomplete support for the subcommand
2026-03-04 18:54:24 +03:00
904e0ec5df feat(draft): add /history export|import for SQLite<->flatfile migration
DRAFT — not yet tested end-to-end with a live XMPP session.

New commands:
  /history export [<jid>]  — copy messages from SQLite to flat-file
  /history import [<jid>]  — copy messages from flat-file to SQLite
Both merge with existing data; duplicates are skipped using stanza-id
or a SHA-256 fallback key (timestamp + sender + body prefix).

Implementation (src/database_export.c):
- Export: paginate SQLite via get_previous_chat, read existing flatfile
  for dedup, write merged result via ff_write_line + atomic rename
- Import: parse flatfile lines via ff_parse_line, build dedup set from
  SQLite, insert new messages via add_incoming (preserves original
  timestamps for both directions)
- List all contacts: db_sqlite_list_contacts() queries UNION of
  DISTINCT from_jid/to_jid; flatfile enumerates flatlog directories

Wiring:
- database.h: declare export/import functions + db_sqlite_list_contacts
- database_sqlite.c: add db_sqlite_list_contacts()
- cmd_defs.c: add export/import to /history synopsis and args
- cmd_funcs.c: add export/import handlers in cmd_history()
- cmd_ac.c: add 'export' and 'import' to history_ac
- Makefile.am: add database_export.c to core_sources
- stub_database.c: add stubs for test linking
- profanity.1: document export/import in man page

All code guarded with #ifdef HAVE_SQLITE — builds cleanly without it.
2026-03-04 18:54:24 +03:00
c24879bdc0 fix: harden flatfile backend, make SQLite optional
Build system:
- Add --without-sqlite configure flag (AM_CONDITIONAL BUILD_SQLITE)
- Guard database_sqlite.c with HAVE_SQLITE in Makefile.am, database.c,
  database.h and stub_database.c
- Fall back to flatfile when SQLite not compiled in

Security (database_flatfile.c):
- MAM dedup: skip incoming messages with duplicate stanza-id (archive_id)
- LMC sender validation: reject corrections from mismatched JIDs
- Add flock() advisory locking to prevent interleaved writes from
  concurrent instances
- Check fprintf return when writing file header

Autocomplete (cmd_ac.c):
- Add 'flatfile' to /privacy logging autocomplete
- Add dedicated /history autocomplete with on/off/verify (was boolean-only)

Code quality:
- Fix fwrite return type: size_t not ssize_t (database_flatfile_parser.c)
- Fix mixed allocators in ff_readline: use malloc() consistently for
  overlength-line fallback instead of g_strdup()
- Fix index alignment in _ff_state_extend: use local counter so index
  step doesn't depend on total_lines from initial build

Documentation:
- Fix page: correct directory structure (history.log not per-day files)
- Fix page: add aid:{archive_id} to line format example
- Fix page: missing newline before .SH BUGS
2026-03-04 18:54:24 +03:00
a3290261cf database_flatfile: single-file storage with sparse index
Replace per-contact directory structure with a single flat file per contact.
Each file uses pipe-delimited records with a sparse byte-offset index
that is rebuilt on demand and cached in memory.

- Rewrite _ff_write_record / _ff_read_records for single-file format
- Add sparse index (every Nth record) for O(log N) seek on history read
- Cursor-based pagination: _ff_get_previous_chat uses saved byte offset
- LMC corrections applied in-place during read (_ff_apply_lmc)
- Newline escaping (\n) in message bodies for line-based storage
- Simplify verify: parse + timestamp order + LMC reference check
- Update parser helpers for new field layout
2026-03-04 18:54:24 +03:00
b88d6216cf refactor: split database_flatfile.c into functional modules
Split the monolithic database_flatfile.c into:
- database_flatfile.h: internal header with shared types and prototypes
- database_flatfile_parser.c: parsing, escaping, IO helpers
- database_flatfile_verify.c: integrity verification logic
- database_flatfile.c: core backend (init, write, read, LMC, vtable)
2026-03-04 18:54:24 +03:00
0ee3d67ea0 fix(flatfile): harden flat-file backend against injection and traversal attacks
Security fixes for 7 vulnerabilities in database_flatfile.c:

1. Path traversal via crafted JID (HIGH):
   _ff_jid_to_dir() now strips '/', '\' -> '_' and collapses '..' -> '__',
   preventing a malicious federated JID from escaping the log directory.

2. Log injection via unescaped message body (HIGH):
   Add _ff_escape_message()/_ff_unescape_message() -- escape \n, \r, \\
   in message text on write, unescape on read. Prevents remote contacts
   from injecting fake log lines with forged sender/timestamp/encryption.

3. Metadata field injection (HIGH):
   Add _ff_escape_meta_value()/_ff_unescape_meta_value() -- escape |, ],
   \\, \n, \r in stanza_id/archive_id/replace_id. Parser uses escape-aware
   _ff_find_unescaped_char() and _ff_split_meta() instead of strchr/strsplit.

4. Sender ": " parsing confusion (MEDIUM):
   Escape ": " -> "\: " in XMPP resource on write. Parser uses
   _ff_find_unescaped_colonspace() + _ff_unescape_sender_resource().

5. LMC correction chain cycle -> infinite loop (MEDIUM):
   Add visited hash-set and FF_MAX_LMC_DEPTH (100) limit to chain walker.

6. Unbounded getline() -> OOM (MEDIUM):
   Add FF_MAX_LINE_LEN (10 MB) cap in _ff_readline(); overlength lines
   are skipped with a warning. Replaces all char buf[8192]/fgets() sites
   with dynamic getline() + truncated-line detection at EOF.

7. Symlink attack + TOCTOU on file creation (MEDIUM):
   Replace fopen("a") with open(O_WRONLY|O_APPEND|O_CREAT|O_EXCL|O_NOFOLLOW)
   + fdopen() -- atomic new-file detection, symlink rejection via O_NOFOLLOW.
   _ff_ensure_dir() checks g_lstat() for symlinks. File permissions 0600
   set via open() mode, not post-hoc g_chmod().
2026-03-04 18:54:24 +03:00
71230f7ced feat: add flat-file database backend for message history
Add pluggable storage backend abstraction (vtable) to the database layer,
allowing selection between SQLite (default) and a new flat-file backend
that stores messages as human-readable plain text files.

New files:
- database_sqlite.c: extracted SQLite backend from database.c
- database_flatfile.c: plain text backend with tolerant parser,
  LMC correction chains, UTF-8/BOM/CRLF handling, integrity checks

Commands:
- /privacy logging flatfile — switch to flat-file backend
- /history verify [<jid>] — check integrity of stored history

Fixes:
- Add missing 'off' guard in flatfile backend
- Enable CHLOG/HISTORY prefs when switching to flatfile mode

Logs stored in ~/.local/share/profanity/flatlog/{account}/{contact}/{date}.log
Format: {ISO8601} [{type}|{enc}|id:{id}|corrects:{id}] {sender}: {message}

Updated: CHANGELOG, CONTRIBUTING.md, profrc.example, man page, cmd_defs,
Makefile.am, test stubs, functional test support (PROF_FLATFILE=1)
2026-03-04 18:54:24 +03:00
9e1b95a814 test(disco): remove disco_items_error_handling test
All checks were successful
CI Code / Check spelling (pull_request) Successful in 22s
CI Code / Check coding style (pull_request) Successful in 40s
CI Code / Linux (debian) (pull_request) Successful in 6m59s
CI Code / Linux (arch) (pull_request) Successful in 7m21s
CI Code / Linux (ubuntu) (pull_request) Successful in 7m13s
CI Code / Code Coverage (pull_request) Successful in 10m59s
CI Code / Check spelling (push) Successful in 20s
CI Code / Check coding style (push) Successful in 36s
CI Code / Code Coverage (push) Successful in 5m43s
CI Code / Linux (ubuntu) (push) Successful in 7m9s
CI Code / Linux (debian) (push) Successful in 10m5s
CI Code / Linux (arch) (push) Successful in 12m0s
This test requires the fix from fix/xep-0030-disco-items-error-handling
branch. Moved there along with the source code fix.
2026-03-03 16:57:35 +01:00
24e1dac354 test(disco): add comprehensive XEP-0030 functional tests
Add 7 new tests for /disco command:
- disco_items_to_jid: query items to specific JID
- disco_info_empty_result: handle empty disco#info response
- disco_info_multiple_identities: multiple identity elements
- disco_info_without_name: identity without optional name attr
- disco_items_without_name: items without optional name attr
- disco_info_service_unavailable: error handling for info
- disco_items_error_handling: error handling for items (XEP-0030 §7)

The disco_items_error_handling test documents a bug where disco#items
errors are silently ignored (unlike disco#info which handles them).
This violates XEP-0030 Section 7 which requires error feedback to user.
2026-03-03 16:57:35 +01:00
f20a4da160 Add functional tests for /disco command (XEP-0030)
- Add 8 tests for disco info and disco items commands
- Fix XEP-0030 compliance bug: show message for empty disco#items results
- Tests cover: identity display, features, server/jid queries, error handling,
  items display, empty results, and connection requirement
2026-03-03 16:57:35 +01:00
663a959f9c test: add functional tests for autoping (XEP-0199)
- autoping_set_interval: verify /autoping set command
- autoping_set_zero_disables: verify disabling autoping
- autoping_timeout_set: verify /autoping timeout command
- autoping_timeout_zero_disables: verify disabling timeout
- autoping_sends_ping_after_interval: verify automatic ping IQ
- autoping_server_not_supporting_ping: verify error handling

Fast tests (command parsing) in Group 1, slow tests (timer-based) in Group 3.
2026-03-03 16:57:35 +01:00
7 changed files with 596 additions and 8 deletions

View File

@@ -194,7 +194,9 @@ functionaltest_sources = \
tests/functionaltests/test_disconnect.c tests/functionaltests/test_disconnect.h \
tests/functionaltests/test_history.c tests/functionaltests/test_history.h \
tests/functionaltests/test_lastactivity.c tests/functionaltests/test_lastactivity.h \
tests/functionaltests/test_export_import.c tests/functionaltests/test_export_import.h \
tests/functionaltests/test_autoping.c tests/functionaltests/test_autoping.h \
tests/functionaltests/test_disco.c tests/functionaltests/test_disco.h \
tests/functionaltests/test_export_import.c tests/functionaltests/test_export_import.h \
tests/functionaltests/functionaltests.c
main_source = src/main.c

View File

@@ -15,10 +15,10 @@
* functional tests run less frequently than unit tests.
*
* Tests are organized into groups for better maintainability and parallel execution:
* Group 1: Connect, Ping, Rooms, Software, Last Activity (19 tests)
* Group 2: Message, Receipts, Roster, DB History (23+2 tests, +2 with SQLite)
* Group 3: Chat Session, Presence, Disconnect (22 tests)
* Group 4: MUC, Carbons (21 tests)
* Group 1: Connect, Ping, Rooms, Software, Last Activity, Autoping
* Group 2: Message, Receipts, Roster, DB History (+2 with SQLite)
* Group 3: Chat Session, Presence, Disconnect, Disco
* Group 4: MUC, Carbons
*
* Parallel execution:
* ./functionaltests - run all tests sequentially
@@ -53,6 +53,8 @@
#include "test_muc.h"
#include "test_disconnect.h"
#include "test_lastactivity.h"
#include "test_autoping.h"
#include "test_disco.h"
#include "test_history.h"
#ifdef HAVE_SQLITE
#include "test_export_import.h"
@@ -114,6 +116,16 @@ main(int argc, char* argv[])
/* Last Activity - XEP-0012 */
PROF_FUNC_TEST(responds_to_last_activity_request),
PROF_FUNC_TEST(last_activity_request_to_contact),
/* Autoping command tests - fast, no waiting */
PROF_FUNC_TEST(autoping_set_interval),
PROF_FUNC_TEST(autoping_set_zero_disables),
PROF_FUNC_TEST(autoping_timeout_set),
PROF_FUNC_TEST(autoping_timeout_zero_disables),
/* Autoping slow tests - require sleep for timer triggers (~2s each) */
PROF_FUNC_TEST(autoping_sends_ping_after_interval),
PROF_FUNC_TEST(autoping_server_not_supporting_ping),
};
/* ============================================================
@@ -193,6 +205,22 @@ main(int argc, char* argv[])
/* Disconnect - clean session termination */
PROF_FUNC_TEST(disconnect_ends_session),
/* Service Discovery - XEP-0030 */
PROF_FUNC_TEST(disco_info_shows_identity),
PROF_FUNC_TEST(disco_info_shows_features),
PROF_FUNC_TEST(disco_info_to_server),
PROF_FUNC_TEST(disco_info_to_jid),
PROF_FUNC_TEST(disco_info_not_found),
PROF_FUNC_TEST(disco_items_shows_items),
PROF_FUNC_TEST(disco_items_empty_result),
PROF_FUNC_TEST(disco_requires_connection),
PROF_FUNC_TEST(disco_items_to_jid),
PROF_FUNC_TEST(disco_info_empty_result),
PROF_FUNC_TEST(disco_info_multiple_identities),
PROF_FUNC_TEST(disco_info_without_name),
PROF_FUNC_TEST(disco_items_without_name),
PROF_FUNC_TEST(disco_info_service_unavailable),
};
/* ============================================================
@@ -241,9 +269,9 @@ main(int argc, char* argv[])
const struct CMUnitTest* tests;
size_t count;
} groups[] = {
{ "Group 1: Connect/Ping/Rooms/Software/LastActivity", group1_tests, ARRAY_SIZE(group1_tests) },
{ "Group 1: Connect/Ping/Rooms/Software/LastActivity/Autoping", group1_tests, ARRAY_SIZE(group1_tests) },
{ "Group 2: Message/Receipts/Roster/DBHistory", group2_tests, ARRAY_SIZE(group2_tests) },
{ "Group 3: Session/Presence/Disconnect", group3_tests, ARRAY_SIZE(group3_tests) },
{ "Group 3: Session/Presence/Disconnect/Disco", group3_tests, ARRAY_SIZE(group3_tests) },
{ "Group 4: MUC/Carbons", group4_tests, ARRAY_SIZE(group4_tests) },
};
const int num_groups = ARRAY_SIZE(groups);

View File

@@ -376,7 +376,6 @@ close_prof_test(void **state)
fd = 0;
child_pid = 0;
}
_cleanup_dirs();
if (config_orig) {
setenv("XDG_CONFIG_HOME", config_orig, 1);

View File

@@ -0,0 +1,114 @@
#include <glib.h>
#include "prof_cmocka.h"
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <stabber.h>
#include "proftest.h"
void
autoping_set_interval(void** state)
{
prof_connect();
prof_input("/autoping set 60");
assert_true(prof_output_exact("Autoping interval set to 60 seconds."));
}
void
autoping_set_zero_disables(void** state)
{
prof_connect();
prof_input("/autoping set 0");
assert_true(prof_output_exact("Autoping disabled."));
}
void
autoping_timeout_set(void** state)
{
prof_connect();
prof_input("/autoping timeout 30");
assert_true(prof_output_exact("Autoping timeout set to 30 seconds."));
}
void
autoping_timeout_zero_disables(void** state)
{
prof_connect();
prof_input("/autoping timeout 0");
assert_true(prof_output_exact("Autoping timeout disabled."));
}
void
autoping_sends_ping_after_interval(void** state)
{
/*
* This test verifies that autoping sends a ping IQ after the configured
* interval. We set a short interval (1 second) and verify the ping is sent.
*/
// Register disco#info response with ping support
stbbr_for_query("http://jabber.org/protocol/disco#info",
"<iq to='stabber@localhost/profanity' type='result' from='localhost'>"
"<query xmlns='http://jabber.org/protocol/disco#info'>"
"<identity category='server' type='im' name='Stabber'/>"
"<feature var='urn:xmpp:ping'/>"
"</query>"
"</iq>"
);
// Register ping response
stbbr_for_query("urn:xmpp:ping",
"<iq from='localhost' to='stabber@localhost/profanity' type='result'/>"
);
prof_connect();
// Set short autoping interval
prof_input("/autoping set 1");
assert_true(prof_output_exact("Autoping interval set to 1 seconds."));
// Wait for autoping to trigger (interval + some buffer)
sleep(2);
// Verify ping was sent (no 'to' attribute means server ping)
assert_true(stbbr_received(
"<iq id='*' type='get'>"
"<ping xmlns='urn:xmpp:ping'/>"
"</iq>"
));
}
void
autoping_server_not_supporting_ping(void** state)
{
/*
* When server doesn't support ping, autoping should show error.
*/
// Register disco#info response WITHOUT ping support
stbbr_for_query("http://jabber.org/protocol/disco#info",
"<iq to='stabber@localhost/profanity' type='result' from='localhost'>"
"<query xmlns='http://jabber.org/protocol/disco#info'>"
"<identity category='server' type='im' name='Stabber'/>"
"</query>"
"</iq>"
);
prof_connect();
// Set short autoping interval
prof_input("/autoping set 1");
assert_true(prof_output_exact("Autoping interval set to 1 seconds."));
// Wait for autoping to trigger
sleep(2);
// Should show error about ping not being supported
assert_true(prof_output_regex("Server ping not supported"));
}

View File

@@ -0,0 +1,6 @@
void autoping_set_interval(void** state);
void autoping_set_zero_disables(void** state);
void autoping_timeout_set(void** state);
void autoping_timeout_zero_disables(void** state);
void autoping_sends_ping_after_interval(void** state);
void autoping_server_not_supporting_ping(void** state);

View File

@@ -0,0 +1,420 @@
/*
* test_disco.c
*
* Functional tests for /disco command (XEP-0030 Service Discovery).
* Tests cover:
* - /disco info [jid] - query entity capabilities and features
* - /disco items [jid] - query entity items/services
*
* XEP-0030: https://xmpp.org/extensions/xep-0030.html
*/
#include <glib.h>
#include "prof_cmocka.h"
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <stabber.h>
#include "proftest.h"
void
disco_info_shows_identity(void **state)
{
/*
* Test that /disco info displays identity information correctly.
* Identity includes: name, type, category
*/
stbbr_for_query("http://jabber.org/protocol/disco#info",
"<iq to='stabber@localhost/profanity' type='result' from='localhost'>"
"<query xmlns='http://jabber.org/protocol/disco#info'>"
"<identity category='server' type='im' name='Prosody'/>"
"</query>"
"</iq>"
);
prof_connect();
prof_input("/disco info");
prof_timeout(10);
assert_true(prof_output_exact("Service discovery info for localhost"));
assert_true(prof_output_exact("Identities"));
assert_true(prof_output_regex("Prosody.*im.*server"));
prof_timeout_reset();
}
void
disco_info_shows_features(void **state)
{
/*
* Test that /disco info displays feature list.
*/
stbbr_for_query("http://jabber.org/protocol/disco#info",
"<iq to='stabber@localhost/profanity' type='result' from='localhost'>"
"<query xmlns='http://jabber.org/protocol/disco#info'>"
"<identity category='server' type='im' name='TestServer'/>"
"<feature var='urn:xmpp:ping'/>"
"<feature var='http://jabber.org/protocol/disco#info'/>"
"<feature var='http://jabber.org/protocol/disco#items'/>"
"</query>"
"</iq>"
);
prof_connect();
prof_input("/disco info");
prof_timeout(10);
assert_true(prof_output_exact("Features:"));
assert_true(prof_output_exact("urn:xmpp:ping"));
assert_true(prof_output_exact("http://jabber.org/protocol/disco#info"));
prof_timeout_reset();
}
void
disco_info_to_server(void **state)
{
/*
* Test that /disco info without arguments queries the server (domainpart).
*/
stbbr_for_query("http://jabber.org/protocol/disco#info",
"<iq to='stabber@localhost/profanity' type='result' from='localhost'>"
"<query xmlns='http://jabber.org/protocol/disco#info'>"
"<identity category='server' type='im' name='LocalServer'/>"
"</query>"
"</iq>"
);
prof_connect();
prof_input("/disco info");
/* Verify request was sent to server (localhost) */
prof_timeout(10);
assert_true(stbbr_received(
"<iq id='*' to='localhost' type='get'>"
"<query xmlns='http://jabber.org/protocol/disco#info'/>"
"</iq>"
));
prof_timeout_reset();
}
void
disco_info_to_jid(void **state)
{
/*
* Test that /disco info <jid> queries the specified JID.
*/
stbbr_for_query("http://jabber.org/protocol/disco#info",
"<iq to='stabber@localhost/profanity' type='result' from='conference.localhost'>"
"<query xmlns='http://jabber.org/protocol/disco#info'>"
"<identity category='conference' type='text' name='MUC Service'/>"
"<feature var='http://jabber.org/protocol/muc'/>"
"</query>"
"</iq>"
);
prof_connect();
prof_input("/disco info conference.localhost");
prof_timeout(10);
/* Verify request was sent to specified JID */
assert_true(stbbr_received(
"<iq id='*' to='conference.localhost' type='get'>"
"<query xmlns='http://jabber.org/protocol/disco#info'/>"
"</iq>"
));
assert_true(prof_output_exact("Service discovery info for conference.localhost"));
prof_timeout_reset();
}
void
disco_info_not_found(void **state)
{
/*
* Test error handling when disco info returns item-not-found.
*/
stbbr_for_query("http://jabber.org/protocol/disco#info",
"<iq to='stabber@localhost/profanity' type='error' from='unknown.localhost'>"
"<error type='cancel'>"
"<item-not-found xmlns='urn:ietf:params:xml:ns:xmpp-stanzas'/>"
"</error>"
"</iq>"
);
prof_connect();
prof_input("/disco info unknown.localhost");
prof_timeout(10);
assert_true(prof_output_regex("Service discovery failed.*item-not-found"));
prof_timeout_reset();
}
void
disco_items_shows_items(void **state)
{
/*
* Test that /disco items displays items list with JID and name.
*/
stbbr_for_query("http://jabber.org/protocol/disco#items",
"<iq to='stabber@localhost/profanity' type='result' from='localhost'>"
"<query xmlns='http://jabber.org/protocol/disco#items'>"
"<item jid='conference.localhost' name='Chat Rooms'/>"
"<item jid='pubsub.localhost' name='Publish-Subscribe'/>"
"<item jid='proxy.localhost' name='SOCKS5 Bytestreams'/>"
"</query>"
"</iq>"
);
prof_connect();
prof_input("/disco items");
prof_timeout(10);
assert_true(prof_output_exact("Service discovery items for localhost:"));
assert_true(prof_output_regex("conference.localhost.*Chat Rooms"));
assert_true(prof_output_regex("pubsub.localhost.*Publish-Subscribe"));
assert_true(prof_output_regex("proxy.localhost.*SOCKS5 Bytestreams"));
/* Verify IQ was sent with correct id */
assert_true(stbbr_received(
"<iq id='discoitemsreq' to='localhost' type='get'>"
"<query xmlns='http://jabber.org/protocol/disco#items'/>"
"</iq>"
));
prof_timeout_reset();
}
void
disco_items_empty_result(void **state)
{
/*
* Test that /disco items handles empty result gracefully.
* Per XEP-0030: "if an entity has no associated items, it MUST return
* an empty <query/> element (rather than an error)"
*/
stbbr_for_query("http://jabber.org/protocol/disco#items",
"<iq to='stabber@localhost/profanity' type='result' from='localhost'>"
"<query xmlns='http://jabber.org/protocol/disco#items'/>"
"</iq>"
);
prof_connect();
prof_input("/disco items");
prof_timeout(10);
assert_true(prof_output_exact("No service discovery items for localhost"));
prof_timeout_reset();
}
void
disco_requires_connection(void **state)
{
/*
* Test that /disco info and /disco items require an active connection.
* First test without any connection, then after connect/disconnect.
*/
/* Without connection */
prof_input("/disconnect");
assert_true(prof_output_exact("You are not currently connected."));
prof_input("/disco info");
assert_true(prof_output_exact("You are not currently connected."));
prof_input("/disco items");
assert_true(prof_output_exact("You are not currently connected."));
/* After connect and disconnect */
prof_connect();
prof_input("/disconnect");
assert_true(prof_output_exact("stabber@localhost logged out successfully."));
prof_input("/disco info");
assert_true(prof_output_exact("You are not currently connected."));
prof_input("/disco items");
assert_true(prof_output_exact("You are not currently connected."));
}
void
disco_items_to_jid(void **state)
{
/*
* Test that /disco items <jid> queries the specified JID.
*/
stbbr_for_query("http://jabber.org/protocol/disco#items",
"<iq to='stabber@localhost/profanity' type='result' from='conference.localhost'>"
"<query xmlns='http://jabber.org/protocol/disco#items'>"
"<item jid='room1@conference.localhost' name='General Chat'/>"
"<item jid='room2@conference.localhost' name='Support'/>"
"</query>"
"</iq>"
);
prof_connect();
prof_input("/disco items conference.localhost");
prof_timeout(10);
/* Verify request was sent to specified JID */
assert_true(stbbr_received(
"<iq id='discoitemsreq' to='conference.localhost' type='get'>"
"<query xmlns='http://jabber.org/protocol/disco#items'/>"
"</iq>"
));
assert_true(prof_output_exact("Service discovery items for conference.localhost:"));
assert_true(prof_output_regex("room1@conference.localhost.*General Chat"));
prof_timeout_reset();
}
void
disco_info_empty_result(void **state)
{
/*
* Test that /disco info handles empty result (no identities/features).
* This can happen with minimal server configurations.
*/
stbbr_for_query("http://jabber.org/protocol/disco#info",
"<iq to='stabber@localhost/profanity' type='result' from='minimal.localhost'>"
"<query xmlns='http://jabber.org/protocol/disco#info'/>"
"</iq>"
);
prof_connect();
prof_input("/disco info minimal.localhost");
prof_timeout(10);
/* Verify request was sent */
assert_true(stbbr_received(
"<iq id='*' to='minimal.localhost' type='get'>"
"<query xmlns='http://jabber.org/protocol/disco#info'/>"
"</iq>"
));
/* Empty result should not crash and should not show "Service discovery info" */
prof_timeout_reset();
}
void
disco_info_multiple_identities(void **state)
{
/*
* Test that /disco info displays multiple identities correctly.
* Entities can have multiple identities (e.g., server + gateway).
*/
stbbr_for_query("http://jabber.org/protocol/disco#info",
"<iq to='stabber@localhost/profanity' type='result' from='gateway.localhost'>"
"<query xmlns='http://jabber.org/protocol/disco#info'>"
"<identity category='gateway' type='irc' name='IRC Gateway'/>"
"<identity category='directory' type='chatroom' name='Room Directory'/>"
"<identity category='automation' type='command-node' name='Ad-Hoc Commands'/>"
"</query>"
"</iq>"
);
prof_connect();
prof_input("/disco info gateway.localhost");
prof_timeout(10);
assert_true(prof_output_exact("Service discovery info for gateway.localhost"));
assert_true(prof_output_exact("Identities"));
assert_true(prof_output_regex("IRC Gateway.*irc.*gateway"));
assert_true(prof_output_regex("Room Directory.*chatroom.*directory"));
assert_true(prof_output_regex("Ad-Hoc Commands.*command-node.*automation"));
prof_timeout_reset();
}
void
disco_info_without_name(void **state)
{
/*
* Test that /disco info handles identity without name attribute.
* Per XEP-0030: name is OPTIONAL, only category and type are REQUIRED.
*/
stbbr_for_query("http://jabber.org/protocol/disco#info",
"<iq to='stabber@localhost/profanity' type='result' from='localhost'>"
"<query xmlns='http://jabber.org/protocol/disco#info'>"
"<identity category='server' type='im'/>"
"</query>"
"</iq>"
);
prof_connect();
prof_input("/disco info");
prof_timeout(10);
assert_true(prof_output_exact("Service discovery info for localhost"));
assert_true(prof_output_exact("Identities"));
/* Should show type and category even without name */
assert_true(prof_output_regex("im.*server"));
prof_timeout_reset();
}
void
disco_items_without_name(void **state)
{
/*
* Test that /disco items handles items without name attribute.
* Per XEP-0030: name is OPTIONAL for items, only jid is REQUIRED.
*/
stbbr_for_query("http://jabber.org/protocol/disco#items",
"<iq to='stabber@localhost/profanity' type='result' from='localhost'>"
"<query xmlns='http://jabber.org/protocol/disco#items'>"
"<item jid='conference.localhost'/>"
"<item jid='pubsub.localhost' name='PubSub Service'/>"
"<item jid='upload.localhost'/>"
"</query>"
"</iq>"
);
prof_connect();
prof_input("/disco items");
prof_timeout(10);
assert_true(prof_output_exact("Service discovery items for localhost:"));
/* Items without name should still display their JID */
assert_true(prof_output_exact("conference.localhost"));
assert_true(prof_output_regex("pubsub.localhost.*PubSub Service"));
assert_true(prof_output_exact("upload.localhost"));
prof_timeout_reset();
}
void
disco_info_service_unavailable(void **state)
{
/*
* Test error handling when disco info returns service-unavailable.
*/
stbbr_for_query("http://jabber.org/protocol/disco#info",
"<iq to='stabber@localhost/profanity' type='error' from='offline.localhost'>"
"<error type='cancel'>"
"<service-unavailable xmlns='urn:ietf:params:xml:ns:xmpp-stanzas'/>"
"</error>"
"</iq>"
);
prof_connect();
prof_input("/disco info offline.localhost");
prof_timeout(10);
assert_true(prof_output_regex("Service discovery failed.*service-unavailable"));
prof_timeout_reset();
}

View File

@@ -0,0 +1,19 @@
/* test_disco.h
*
* Functional tests for /disco command (XEP-0030 Service Discovery)
*/
void disco_info_shows_identity(void **state);
void disco_info_shows_features(void **state);
void disco_info_to_server(void **state);
void disco_info_to_jid(void **state);
void disco_info_not_found(void **state);
void disco_items_shows_items(void **state);
void disco_items_empty_result(void **state);
void disco_requires_connection(void **state);
void disco_items_to_jid(void **state);
void disco_info_empty_result(void **state);
void disco_info_multiple_identities(void **state);
void disco_info_without_name(void **state);
void disco_items_without_name(void **state);
void disco_info_service_unavailable(void **state);