diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index 3605b4346..2be50493b 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -38,11 +38,11 @@ jobs:
config: ./test/demoboot-with-additions.xml
plugin: ./restAPI.jar
- - uses: gacts/install-hurl@v1
-
- name: Test the plugin
run: |
- hurl --test --report-junit test-results.xml --variables-file test/test.env --jobs 1 test/*.hurl
+ docker run --rm --network host --user "$(id -u):$(id -g)" --volume "$PWD:$PWD" --workdir "$PWD" \
+ ghcr.io/orange-opensource/hurl:8.0.1 \
+ --test --report-junit test-results.xml --variables-file test/test.env --jobs 1 test/*.hurl
- name: Expose Openfire logs
uses: actions/upload-artifact@v7
diff --git a/test/README.md b/test/README.md
index cb404e3e0..a6ad6a969 100644
--- a/test/README.md
+++ b/test/README.md
@@ -2,10 +2,31 @@
The tests contained in this folder are written in Hurl (see [docs](https://hurl.dev/docs/manual.html)).
-Install Hurl with instructions as per the documentation.
+Install Hurl with instructions as per the documentation, and run the tests from the root of the repository:
+
+```bash
+hurl --test --variables-file test/test.env --jobs 1 test/*.hurl
+```
+
+Alternatively, run Hurl from its container image, as CI does (`--network host` lets the container reach Openfire on
+`localhost`):
+
+```bash
+docker run --rm --network host --user "$(id -u):$(id -g)" --volume "$PWD:$PWD" --workdir "$PWD" \
+ ghcr.io/orange-opensource/hurl:8.0.1 \
+ --test --variables-file test/test.env --jobs 1 test/*.hurl
+```
Configure the Rest API:
* Enable it
* Set auth for shared key, and set the value in test.env
-* Set `adminConsole.access.allow-wildcards-in-excludes` to true
\ No newline at end of file
+* Set `adminConsole.access.allow-wildcards-in-excludes` to true
+
+test.env defines where the tests find Openfire: `restapi_url` (the base URL of the REST API, including its version),
+`adminconsole_url` and `bosh_url`, as well as the shared secret (`authkey`) and the credentials of an admin user
+(`admin_username` and `admin_password`), which are used to log in to the admin console.
+
+Some tests create XMPP client sessions by logging in over BOSH (Openfire's HTTP binding, which listens on port 7070 by
+default). The BOSH endpoint is configured by the `bosh_url` variable in test.env; when Openfire runs in a container, make
+sure that port 7070 is published (or override the variable, e.g. `--variable bosh_url=http://:7070`).
diff --git a/test/chatroomaffiliations.hurl b/test/chatroomaffiliations.hurl
new file mode 100644
index 000000000..42495bd07
--- /dev/null
+++ b/test/chatroomaffiliations.hurl
@@ -0,0 +1,297 @@
+# Affiliations are tested on a dedicated chat room, and a dedicated group, which are removed at the end.
+POST {{restapi_url}}/chatrooms
+Authorization: {{authkey}}
+Content-Type: application/xml
+```
+
+
+ ops
+ Operations
+ Affiliations test room
+
+ john@example.org
+
+
+```
+HTTP 201
+
+POST {{restapi_url}}/groups
+Authorization: {{authkey}}
+Content-Type: application/xml
+```
+
+
+ crew
+ Crew
+ false
+
+
+ john
+ jane
+
+
+```
+HTTP 201
+
+
+# TEST: The owners that a chat room was created with are listed
+GET {{restapi_url}}/chatrooms/ops/owners
+Authorization: {{authkey}}
+HTTP 200
+[Asserts]
+xpath "/owners/owner" count == 1
+xpath "string(/owners/owner)" == "john@example.org"
+
+# TEST: A new chat room has no admins, members or outcasts
+GET {{restapi_url}}/chatrooms/ops/admins
+Authorization: {{authkey}}
+HTTP 200
+[Asserts]
+xpath "/admins/admin" count == 0
+
+GET {{restapi_url}}/chatrooms/ops/members
+Authorization: {{authkey}}
+HTTP 200
+[Asserts]
+xpath "/members/member" count == 0
+
+GET {{restapi_url}}/chatrooms/ops/outcasts
+Authorization: {{authkey}}
+HTTP 200
+[Asserts]
+xpath "/outcasts/outcast" count == 0
+
+# TEST: Add an affiliation by JID
+POST {{restapi_url}}/chatrooms/ops/members/jane@example.org
+Authorization: {{authkey}}
+HTTP 201
+
+GET {{restapi_url}}/chatrooms/ops/members
+Authorization: {{authkey}}
+HTTP 200
+[Asserts]
+xpath "/members/member" count == 1
+xpath "string(/members/member)" == "jane@example.org"
+
+# TEST: A user has only one affiliation, so adding another replaces the previous one
+POST {{restapi_url}}/chatrooms/ops/admins/jane@example.org
+Authorization: {{authkey}}
+HTTP 201
+
+GET {{restapi_url}}/chatrooms/ops/admins
+Authorization: {{authkey}}
+HTTP 200
+[Asserts]
+xpath "/admins/admin" count == 1
+xpath "string(/admins/admin)" == "jane@example.org"
+
+GET {{restapi_url}}/chatrooms/ops/members
+Authorization: {{authkey}}
+HTTP 200
+[Asserts]
+xpath "/members/member" count == 0
+
+# TEST: Add an affiliation by username, which is resolved against the local domain
+POST {{restapi_url}}/chatrooms/ops/outcasts/mallory
+Authorization: {{authkey}}
+HTTP 201
+
+GET {{restapi_url}}/chatrooms/ops/outcasts
+Authorization: {{authkey}}
+HTTP 200
+[Asserts]
+xpath "/outcasts/outcast" count == 1
+xpath "string(/outcasts/outcast)" == "mallory@example.org"
+
+# TEST: Remove an affiliation
+DELETE {{restapi_url}}/chatrooms/ops/admins/jane@example.org
+Authorization: {{authkey}}
+HTTP 200
+
+GET {{restapi_url}}/chatrooms/ops/admins
+Authorization: {{authkey}}
+HTTP 200
+[Asserts]
+xpath "/admins/admin" count == 0
+
+DELETE {{restapi_url}}/chatrooms/ops/outcasts/mallory@example.org
+Authorization: {{authkey}}
+HTTP 200
+
+GET {{restapi_url}}/chatrooms/ops/outcasts
+Authorization: {{authkey}}
+HTTP 200
+[Asserts]
+xpath "/outcasts/outcast" count == 0
+
+# TEST: Removing the last owner of a chat room is a conflict
+DELETE {{restapi_url}}/chatrooms/ops/owners/john@example.org
+Authorization: {{authkey}}
+HTTP 409
+[Asserts]
+xpath "string(/error/exception)" == "NotAllowedException"
+
+GET {{restapi_url}}/chatrooms/ops/owners
+Authorization: {{authkey}}
+HTTP 200
+[Asserts]
+xpath "string(/owners/owner)" == "john@example.org"
+
+# TEST: Affiliate the members of a group, which the room lists as a group (or its members, when expanded)
+POST {{restapi_url}}/chatrooms/ops/members/group/crew
+Authorization: {{authkey}}
+HTTP 201
+
+GET {{restapi_url}}/chatrooms/ops
+Authorization: {{authkey}}
+HTTP 200
+[Asserts]
+xpath "/chatRoom/memberGroups/memberGroup" count == 1
+xpath "string(/chatRoom/memberGroups/memberGroup)" == "crew"
+xpath "/chatRoom/members/member" count == 0
+
+GET {{restapi_url}}/chatrooms/ops?expandGroups=true
+Authorization: {{authkey}}
+HTTP 200
+[Asserts]
+xpath "/chatRoom/members[member='john@example.org']" exists
+xpath "/chatRoom/members[member='jane@example.org']" exists
+
+# TEST: Remove the affiliation of the members of a group
+DELETE {{restapi_url}}/chatrooms/ops/members/group/crew
+Authorization: {{authkey}}
+HTTP 200
+
+GET {{restapi_url}}/chatrooms/ops
+Authorization: {{authkey}}
+HTTP 200
+[Asserts]
+xpath "/chatRoom/memberGroups/memberGroup" count == 0
+
+# TEST: Getting the affiliations of a chat room that does not exist fails
+GET {{restapi_url}}/chatrooms/nonexistent/members
+Authorization: {{authkey}}
+HTTP 404
+[Asserts]
+xpath "string(/error/exception)" == "RoomNotFoundException"
+xpath "string(/error/resource)" == "nonexistent"
+
+# TEST: An affiliation type that does not exist is not found
+# The API documents a 400 for this, but the URL does not match any endpoint, as the affiliation type is part of the
+# path. See https://github.com/igniterealtime/openfire-restAPI-plugin/issues/267
+GET {{restapi_url}}/chatrooms/ops/visitors
+Authorization: {{authkey}}
+HTTP 404
+
+# TEST: Replace the affiliations of a type with a collection
+# Disabled until https://github.com/igniterealtime/openfire-restAPI-plugin/issues/163 is fixed.
+PUT {{restapi_url}}/chatrooms/ops/members
+Authorization: {{authkey}}
+Content-Type: application/xml
+[Options]
+skip: true
+```
+
+
+ jane@example.org
+ mallory@example.org
+
+```
+HTTP 201
+
+GET {{restapi_url}}/chatrooms/ops/members
+Authorization: {{authkey}}
+[Options]
+skip: true
+HTTP 200
+[Asserts]
+xpath "/members/member" count == 2
+
+PUT {{restapi_url}}/chatrooms/ops/members
+Authorization: {{authkey}}
+Content-Type: application/xml
+[Options]
+skip: true
+```
+
+
+ jane@example.org
+
+```
+HTTP 201
+
+GET {{restapi_url}}/chatrooms/ops/members
+Authorization: {{authkey}}
+[Options]
+skip: true
+HTTP 200
+[Asserts]
+xpath "/members/member" count == 1
+xpath "string(/members/member)" == "jane@example.org"
+
+DELETE {{restapi_url}}/chatrooms/ops/members/jane@example.org
+Authorization: {{authkey}}
+[Options]
+skip: true
+HTTP 200
+
+# TEST: Add a collection of affiliations
+# Disabled until https://github.com/igniterealtime/openfire-restAPI-plugin/issues/163 is fixed.
+POST {{restapi_url}}/chatrooms/ops/outcasts
+Authorization: {{authkey}}
+Content-Type: application/xml
+[Options]
+skip: true
+```
+
+
+ eve@example.org
+ mallory@example.org
+
+```
+HTTP 201
+
+GET {{restapi_url}}/chatrooms/ops/outcasts
+Authorization: {{authkey}}
+[Options]
+skip: true
+HTTP 200
+[Asserts]
+xpath "/outcasts/outcast" count == 2
+
+DELETE {{restapi_url}}/chatrooms/ops/outcasts/eve@example.org
+Authorization: {{authkey}}
+[Options]
+skip: true
+HTTP 200
+
+DELETE {{restapi_url}}/chatrooms/ops/outcasts/mallory@example.org
+Authorization: {{authkey}}
+[Options]
+skip: true
+HTTP 200
+
+# TEST: Affiliating the members of a group that does not exist fails
+# Disabled until https://github.com/igniterealtime/openfire-restAPI-plugin/issues/286 is fixed.
+POST {{restapi_url}}/chatrooms/ops/members/group/nonexistent
+Authorization: {{authkey}}
+[Options]
+skip: true
+HTTP 404
+
+GET {{restapi_url}}/chatrooms/ops/members
+Authorization: {{authkey}}
+[Options]
+skip: true
+HTTP 200
+[Asserts]
+xpath "/members[member='nonexistent@example.org']" not exists
+
+
+DELETE {{restapi_url}}/chatrooms/ops
+Authorization: {{authkey}}
+HTTP 200
+
+DELETE {{restapi_url}}/groups/crew
+Authorization: {{authkey}}
+HTTP 200
diff --git a/test/chatrooms.hurl b/test/chatrooms.hurl
index 1c3027a89..aad477332 100644
--- a/test/chatrooms.hurl
+++ b/test/chatrooms.hurl
@@ -1,14 +1,505 @@
-GET http://localhost:9090/plugins/restapi/v1/chatrooms
+# TEST: List the chat rooms
+GET {{restapi_url}}/chatrooms
Authorization: {{authkey}}
HTTP 200
-GET http://localhost:9090/plugins/restapi/v1/sessions
+# TEST: A created chat room can be retrieved
+POST {{restapi_url}}/chatrooms
+Authorization: {{authkey}}
+Content-Type: application/xml
+```
+
+
+ lobby
+ The Lobby
+ Where everyone meets
+ Welcome
+ 30
+ true
+ true
+
+ john@example.org
+
+
+ jane@example.org
+
+
+```
+HTTP 201
+
+GET {{restapi_url}}/chatrooms/lobby
+Authorization: {{authkey}}
+HTTP 200
+[Asserts]
+xpath "string(/chatRoom/roomName)" == "lobby"
+xpath "string(/chatRoom/naturalName)" == "The Lobby"
+xpath "string(/chatRoom/description)" == "Where everyone meets"
+xpath "string(/chatRoom/subject)" == "Welcome"
+xpath "string(/chatRoom/maxUsers)" == "30"
+xpath "string(/chatRoom/persistent)" == "true"
+xpath "string(/chatRoom/publicRoom)" == "true"
+xpath "/chatRoom/owners/owner" count == 1
+xpath "string(/chatRoom/owners/owner)" == "john@example.org"
+xpath "/chatRoom/members/member" count == 1
+xpath "string(/chatRoom/members/member)" == "jane@example.org"
+xpath "/chatRoom/creationDate" exists
+
+# TEST: Creating a chat room that already exists is a conflict, and leaves the room unchanged
+# Disabled until https://github.com/igniterealtime/openfire-restAPI-plugin/issues/140 is fixed.
+POST {{restapi_url}}/chatrooms
+Authorization: {{authkey}}
+Content-Type: application/xml
+[Options]
+skip: true
+```
+
+
+ lobby
+ Replaced
+ Replaced
+
+```
+HTTP 409
+
+POST {{restapi_url}}/chatrooms/bulk
+Authorization: {{authkey}}
+Content-Type: application/xml
+[Options]
+skip: true
+```
+
+
+
+ lobby
+ Replaced
+ Replaced
+
+
+```
+HTTP 200
+[Asserts]
+xpath "/results/failure/result[roomName='lobby']" exists
+
+GET {{restapi_url}}/chatrooms/lobby
+Authorization: {{authkey}}
+[Options]
+skip: true
+HTTP 200
+[Asserts]
+xpath "string(/chatRoom/naturalName)" == "The Lobby"
+xpath "string(/chatRoom/owners/owner)" == "john@example.org"
+xpath "string(/chatRoom/members/member)" == "jane@example.org"
+
+# TEST: Only public chat rooms are listed, unless all rooms are asked for
+POST {{restapi_url}}/chatrooms
+Authorization: {{authkey}}
+Content-Type: application/xml
+```
+
+
+ backroom
+ The Back Room
+ Not listed
+ false
+
+```
+HTTP 201
+
+GET {{restapi_url}}/chatrooms
+Authorization: {{authkey}}
+HTTP 200
+[Asserts]
+xpath "/chatRooms/chatRoom[roomName='lobby']" exists
+xpath "/chatRooms/chatRoom[roomName='backroom']" not exists
+
+GET {{restapi_url}}/chatrooms?type=public
+Authorization: {{authkey}}
+HTTP 200
+[Asserts]
+xpath "/chatRooms/chatRoom[roomName='lobby']" exists
+xpath "/chatRooms/chatRoom[roomName='backroom']" not exists
+
+GET {{restapi_url}}/chatrooms?type=all
+Authorization: {{authkey}}
+HTTP 200
+[Asserts]
+xpath "/chatRooms/chatRoom[roomName='lobby']" exists
+xpath "/chatRooms/chatRoom[roomName='backroom']" exists
+
+# TEST: Search for chat rooms by (part of) their name or natural name
+GET {{restapi_url}}/chatrooms?search=lob
+Authorization: {{authkey}}
+HTTP 200
+[Asserts]
+xpath "/chatRooms/chatRoom" count == 1
+xpath "string(/chatRooms/chatRoom/roomName)" == "lobby"
+
+GET {{restapi_url}}/chatrooms?type=all&search=Back
+Authorization: {{authkey}}
+HTTP 200
+[Asserts]
+xpath "/chatRooms/chatRoom" count == 1
+xpath "string(/chatRooms/chatRoom/roomName)" == "backroom"
+
+GET {{restapi_url}}/chatrooms?type=all&search=nothing-matches-this
+Authorization: {{authkey}}
+HTTP 200
+[Asserts]
+xpath "/chatRooms/chatRoom" count == 0
+
+# TEST: Search for chat rooms by a natural name that is not a valid JID node
+# Disabled until https://github.com/igniterealtime/openfire-restAPI-plugin/issues/284 is fixed.
+GET {{restapi_url}}/chatrooms?type=all&search=Back%20Room
+Authorization: {{authkey}}
+[Options]
+skip: true
+HTTP 200
+[Asserts]
+xpath "/chatRooms/chatRoom" count == 1
+xpath "string(/chatRooms/chatRoom/roomName)" == "backroom"
+
+# TEST: Listing the chat rooms of a service that does not exist fails
+GET {{restapi_url}}/chatrooms?servicename=nonexistent
+Authorization: {{authkey}}
+HTTP 404
+[Asserts]
+xpath "string(/error/exception)" == "MUCServiceNotFoundException"
+xpath "string(/error/resource)" == "nonexistent"
+
+# TEST: Getting a chat room that does not exist fails
+GET {{restapi_url}}/chatrooms/nonexistent
+Authorization: {{authkey}}
+HTTP 404
+[Asserts]
+xpath "string(/error/exception)" == "RoomNotFoundException"
+xpath "string(/error/resource)" == "nonexistent"
+
+# TEST: Getting a chat room of a service that does not exist fails
+GET {{restapi_url}}/chatrooms/lobby?servicename=nonexistent
+Authorization: {{authkey}}
+HTTP 404
+[Asserts]
+xpath "string(/error/exception)" == "MUCServiceNotFoundException"
+
+# TEST: Update a chat room
+PUT {{restapi_url}}/chatrooms/lobby
+Authorization: {{authkey}}
+Content-Type: application/xml
+```
+
+
+ lobby
+ The Grand Lobby
+ Where everyone meets, now bigger
+ Welcome
+ 50
+ true
+ true
+
+ john@example.org
+
+
+ jane@example.org
+
+
+```
+HTTP 200
+
+GET {{restapi_url}}/chatrooms/lobby
+Authorization: {{authkey}}
+HTTP 200
+[Asserts]
+xpath "string(/chatRoom/naturalName)" == "The Grand Lobby"
+xpath "string(/chatRoom/description)" == "Where everyone meets, now bigger"
+xpath "string(/chatRoom/maxUsers)" == "50"
+
+# TEST: Updating a chat room that does not exist fails
+# Disabled until https://github.com/igniterealtime/openfire-restAPI-plugin/issues/283 is fixed.
+# (The issue also allows for documenting that this creates the room, in which case this test needs to change.)
+PUT {{restapi_url}}/chatrooms/nonexistent
+Authorization: {{authkey}}
+Content-Type: application/xml
+[Options]
+skip: true
+```
+
+
+ nonexistent
+ Created by an update
+ A room that did not exist
+
+```
+HTTP 404
+
+GET {{restapi_url}}/chatrooms/nonexistent
+Authorization: {{authkey}}
+[Options]
+skip: true
+HTTP 404
+
+# TEST: A new chat room has no participants, occupants or history
+GET {{restapi_url}}/chatrooms/lobby/participants
+Authorization: {{authkey}}
+HTTP 200
+[Asserts]
+xpath "/participants/participant" count == 0
+
+GET {{restapi_url}}/chatrooms/lobby/occupants
Authorization: {{authkey}}
HTTP 200
[Asserts]
-xpath "/sessions[not(child::node())]" count == 1 # sessions at the root, with no child nodes
+xpath "/occupants/occupant" count == 0
+GET {{restapi_url}}/chatrooms/lobby/chathistory
+Authorization: {{authkey}}
+HTTP 200
+[Asserts]
+xpath "/messages/message" count == 0
+
+# TEST: Getting the participants, occupants or history of a chat room that does not exist fails
+GET {{restapi_url}}/chatrooms/nonexistent/participants
+Authorization: {{authkey}}
+HTTP 404
+[Asserts]
+xpath "string(/error/exception)" == "RoomNotFoundException"
-GET http://localhost:9090/plugins/restapi/v1/system/readiness/server
+GET {{restapi_url}}/chatrooms/nonexistent/occupants
Authorization: {{authkey}}
-HTTP 200
\ No newline at end of file
+HTTP 404
+[Asserts]
+xpath "string(/error/exception)" == "RoomNotFoundException"
+
+GET {{restapi_url}}/chatrooms/nonexistent/chathistory
+Authorization: {{authkey}}
+HTTP 404
+[Asserts]
+xpath "string(/error/exception)" == "RoomNotFoundException"
+
+
+# TEST: A client that joins a chat room is listed as a participant and occupant, and what it says is in the history
+# Log in anonymously over BOSH (XEP-0124/XEP-0206). The server may send stanzas (like pings) at any time, so responses
+# are only checked for the presence of the stanzas that are expected.
+POST {{bosh_url}}/http-bind/
+Content-Type: text/xml; charset=utf-8
+[Options]
+variable: nick=visitor-{{newUuid}}
+```
+
+```
+HTTP 200
+[Captures]
+sid: xpath "string(/_:body/@sid)"
+
+POST {{bosh_url}}/http-bind/
+Content-Type: text/xml; charset=utf-8
+```
+
+```
+HTTP 200
+[Asserts]
+xpath "//*[local-name()='success']" exists
+
+POST {{bosh_url}}/http-bind/
+Content-Type: text/xml; charset=utf-8
+```
+
+```
+HTTP 200
+[Captures]
+bare_jid: xpath "substring-before(//*[local-name()='jid'], '/')"
+
+POST {{bosh_url}}/http-bind/
+Content-Type: text/xml; charset=utf-8
+```
+
+```
+HTTP 200
+
+# Join the room.
+POST {{bosh_url}}/http-bind/
+Content-Type: text/xml; charset=utf-8
+```
+
+```
+HTTP 200
+[Asserts]
+xpath "//*[local-name()='presence'][@from='lobby@conference.example.org/{{nick}}']" exists
+
+GET {{restapi_url}}/chatrooms/lobby/participants
+Authorization: {{authkey}}
+HTTP 200
+[Asserts]
+xpath "/participants/participant" count == 1
+xpath "string(/participants/participant/jid)" == "lobby@conference.example.org/{{nick}}"
+xpath "string(/participants/participant/role)" == "participant"
+xpath "string(/participants/participant/affiliation)" == "none"
+
+GET {{restapi_url}}/chatrooms/lobby/occupants
+Authorization: {{authkey}}
+HTTP 200
+[Asserts]
+xpath "/occupants/occupant" count == 1
+xpath "string(/occupants/occupant/jid)" == "lobby@conference.example.org/{{nick}}"
+xpath "string(/occupants/occupant/userAddress)" startsWith "{{bare_jid}}/"
+
+POST {{bosh_url}}/http-bind/
+Content-Type: text/xml; charset=utf-8
+```
+Hello, room
+```
+HTTP 200
+
+GET {{restapi_url}}/chatrooms/lobby/chathistory
+Authorization: {{authkey}}
+HTTP 200
+[Asserts]
+xpath "/messages/message" count == 1
+xpath "string(/messages/message/from)" == "lobby@conference.example.org/{{nick}}"
+xpath "string(/messages/message/type)" == "groupchat"
+xpath "string(/messages/message/body)" == "Hello, room"
+
+
+# TEST: Invite a user to a chat room, with a reason
+POST {{restapi_url}}/chatrooms/lobby/invite/{{bare_jid}}
+Authorization: {{authkey}}
+Content-Type: application/xml
+```
+
+
+ Please join us
+
+```
+HTTP 200
+
+# The poll returns everything that is pending for the client, which includes the invitation.
+POST {{bosh_url}}/http-bind/
+Content-Type: text/xml; charset=utf-8
+```
+
+```
+HTTP 200
+
+
+# TEST: Inviting to a chat room without a request body is rejected
+# Disabled until https://github.com/igniterealtime/openfire-restAPI-plugin/issues/285 is fixed.
+POST {{restapi_url}}/chatrooms/lobby/invite/jane@example.org
+Authorization: {{authkey}}
+[Options]
+skip: true
+HTTP 400
+
+POST {{restapi_url}}/chatrooms/lobby/invite
+Authorization: {{authkey}}
+[Options]
+skip: true
+HTTP 400
+
+# TEST: Create multiple chat rooms at once
+POST {{restapi_url}}/chatrooms/bulk
+Authorization: {{authkey}}
+Content-Type: application/xml
+```
+
+
+
+ bulk1
+ Bulk room 1
+ Created in bulk
+
+
+ bulk2
+ Bulk room 2
+ Created in bulk
+
+
+```
+HTTP 200
+[Asserts]
+xpath "/results/success/result" count == 2
+xpath "/results/success/result[roomName='bulk1'][resultType='Success']" exists
+xpath "/results/success/result[roomName='bulk2'][resultType='Success']" exists
+xpath "/results/failure/result" count == 0
+
+GET {{restapi_url}}/chatrooms/bulk1
+Authorization: {{authkey}}
+HTTP 200
+[Asserts]
+xpath "string(/chatRoom/naturalName)" == "Bulk room 1"
+
+GET {{restapi_url}}/chatrooms/bulk2
+Authorization: {{authkey}}
+HTTP 200
+[Asserts]
+xpath "string(/chatRoom/naturalName)" == "Bulk room 2"
+
+# TEST: Delete a chat room
+DELETE {{restapi_url}}/chatrooms/lobby
+Authorization: {{authkey}}
+HTTP 200
+
+GET {{restapi_url}}/chatrooms/lobby
+Authorization: {{authkey}}
+HTTP 404
+
+# TEST: Deleting a chat room that does not exist fails
+DELETE {{restapi_url}}/chatrooms/nonexistent
+Authorization: {{authkey}}
+HTTP 404
+[Asserts]
+xpath "string(/error/exception)" == "RoomNotFoundException"
+
+
+DELETE {{restapi_url}}/chatrooms/backroom
+Authorization: {{authkey}}
+HTTP 200
+
+DELETE {{restapi_url}}/chatrooms/bulk1
+Authorization: {{authkey}}
+HTTP 200
+
+DELETE {{restapi_url}}/chatrooms/bulk2
+Authorization: {{authkey}}
+HTTP 200
diff --git a/test/chatservice.hurl b/test/chatservice.hurl
index 99f31274b..96889cb0b 100644
--- a/test/chatservice.hurl
+++ b/test/chatservice.hurl
@@ -1,8 +1,106 @@
-GET http://localhost:9090/plugins/restapi/v1/chatservices
+# TEST: The default 'conference' chat service is listed
+GET {{restapi_url}}/chatservices
Authorization: {{authkey}}
HTTP 200
[Asserts]
-xpath "/chatServices/chatService" count == 1
-xpath "string(/chatServices/chatService/serviceName)" == "conference"
-xpath "string(/chatServices/chatService/description)" == "Public Chatrooms"
-xpath "string(/chatServices/chatService/hidden)" == "false"
+xpath "/chatServices/chatService[serviceName='conference']" count == 1
+xpath "string(/chatServices/chatService[serviceName='conference']/description)" == "Public Chatrooms"
+xpath "string(/chatServices/chatService[serviceName='conference']/hidden)" == "false"
+
+# The API cannot delete a chat service, so each run creates a new one, with a unique name.
+
+# TEST: A created chat service is listed, and can have chat rooms
+POST {{restapi_url}}/chatservices
+Authorization: {{authkey}}
+Content-Type: application/xml
+[Options]
+variable: service=test-{{newUuid}}
+```
+
+
+ {{service}}
+ Test service
+ true
+
+```
+HTTP 201
+
+GET {{restapi_url}}/chatservices
+Authorization: {{authkey}}
+HTTP 200
+[Asserts]
+xpath "/chatServices/chatService[serviceName='{{service}}']" count == 1
+xpath "string(/chatServices/chatService[serviceName='{{service}}']/description)" == "Test service"
+xpath "string(/chatServices/chatService[serviceName='{{service}}']/hidden)" == "true"
+
+POST {{restapi_url}}/chatrooms?servicename={{service}}
+Authorization: {{authkey}}
+Content-Type: application/xml
+```
+
+
+ room
+ A room
+ A room in the test service
+
+```
+HTTP 201
+
+GET {{restapi_url}}/chatrooms?servicename={{service}}&type=all
+Authorization: {{authkey}}
+HTTP 200
+[Asserts]
+xpath "/chatRooms/chatRoom" count == 1
+xpath "string(/chatRooms/chatRoom/roomName)" == "room"
+
+DELETE {{restapi_url}}/chatrooms/room?servicename={{service}}
+Authorization: {{authkey}}
+HTTP 200
+
+# TEST: Creating a chat service that already exists is a conflict
+POST {{restapi_url}}/chatservices
+Authorization: {{authkey}}
+Content-Type: application/xml
+```
+
+
+ {{service}}
+ Another test service
+ false
+
+```
+HTTP 409
+[Asserts]
+xpath "string(/error/exception)" == "AlreadyExistsException"
+xpath "string(/error/resource)" == "{{service}}"
+
+# TEST: Creating a chat service without a name is rejected
+# Disabled until https://github.com/igniterealtime/openfire-restAPI-plugin/issues/287 is fixed.
+POST {{restapi_url}}/chatservices
+Authorization: {{authkey}}
+Content-Type: application/xml
+[Options]
+skip: true
+```
+
+
+ No name
+ false
+
+```
+HTTP 400
+
+POST {{restapi_url}}/chatservices
+Authorization: {{authkey}}
+Content-Type: application/xml
+[Options]
+skip: true
+```
+
+
+
+ Empty name
+ false
+
+```
+HTTP 400
diff --git a/test/clustering.hurl b/test/clustering.hurl
index 84fd5d471..83dbbd7fa 100644
--- a/test/clustering.hurl
+++ b/test/clustering.hurl
@@ -1,10 +1,12 @@
-GET {{host}}/plugins/restapi/v1/clustering/status
+# TEST: Clustering is reported as disabled on a standalone server
+GET {{restapi_url}}/clustering/status
Authorization: {{authkey}}
HTTP 200
[Asserts]
xpath "string(/clustering/status)" == "Disabled"
-GET {{host}}/plugins/restapi/v1/clustering/nodes
+# TEST: A standalone server has no cluster nodes
+GET {{restapi_url}}/clustering/nodes
Authorization: {{authkey}}
HTTP 200
[Asserts]
diff --git a/test/groups.hurl b/test/groups.hurl
index 299ec523a..365457202 100644
--- a/test/groups.hurl
+++ b/test/groups.hurl
@@ -1,11 +1,13 @@
-GET http://localhost:9090/plugins/restapi/v1/groups
+# TEST: There are no groups initially
+GET {{restapi_url}}/groups
Authorization: {{authkey}}
HTTP 200
[Asserts]
xpath "/groups[not(child::node())]" exists # groups at the root, with no child nodes
-POST http://localhost:9090/plugins/restapi/v1/groups
+# TEST: A created group is listed, and can be retrieved
+POST {{restapi_url}}/groups
Authorization: {{authkey}}
Content-Type: application/xml
```
@@ -24,20 +26,21 @@ Content-Type: application/xml
```
HTTP 201
-GET http://localhost:9090/plugins/restapi/v1/groups # check if the group was created
+GET {{restapi_url}}/groups
Authorization: {{authkey}}
HTTP 200
[Asserts]
xpath "/groups/group[name='group1']" exists
-GET http://localhost:9090/plugins/restapi/v1/groups/group1
+GET {{restapi_url}}/groups/group1
Authorization: {{authkey}}
HTTP 200
[Asserts]
xpath "/group[name='group1']" exists
xpath "string(/group/description)" == "test-group"
-PUT http://localhost:9090/plugins/restapi/v1/groups/group1
+# TEST: Updating a group changes its description
+PUT {{restapi_url}}/groups/group1
Authorization: {{authkey}}
Content-Type: application/xml
```
@@ -56,14 +59,49 @@ Content-Type: application/xml
```
HTTP 200
-GET http://localhost:9090/plugins/restapi/v1/groups/group1
+GET {{restapi_url}}/groups/group1
Authorization: {{authkey}}
HTTP 200
[Asserts]
xpath "/group[name='group1']" exists
xpath "string(/group/description)" == "test-group-updated"
-DELETE http://localhost:9090/plugins/restapi/v1/groups/group1
+# TEST: Delete a group
+DELETE {{restapi_url}}/groups/group1
Authorization: {{authkey}}
HTTP 200
+# TEST: A group can be created without an admins element
+# Disabled until https://github.com/igniterealtime/openfire-restAPI-plugin/issues/288 is fixed.
+POST {{restapi_url}}/groups
+Authorization: {{authkey}}
+Content-Type: application/xml
+[Options]
+skip: true
+```
+
+
+ group2
+ A group without an admins element
+ false
+
+ john
+
+
+```
+HTTP 201
+
+GET {{restapi_url}}/groups/group2
+Authorization: {{authkey}}
+[Options]
+skip: true
+HTTP 200
+[Asserts]
+xpath "/group/members/member" count == 1
+xpath "/group/admins/admin" count == 0
+
+DELETE {{restapi_url}}/groups/group2
+Authorization: {{authkey}}
+[Options]
+skip: true
+HTTP 200
diff --git a/test/messagearchive.hurl b/test/messagearchive.hurl
index 7b06bc4f2..1b7551c47 100644
--- a/test/messagearchive.hurl
+++ b/test/messagearchive.hurl
@@ -1,8 +1,13 @@
-GET http://localhost:9090/plugins/restapi/v1/archive/messages/unread/john@example.org
+# TEST: Get the unread message count for a user
+GET {{restapi_url}}/archive/messages/unread/john@example.org
Authorization: {{authkey}}
HTTP 200
[Asserts]
xpath "string(/archive/jid)" == "john@example.org"
xpath "string(/archive/count)" == "0"
-# TODO: How to get this >0 ?
\ No newline at end of file
+# TODO: The unread message count is read from the ofMessageArchive table, which is created and filled by the Monitoring
+# plugin. That plugin is not installed on the test server, so the query fails (Openfire logs a warning) and 0 is
+# returned. To test a count above 0: install the Monitoring plugin (with message archiving enabled), log in as john
+# over BOSH and log out again (to record an offline date in ofPresence), send john a message from jane over BOSH, and
+# then expect a count of 1.
diff --git a/test/messagebroadcast.hurl b/test/messagebroadcast.hurl
index 3bec3dbf5..5ec55e215 100644
--- a/test/messagebroadcast.hurl
+++ b/test/messagebroadcast.hurl
@@ -1,4 +1,5 @@
-POST http://localhost:9090/plugins/restapi/v1/messages/users
+# TEST: Broadcast a message while there are no sessions to deliver it to
+POST {{restapi_url}}/messages/users
Authorization: {{authkey}}
Content-Type: application/xml
```
@@ -9,4 +10,103 @@ Content-Type: application/xml
```
HTTP 201
-# TODO: What could validate behaviour?
\ No newline at end of file
+# TEST: Broadcasting a message with an empty body is rejected
+POST {{restapi_url}}/messages/users
+Authorization: {{authkey}}
+Content-Type: application/xml
+```
+
+
+
+
+```
+HTTP 400
+[Asserts]
+xpath "string(/error/exception)" == "IllegalArgumentException"
+
+# TEST: Broadcasting a message without a body is rejected
+POST {{restapi_url}}/messages/users
+Authorization: {{authkey}}
+Content-Type: application/xml
+```
+
+
+```
+HTTP 400
+[Asserts]
+xpath "string(/error/exception)" == "IllegalArgumentException"
+
+
+# TEST: A logged-in client receives a broadcast message
+# Log in anonymously over BOSH (XEP-0124/XEP-0206), to be able to receive the broadcast.
+POST {{bosh_url}}/http-bind/
+Content-Type: text/xml; charset=utf-8
+```
+
+```
+HTTP 200
+[Asserts]
+xpath "//*[local-name()='success']" exists
+
+POST {{bosh_url}}/http-bind/
+Content-Type: text/xml; charset=utf-8
+```
+
+```
+HTTP 200
+
+POST {{bosh_url}}/http-bind/
+Content-Type: text/xml; charset=utf-8
+```
+
+```
+HTTP 200
+
+POST {{restapi_url}}/messages/users
+Authorization: {{authkey}}
+Content-Type: application/xml
+```
+
+
+ Hello from the REST API
+
+```
+HTTP 201
+
+# Poll for the broadcast, which is delivered as a headline message from the server.
+POST {{bosh_url}}/http-bind/
+Content-Type: text/xml; charset=utf-8
+```
+
+```
+HTTP 200
+[Asserts]
+xpath "string(/_:body/@type)" == "terminate"
diff --git a/test/securitylog.hurl b/test/securitylog.hurl
index 6a820dcf3..9a67730ed 100644
--- a/test/securitylog.hurl
+++ b/test/securitylog.hurl
@@ -1,11 +1,93 @@
-GET http://localhost:9090/plugins/restapi/v1/logs/security
+# TEST: An admin console login is recorded as a new event in the security audit log
+# The log may already contain events (e.g. from earlier runs). Events are returned newest first, so remember the ID of
+# the newest one (or 0 when the log is empty), to be able to tell which events are new.
+GET {{restapi_url}}/logs/security?limit=1
Authorization: {{authkey}}
HTTP 200
[Asserts]
-xpath "/logs/log" count == 0 # TODO: How to make this have 1+ events
-#xpath "/logs/log/details" exists
-#xpath "/logs/log/logId" exists
-#xpath "/logs/log/node" exists
-#xpath "/logs/log/summary" exists
-#xpath "/logs/log/timestamp" exists
-#xpath "/logs/log/username" exists
\ No newline at end of file
+xpath "/logs" exists
+xpath "/logs/log" count <= 1
+[Captures]
+last_log_id: xpath "number(concat('0', /logs/log/logId))"
+
+
+# Openfire records a security audit event for every login to the admin console, so log in to create one. (The REST API
+# does not record audit events of its own: https://github.com/igniterealtime/openfire-restAPI-plugin/issues/115)
+GET {{adminconsole_url}}/login.jsp
+HTTP 200
+[Captures]
+csrf: cookie "csrf"
+
+POST {{adminconsole_url}}/login.jsp
+[Form]
+login: true
+csrf: {{csrf}}
+username: {{admin_username}}
+password: {{admin_password}}
+HTTP 302
+[Asserts]
+header "Location" == "/index.jsp"
+
+
+GET {{restapi_url}}/logs/security
+Authorization: {{authkey}}
+HTTP 200
+[Asserts]
+xpath "/logs/log[logId > {{last_log_id}}]" count == 1
+xpath "string(/logs/log[logId > {{last_log_id}}]/summary)" == "Successful admin console login attempt"
+xpath "string(/logs/log[logId > {{last_log_id}}]/username)" == "{{admin_username}}"
+xpath "string(/logs/log[logId > {{last_log_id}}]/node)" == "example.org"
+xpath "string(/logs/log[logId > {{last_log_id}}]/details)" startsWith "The user logged in successfully to the admin console"
+xpath "/logs/log[logId > {{last_log_id}}]/timestamp" exists
+
+# TEST: Filtering by username returns only that user's events
+GET {{restapi_url}}/logs/security?username={{admin_username}}
+Authorization: {{authkey}}
+HTTP 200
+[Asserts]
+xpath "/logs/log[logId > {{last_log_id}}]" count == 1
+xpath "/logs/log[username!='{{admin_username}}']" not exists
+
+# TEST: Filtering by an unknown username returns no events
+GET {{restapi_url}}/logs/security?username=nonexistent
+Authorization: {{authkey}}
+HTTP 200
+[Asserts]
+xpath "/logs/log" count == 0
+
+# TEST: The number of events returned can be limited, newest first
+GET {{restapi_url}}/logs/security?limit=1
+Authorization: {{authkey}}
+HTTP 200
+[Asserts]
+xpath "/logs/log" count == 1
+xpath "/logs/log[logId > {{last_log_id}}]" count == 1
+
+# TEST: Skipping more events than exist returns no events
+GET {{restapi_url}}/logs/security?offset=1000000
+Authorization: {{authkey}}
+HTTP 200
+[Asserts]
+xpath "/logs/log" count == 0
+
+# TEST: Filtering by a start time in the future returns no events
+# Timestamps are in seconds since the epoch: nothing was logged after 2100-01-01, or before 1970-01-01T00:00:01.
+GET {{restapi_url}}/logs/security?startTime=4102444800
+Authorization: {{authkey}}
+HTTP 200
+[Asserts]
+xpath "/logs/log" count == 0
+
+# TEST: Filtering by an end time in the distant past returns no events
+GET {{restapi_url}}/logs/security?endTime=1
+Authorization: {{authkey}}
+HTTP 200
+[Asserts]
+xpath "/logs/log" count == 0
+
+# TEST: Filtering by a time range includes the new event
+GET {{restapi_url}}/logs/security?startTime=1&endTime=4102444800
+Authorization: {{authkey}}
+HTTP 200
+[Asserts]
+xpath "/logs/log[logId > {{last_log_id}}]" count == 1
diff --git a/test/sessions.hurl b/test/sessions.hurl
index 12d28127e..523e7dec6 100644
--- a/test/sessions.hurl
+++ b/test/sessions.hurl
@@ -1,20 +1,149 @@
-GET http://localhost:9090/plugins/restapi/v1/sessions
+# Sessions are tested on a dedicated user, which is removed at the end.
+POST {{restapi_url}}/users
+Authorization: {{authkey}}
+Content-Type: application/xml
+```
+
+
+ riker
+ number1
+
+```
+HTTP 201
+
+
+# TEST: There are no sessions initially
+GET {{restapi_url}}/sessions
Authorization: {{authkey}}
HTTP 200
[Asserts]
xpath "/sessions" exists
+xpath "/sessions[not(child::node())]" count == 1 # sessions at the root, with no child nodes
-GET http://localhost:9090/plugins/restapi/v1/sessions/john
+# TEST: A user without sessions has an empty list of sessions
+GET {{restapi_url}}/sessions/riker
Authorization: {{authkey}}
HTTP 200
[Asserts]
xpath "/sessions" exists
+xpath "/sessions/session" count == 0
-# TODO: This documents behaviour, but is it correct??
-GET http://localhost:9090/plugins/restapi/v1/sessions/nonexistent
+# TEST: A user that does not exist has an empty list of sessions
+# Openfire does not check whether the user exists: a user that does not exist simply has no sessions. The API documents
+# no other response than 200 for this endpoint.
+GET {{restapi_url}}/sessions/nonexistent
Authorization: {{authkey}}
HTTP 200
[Asserts]
xpath "/sessions" exists
+xpath "/sessions/session" count == 0
+
+
+# TEST: A logged-in client's session is listed with its details, for its user only
+# Create a session for the user, by logging in over BOSH (XEP-0124/XEP-0206).
+POST {{bosh_url}}/http-bind/
+Content-Type: text/xml; charset=utf-8
+```
+AHJpa2VyAG51bWJlcjE=
+```
+HTTP 200
+[Asserts]
+xpath "//*[local-name()='success']" exists
+
+POST {{bosh_url}}/http-bind/
+Content-Type: text/xml; charset=utf-8
+```
+hurl
+```
+HTTP 200
+[Asserts]
+xpath "string(//*[local-name()='jid'])" == "riker@example.org/hurl"
+
+# The session is only listed once it has sent initial presence.
+POST {{bosh_url}}/http-bind/
+Content-Type: text/xml; charset=utf-8
+```
+
+```
+HTTP 200
+
+GET {{restapi_url}}/sessions
+Authorization: {{authkey}}
+HTTP 200
+[Asserts]
+xpath "/sessions/session[sessionId='riker@example.org/hurl']" exists
+
+GET {{restapi_url}}/sessions/riker
+Authorization: {{authkey}}
+HTTP 200
+[Asserts]
+xpath "/sessions/session" count == 1
+xpath "string(/sessions/session/sessionId)" == "riker@example.org/hurl"
+xpath "string(/sessions/session/username)" == "riker"
+xpath "string(/sessions/session/resource)" == "hurl"
+xpath "string(/sessions/session/node)" == "Local"
+xpath "string(/sessions/session/sessionStatus)" == "Authenticated"
+xpath "string(/sessions/session/presenceStatus)" == "Online"
+xpath "string(/sessions/session/priority)" == "0"
+xpath "/sessions/session/hostAddress" exists
+xpath "/sessions/session/creationDate" exists
+xpath "/sessions/session/lastActionDate" exists
-# TODO: create a session (somehow), then read its props, then kick it with DELETE
\ No newline at end of file
+GET {{restapi_url}}/sessions/jane
+Authorization: {{authkey}}
+HTTP 200
+[Asserts]
+xpath "/sessions/session" count == 0
+
+# TEST: Kicking a user's sessions closes them
+DELETE {{restapi_url}}/sessions/riker
+Authorization: {{authkey}}
+HTTP 200
+
+# Sessions are closed asynchronously, so allow for some delay.
+GET {{restapi_url}}/sessions/riker
+Authorization: {{authkey}}
+[Options]
+retry: 10
+retry-interval: 100ms
+HTTP 200
+[Asserts]
+xpath "/sessions/session" count == 0
+
+# The BOSH session no longer exists either. (A poll that is made while the session is being closed is still answered.)
+POST {{bosh_url}}/http-bind/
+Content-Type: text/xml; charset=utf-8
+[Options]
+retry: 10
+retry-interval: 100ms
+```
+AHdvcmYAcWFwbGE=
+```
+HTTP 200
+[Asserts]
+xpath "//*[local-name()='success']" exists
+
+POST {{bosh_url}}/http-bind/
+Content-Type: text/xml; charset=utf-8
+```
+hurl
+```
+HTTP 200
+
+POST {{bosh_url}}/http-bind/
+Content-Type: text/xml; charset=utf-8
+```
+
+```
+HTTP 200
+
+GET {{restapi_url}}/sessions/worf
+Authorization: {{authkey}}
+HTTP 200
+[Asserts]
+xpath "/sessions/session" count == 1
+
+POST {{restapi_url}}/lockouts/worf
+Authorization: {{authkey}}
+HTTP 201
+
+# Sessions are closed asynchronously, so allow for some delay.
+GET {{restapi_url}}/sessions/worf
+Authorization: {{authkey}}
+[Options]
+retry: 10
+retry-interval: 100ms
+HTTP 200
+[Asserts]
+xpath "/sessions/session" count == 0
+
+# The BOSH session no longer exists either. (A poll that is made while the session is being closed is still answered.)
+POST {{bosh_url}}/http-bind/
+Content-Type: text/xml; charset=utf-8
+[Options]
+retry: 10
+retry-interval: 100ms
+```
+AHdvcmYAcWFwbGE=
+```
+HTTP 200
+[Asserts]
+xpath "//*[local-name()='failure']/*[local-name()='not-authorized']" exists
+
+POST {{bosh_url}}/http-bind/
+Content-Type: text/xml; charset=utf-8
+```
+AHdvcmYAcWFwbGE=
+```
+HTTP 200
+[Asserts]
+xpath "//*[local-name()='success']" exists
+
+POST {{bosh_url}}/http-bind/
+Content-Type: text/xml; charset=utf-8
+```
+hurl
+```
+HTTP 200
+
+POST {{bosh_url}}/http-bind/
+Content-Type: text/xml; charset=utf-8
+```
+
+```
+HTTP 200
+
+POST {{bosh_url}}/http-bind/
+Content-Type: text/xml; charset=utf-8
+```
+