From 8e170a53737fe73c14e7b7718e146569f8ed7170 Mon Sep 17 00:00:00 2001 From: Git'Fellow <12234510+solracsf@users.noreply.github.com> Date: Thu, 30 Jul 2026 09:22:41 +0200 Subject: [PATCH] test: cover the documented REST API with integration tests Signed-off-by: Git'Fellow <12234510+solracsf@users.noreply.github.com> --- tests/integration/base-query-count.txt | 2 +- tests/integration/config/behat.yml | 1 + .../features/api/attachments.feature | 94 +++++ .../features/api/authentication.feature | 47 +++ tests/integration/features/api/boards.feature | 208 ++++++++++ tests/integration/features/api/cards.feature | 222 +++++++++++ tests/integration/features/api/config.feature | 62 +++ tests/integration/features/api/labels.feature | 82 ++++ tests/integration/features/api/stacks.feature | 122 ++++++ .../features/bootstrap/DeckApiContext.php | 366 ++++++++++++++++++ 10 files changed, 1205 insertions(+), 1 deletion(-) create mode 100644 tests/integration/features/api/attachments.feature create mode 100644 tests/integration/features/api/authentication.feature create mode 100644 tests/integration/features/api/boards.feature create mode 100644 tests/integration/features/api/cards.feature create mode 100644 tests/integration/features/api/config.feature create mode 100644 tests/integration/features/api/labels.feature create mode 100644 tests/integration/features/api/stacks.feature create mode 100644 tests/integration/features/bootstrap/DeckApiContext.php diff --git a/tests/integration/base-query-count.txt b/tests/integration/base-query-count.txt index 6278065902..d1a1c14bed 100644 --- a/tests/integration/base-query-count.txt +++ b/tests/integration/base-query-count.txt @@ -1 +1 @@ -96706 \ No newline at end of file +198420 \ No newline at end of file diff --git a/tests/integration/config/behat.yml b/tests/integration/config/behat.yml index f37ac238e8..eb932ec51c 100644 --- a/tests/integration/config/behat.yml +++ b/tests/integration/config/behat.yml @@ -14,6 +14,7 @@ default: baseUrl: http://localhost:8080/ - RequestContext - BoardContext + - DeckApiContext - CommentContext - AttachmentContext - SearchContext diff --git a/tests/integration/features/api/attachments.feature b/tests/integration/features/api/attachments.feature new file mode 100644 index 0000000000..9f491e13d2 --- /dev/null +++ b/tests/integration/features/api/attachments.feature @@ -0,0 +1,94 @@ +Feature: REST API - Attachments + Basic coverage of the attachment endpoints documented at + https://deck.readthedocs.io/en/stable/API/#attachments + + Background: + Given user "admin" exists + And user "user0" exists + And user "user1" exists + And acting as user "user0" + And sending "POST" to the API endpoint "/boards" with body: + | title | Attachment board | + | color | ff0000 | + And the response value "id" is stored as "boardId" + And sending "POST" to the API endpoint "/boards//stacks" with body: + | title | ToDo | + | order | 1 | + And the response value "id" is stored as "stackId" + And sending "POST" to the API endpoint "/boards//stacks//cards" with body: + | title | Card with attachments | + | type | plain | + | order | 999 | + And the response value "id" is stored as "cardId" + + Scenario: POST /boards/{boardId}/stacks/{stackId}/cards/{cardId}/attachments - Upload an attachment + When uploading the file "test.txt" with content "Example content" as attachment type "deck_file" to the API endpoint "/boards//stacks//cards//attachments" + Then the response should have a status code "200" + And the response value "cardId" should be "" + And the response value "type" should be "deck_file" + And the response value "data" should be "test.txt" + And the response value "createdBy" should be "user0" + And the response value "deletedAt" should be "0" + And the response value "extendedData.filesize" should be "15" + + Scenario: GET /boards/{boardId}/stacks/{stackId}/cards/{cardId}/attachments - Get a list of attachments + Given uploading the file "test.txt" with content "Example content" as attachment type "deck_file" to the API endpoint "/boards//stacks//cards//attachments" + And the response should have a status code "200" + When sending "GET" to the API endpoint "/boards//stacks//cards//attachments" + Then the response should have a status code "200" + And the response should be a list of objects + And the response list should contain 1 entry + And the response list should contain an entry with "data" set to "test.txt" + And the response value "0.extendedData.info.filename" should be "test" + And the response value "0.extendedData.info.extension" should be "txt" + + Scenario: GET /boards/{boardId}/stacks/{stackId}/cards/{cardId}/attachments/{attachmentId} - Get the attachment file + Given uploading the file "test.txt" with content "Example content" as attachment type "deck_file" to the API endpoint "/boards//stacks//cards//attachments" + And the response value "id" is stored as "attachmentId" + When sending "GET" to the API endpoint "/boards//stacks//cards//attachments/" + Then the response should have a status code "200" + And the response body should be "Example content" + + Scenario: DELETE /boards/{boardId}/stacks/{stackId}/cards/{cardId}/attachments/{attachmentId} - Delete and restore an attachment + Given uploading the file "test.txt" with content "Example content" as attachment type "deck_file" to the API endpoint "/boards//stacks//cards//attachments" + And the response value "id" is stored as "attachmentId" + When sending "DELETE" to the API endpoint "/boards//stacks//cards//attachments/" + Then the response should have a status code "200" + And the response value "deletedAt" should not be "0" + # Attachments are deleted in two steps, so they stay listed with a deletion + # timestamp until they are either restored or removed for good + When sending "GET" to the API endpoint "/boards//stacks//cards//attachments" + Then the response list should contain 1 entry + And the response value "0.deletedAt" should not be "0" + When sending "PUT" to the API endpoint "/boards//stacks//cards//attachments//restore" + Then the response should have a status code "200" + And the response value "deletedAt" should be "0" + When sending "GET" to the API endpoint "/boards//stacks//cards//attachments" + Then the response list should contain 1 entry + And the response value "0.deletedAt" should be "0" + + Scenario: Attachments stored in the files app are only returned by API version 1.1 + Given using the Deck API version "1.1" + When uploading the file "in-files.txt" with content "Example content" as attachment type "file" to the API endpoint "/boards//stacks//cards//attachments" + Then the response should have a status code "200" + And the response value "type" should be "file" + When sending "GET" to the API endpoint "/boards//stacks//cards//attachments" + Then the response should have a status code "200" + And the response list should contain 1 entry + And the response list should contain an entry with "type" set to "file" + # API version 1.0 predates the files app integration and only knows deck_file attachments + Given using the Deck API version "1.0" + When sending "GET" to the API endpoint "/boards//stacks//cards//attachments" + Then the response should have a status code "200" + And the response list should contain 0 entries + + Scenario: Attachments of a board of another user are not accessible + Given uploading the file "test.txt" with content "Example content" as attachment type "deck_file" to the API endpoint "/boards//stacks//cards//attachments" + And the response value "id" is stored as "attachmentId" + And acting as user "user1" + When sending "GET" to the API endpoint "/boards//stacks//cards//attachments" + Then the response should have a status code "403" + When sending "GET" to the API endpoint "/boards//stacks//cards//attachments/" + Then the response should have a status code "403" + When sending "DELETE" to the API endpoint "/boards//stacks//cards//attachments/" + Then the response should have a status code "403" diff --git a/tests/integration/features/api/authentication.feature b/tests/integration/features/api/authentication.feature new file mode 100644 index 0000000000..5368ab8866 --- /dev/null +++ b/tests/integration/features/api/authentication.feature @@ -0,0 +1,47 @@ +Feature: REST API - Authentication + The API is meant to be consumed by external integrations rather than by a + browser, so it has to stay reachable with basic auth and without a CSRF + token, see https://deck.readthedocs.io/en/stable/API/#prerequisites + + Background: + Given user "admin" exists + And user "user0" exists + And user "user1" exists + And acting as user "user0" + And sending "POST" to the API endpoint "/boards" with body: + | title | Integration board | + | color | ff0000 | + And the response value "id" is stored as "boardId" + + Scenario: Unauthenticated requests are rejected + When sending "GET" to the API endpoint "/boards" without authentication + Then the response should have a status code "401" + When sending "GET" to the API endpoint "/boards/" without authentication + Then the response should have a status code "401" + + # The password is the one the shared behat provisioning steps assign to new users + Scenario: A client can use the API with basic auth and without a CSRF token + When sending "GET" to the API endpoint "/boards" as "user0" with password "123456" + Then the response should have a status code "200" + And the response list should contain an entry with "title" set to "Integration board" + When sending "GET" to the API endpoint "/boards/" as "user0" with password "123456" + Then the response should have a status code "200" + And the response value "title" should be "Integration board" + + Scenario: Basic auth is bound to the authenticated user + When sending "GET" to the API endpoint "/boards/" as "user1" with password "123456" + Then the response should have a status code "403" + And the response value "message" should be "Permission denied" + When sending "GET" to the API endpoint "/boards" as "user1" with password "123456" + Then the response should have a status code "200" + And the response list should not contain an entry with "title" set to "Integration board" + + Scenario: A client can write through the API with basic auth + When sending "POST" to the API endpoint "/boards//stacks" as "user0" with password "123456" and body: + | title | Stack from a client | + | order | 1 | + Then the response should have a status code "200" + And the response value "title" should be "Stack from a client" + When sending "GET" to the API endpoint "/boards//stacks" + Then the response should have a status code "200" + And the response list should contain an entry with "title" set to "Stack from a client" diff --git a/tests/integration/features/api/boards.feature b/tests/integration/features/api/boards.feature new file mode 100644 index 0000000000..46ea0e3abe --- /dev/null +++ b/tests/integration/features/api/boards.feature @@ -0,0 +1,208 @@ +Feature: REST API - Boards + Basic coverage of the board endpoints documented at + https://deck.readthedocs.io/en/stable/API/#boards + + Background: + Given user "admin" exists + And user "user0" exists + And user "user1" exists + And acting as user "user0" + + Scenario: POST /boards - Create a new board + When sending "POST" to the API endpoint "/boards" with body: + | title | Board title | + | color | ff0000 | + Then the response should have a status code "200" + And the response value "title" should be "Board title" + And the response value "color" should be "ff0000" + And the response value "archived" should be "false" + And the response value "deletedAt" should be "0" + And the response value "owner.uid" should be "user0" + And the response value "permissions.PERMISSION_READ" should be "true" + And the response value "permissions.PERMISSION_EDIT" should be "true" + And the response value "permissions.PERMISSION_MANAGE" should be "true" + And the response value "permissions.PERMISSION_SHARE" should be "true" + And the response value "acl" should be empty + And the response value "labels" should have 4 entries + + Scenario: POST /boards - Fail to create a board with invalid parameters + When sending "POST" to the API endpoint "/boards" with body: + | title | This is a very long name that exceeds the maximum length of a deck board which is limited to 100 characters | + | color | ff0000 | + Then the response should have a status code "400" + And the response value "status" should be "400" + When sending "POST" to the API endpoint "/boards" with body: + | title | Board title | + | color | | + Then the response should have a status code "400" + And the response value "status" should be "400" + + Scenario: GET /boards - Get a list of boards + Given sending "POST" to the API endpoint "/boards" with body: + | title | Listed board | + | color | 00ff00 | + And the response should have a status code "200" + When sending "GET" to the API endpoint "/boards" + Then the response should have a status code "200" + And the response should be a list of objects + And the response list should contain an entry with "title" set to "Listed board" + And the response should have the header "ETag" + + Scenario: GET /boards - Get a list of boards with details + Given sending "POST" to the API endpoint "/boards" with body: + | title | Detailed board | + | color | 00ff00 | + And the response value "id" is stored as "boardId" + When sending "GET" to the API endpoint "/boards?details=true" + Then the response should have a status code "200" + And the response list should contain an entry with "id" set to "" + + Scenario: GET /boards - Limit the board list with If-Modified-Since + Given sending "POST" to the API endpoint "/boards" with body: + | title | Unmodified board | + | color | 00ff00 | + When sending "GET" to the API endpoint "/boards" with the header "If-Modified-Since" set to "Sun, 03 Aug 2036 10:34:12 GMT" + Then the response should have a status code "200" + And the response list should contain 0 entries + + Scenario: GET /boards/{boardId} - Get board details + Given sending "POST" to the API endpoint "/boards" with body: + | title | Board details | + | color | 0000ff | + And the response value "id" is stored as "boardId" + When sending "GET" to the API endpoint "/boards/" + Then the response should have a status code "200" + And the response value "id" should be "" + And the response value "title" should be "Board details" + And the response value "color" should be "0000ff" + And the response should have the header "ETag" + + Scenario: GET /boards/{boardId} - Unchanged boards are answered with 304 Not Modified + Given sending "POST" to the API endpoint "/boards" with body: + | title | Etag board | + | color | 0000ff | + And the response value "id" is stored as "boardId" + And sending "GET" to the API endpoint "/boards/" + And the response header "ETag" is stored as "boardEtag" + When sending "GET" to the API endpoint "/boards/" with the header "If-None-Match" set to "" + Then the response should have a status code "304" + + Scenario: PUT /boards/{boardId} - Update board details + Given sending "POST" to the API endpoint "/boards" with body: + | title | Board to update | + | color | ff0000 | + And the response value "id" is stored as "boardId" + When sending "PUT" to the API endpoint "/boards/" with body: + | title | Updated board | + | color | 00ff00 | + | archived | true | + Then the response should have a status code "200" + And the response value "title" should be "Updated board" + And the response value "color" should be "00ff00" + And the response value "archived" should be "true" + When sending "GET" to the API endpoint "/boards/" + Then the response value "title" should be "Updated board" + And the response value "archived" should be "true" + + Scenario: DELETE /boards/{boardId} - Delete a board and restore it again + Given sending "POST" to the API endpoint "/boards" with body: + | title | Board to delete | + | color | ff0000 | + And the response value "id" is stored as "boardId" + When sending "DELETE" to the API endpoint "/boards/" + Then the response should have a status code "200" + # Boards are deleted in two steps, so they stay listed with a deletion timestamp + # until they are either restored or removed for good by the cleanup job + And the response value "deletedAt" should not be "0" + When sending "POST" to the API endpoint "/boards//undo_delete" + Then the response should have a status code "200" + And the response value "deletedAt" should be "0" + When sending "GET" to the API endpoint "/boards/" + Then the response should have a status code "200" + And the response value "title" should be "Board to delete" + And the response value "deletedAt" should be "0" + + Scenario: POST /boards/{boardId}/acl - Add, update and delete an acl rule + Given sending "POST" to the API endpoint "/boards" with body: + | title | Shared board | + | color | ff0000 | + And the response value "id" is stored as "boardId" + When sending "POST" to the API endpoint "/boards//acl" with body: + | type | 0 | + | participant | user1 | + | permissionEdit | true | + | permissionShare | false | + | permissionManage | false | + Then the response should have a status code "200" + And the response value "participant.uid" should be "user1" + And the response value "type" should be "0" + And the response value "boardId" should be "" + And the response value "permissionEdit" should be "true" + And the response value "permissionShare" should be "false" + And the response value "permissionManage" should be "false" + And the response value "owner" should be "false" + And the response value "id" is stored as "aclId" + + When sending "PUT" to the API endpoint "/boards//acl/" with body: + | permissionEdit | false | + | permissionShare | true | + | permissionManage | true | + Then the response should have a status code "200" + And the response value "permissionEdit" should be "false" + And the response value "permissionShare" should be "true" + And the response value "permissionManage" should be "true" + + Given acting as user "user1" + When sending "GET" to the API endpoint "/boards" + Then the response list should contain an entry with "title" set to "Shared board" + + Given acting as user "user0" + When sending "DELETE" to the API endpoint "/boards//acl/" + Then the response should have a status code "200" + Given acting as user "user1" + When sending "GET" to the API endpoint "/boards" + Then the response list should not contain an entry with "title" set to "Shared board" + + Scenario: POST /boards/{boardId}/clone - Clone a board + Given sending "POST" to the API endpoint "/boards" with body: + | title | Board to clone | + | color | ff0000 | + And the response value "id" is stored as "boardId" + And sending "POST" to the API endpoint "/boards//stacks" with body: + | title | ToDo | + | order | 1 | + And the response value "id" is stored as "stackId" + And sending "POST" to the API endpoint "/boards//stacks//cards" with body: + | title | Card to clone | + | type | plain | + | order | 999 | + When sending "POST" to the API endpoint "/boards//clone" with body: + | withCards | true | + Then the response should have a status code "200" + And the response value "title" should be "Board to clone (copy)" + And the response value "id" should not be "" + And the response value "id" is stored as "clonedBoardId" + When sending "GET" to the API endpoint "/boards//stacks" + Then the response should have a status code "200" + And the response list should contain 1 entry + And the response value "0.title" should be "ToDo" + And the response value "0.cards" should have 1 entry + And the response value "0.cards.0.title" should be "Card to clone" + + Scenario: Boards of other users are not accessible + Given sending "POST" to the API endpoint "/boards" with body: + | title | Private board | + | color | ff0000 | + And the response value "id" is stored as "boardId" + Given acting as user "user1" + When sending "GET" to the API endpoint "/boards/" + Then the response should have a status code "403" + And the response value "status" should be "403" + And the response value "message" should be "Permission denied" + When sending "PUT" to the API endpoint "/boards/" with body: + | title | Hijacked board | + | color | 00ff00 | + | archived | false | + Then the response should have a status code "403" + When sending "DELETE" to the API endpoint "/boards/" + Then the response should have a status code "403" diff --git a/tests/integration/features/api/cards.feature b/tests/integration/features/api/cards.feature new file mode 100644 index 0000000000..3b8c0e2326 --- /dev/null +++ b/tests/integration/features/api/cards.feature @@ -0,0 +1,222 @@ +Feature: REST API - Cards + Basic coverage of the card endpoints documented at + https://deck.readthedocs.io/en/stable/API/#cards + + Background: + Given user "admin" exists + And user "user0" exists + And user "user1" exists + And acting as user "user0" + And sending "POST" to the API endpoint "/boards" with body: + | title | Card board | + | color | ff0000 | + And the response value "id" is stored as "boardId" + And the response value "labels.0.id" is stored as "labelId" + And the response value "labels.0.title" is stored as "labelTitle" + And sending "POST" to the API endpoint "/boards//stacks" with body: + | title | ToDo | + | order | 1 | + And the response value "id" is stored as "stackId" + + Scenario: POST /boards/{boardId}/stacks/{stackId}/cards - Create a new card + When sending "POST" to the API endpoint "/boards//stacks//cards" with body: + | title | Test card | + | type | plain | + | order | 999 | + | description | A description | + Then the response should have a status code "200" + And the response value "title" should be "Test card" + And the response value "description" should be "A description" + And the response value "type" should be "plain" + And the response value "order" should be "999" + And the response value "stackId" should be "" + And the response value "owner.uid" should be "user0" + And the response value "archived" should be "false" + And the response value "deletedAt" should be "0" + And the response value "done" should be "null" + + Scenario: POST /boards/{boardId}/stacks/{stackId}/cards - Create a card with a duedate + When sending "POST" to the API endpoint "/boards//stacks//cards" with body: + | title | Card with duedate | + | type | plain | + | order | 999 | + | duedate | 2019-12-24T19:29:30+00:00 | + Then the response should have a status code "200" + And the response value "duedate" should be "2019-12-24T19:29:30+00:00" + + Scenario: POST /boards/{boardId}/stacks/{stackId}/cards - Fail to create a card with an invalid title + When sending "POST" to the API endpoint "/boards//stacks//cards" with body: + | title | This is a very long name that exceeds the maximum length of a deck card which is limited to 255 characters. This is a very long name that exceeds the maximum length of a deck card which is limited to 255 characters. This is a very long name that exceeds the maximum length of a deck card. | + | type | plain | + | order | 999 | + Then the response should have a status code "400" + And the response value "status" should be "400" + + Scenario: GET /boards/{boardId}/stacks/{stackId}/cards/{cardId} - Get card details + Given sending "POST" to the API endpoint "/boards//stacks//cards" with body: + | title | Card to read | + | type | plain | + | order | 999 | + And the response value "id" is stored as "cardId" + When sending "GET" to the API endpoint "/boards//stacks//cards/" + Then the response should have a status code "200" + And the response value "id" should be "" + And the response value "title" should be "Card to read" + And the response value "stackId" should be "" + And the response should have the header "ETag" + + Scenario: PUT /boards/{boardId}/stacks/{stackId}/cards/{cardId} - Update card details + Given sending "POST" to the API endpoint "/boards//stacks//cards" with body: + | title | Card to update | + | type | plain | + | order | 999 | + And the response value "id" is stored as "cardId" + When sending "PUT" to the API endpoint "/boards//stacks//cards/" with body: + | title | Updated card | + | description | An updated description | + | type | plain | + | owner | user0 | + | order | 5 | + | duedate | 2019-12-24T19:29:30+00:00 | + Then the response should have a status code "200" + And the response value "title" should be "Updated card" + And the response value "description" should be "An updated description" + And the response value "order" should be "5" + And the response value "duedate" should be "2019-12-24T19:29:30+00:00" + When sending "GET" to the API endpoint "/boards//stacks//cards/" + Then the response value "title" should be "Updated card" + And the response value "description" should be "An updated description" + + Scenario: PUT /boards/{boardId}/stacks/{stackId}/cards/{cardId} - Mark a card as done + Given sending "POST" to the API endpoint "/boards//stacks//cards" with body: + | title | Card to finish | + | type | plain | + | order | 999 | + And the response value "id" is stored as "cardId" + When sending "PUT" to the API endpoint "/boards//stacks//cards/" with body: + | title | Card to finish | + | type | plain | + | owner | user0 | + | done | 2026-01-02T10:00:00+00:00 | + Then the response should have a status code "200" + And the response value "done" should be "2026-01-02T10:00:00+00:00" + When sending "PUT" to the API endpoint "/boards//stacks//cards/" with body: + | title | Card to finish | + | type | plain | + | owner | user0 | + | done | null | + Then the response should have a status code "200" + And the response value "done" should be "null" + + Scenario: PUT /boards/{boardId}/stacks/{stackId}/cards/{cardId}/archive - Archive and unarchive a card + Given sending "POST" to the API endpoint "/boards//stacks//cards" with body: + | title | Card to archive | + | type | plain | + | order | 999 | + And the response value "id" is stored as "cardId" + When sending "PUT" to the API endpoint "/boards//stacks//cards//archive" + Then the response should have a status code "200" + And the response value "archived" should be "true" + When sending "GET" to the API endpoint "/boards//stacks//cards/" + Then the response value "archived" should be "true" + When sending "PUT" to the API endpoint "/boards//stacks//cards//unarchive" + Then the response should have a status code "200" + And the response value "archived" should be "false" + When sending "GET" to the API endpoint "/boards//stacks//cards/" + Then the response value "archived" should be "false" + + Scenario: PUT /boards/{boardId}/stacks/{stackId}/cards/{cardId}/assignLabel - Assign and remove a label + Given sending "POST" to the API endpoint "/boards//stacks//cards" with body: + | title | Card with a label | + | type | plain | + | order | 999 | + And the response value "id" is stored as "cardId" + When sending "PUT" to the API endpoint "/boards//stacks//cards//assignLabel" with body: + | labelId | | + Then the response should have a status code "200" + When sending "GET" to the API endpoint "/boards//stacks//cards/" + Then the response value "labels" should have 1 entry + And the response value "labels.0.id" should be "" + And the response value "labels.0.title" should be "" + When sending "PUT" to the API endpoint "/boards//stacks//cards//removeLabel" with body: + | labelId | | + Then the response should have a status code "200" + When sending "GET" to the API endpoint "/boards//stacks//cards/" + Then the response value "labels" should be empty + + Scenario: PUT /boards/{boardId}/stacks/{stackId}/cards/{cardId}/assignUser - Assign and unassign a user + Given sending "POST" to the API endpoint "/boards//acl" with body: + | type | 0 | + | participant | user1 | + | permissionEdit | true | + | permissionShare | false | + | permissionManage | false | + And sending "POST" to the API endpoint "/boards//stacks//cards" with body: + | title | Card with an assignee | + | type | plain | + | order | 999 | + And the response value "id" is stored as "cardId" + When sending "PUT" to the API endpoint "/boards//stacks//cards//assignUser" with body: + | userId | user1 | + Then the response should have a status code "200" + And the response value "participant.uid" should be "user1" + And the response value "cardId" should be "" + When sending "GET" to the API endpoint "/boards//stacks//cards/" + Then the response value "assignedUsers" should have 1 entry + And the response value "assignedUsers.0.participant.uid" should be "user1" + + # Assigning the same user twice is rejected with a bad request + When sending "PUT" to the API endpoint "/boards//stacks//cards//assignUser" with body: + | userId | user1 | + Then the response should have a status code "400" + + When sending "PUT" to the API endpoint "/boards//stacks//cards//unassignUser" with body: + | userId | user1 | + Then the response should have a status code "200" + When sending "GET" to the API endpoint "/boards//stacks//cards/" + Then the response value "assignedUsers" should be empty + + Scenario: PUT /boards/{boardId}/stacks/{stackId}/cards/{cardId}/reorder - Change the order of a card + Given sending "POST" to the API endpoint "/boards//stacks//cards" with body: + | title | First card | + | type | plain | + | order | 0 | + And sending "POST" to the API endpoint "/boards//stacks//cards" with body: + | title | Second card | + | type | plain | + | order | 1 | + And the response value "id" is stored as "cardId" + When sending "PUT" to the API endpoint "/boards//stacks//cards//reorder" with body: + | stackId | | + | order | 0 | + Then the response should have a status code "200" + When sending "GET" to the API endpoint "/boards//stacks//cards/" + Then the response should have a status code "200" + And the response value "order" should be "0" + When sending "GET" to the API endpoint "/boards//stacks/" + Then the response value "cards.0.title" should be "Second card" + And the response value "cards.1.title" should be "First card" + + Scenario: DELETE /boards/{boardId}/stacks/{stackId}/cards/{cardId} - Delete a card + Given sending "POST" to the API endpoint "/boards//stacks//cards" with body: + | title | Card to delete | + | type | plain | + | order | 999 | + And the response value "id" is stored as "cardId" + When sending "DELETE" to the API endpoint "/boards//stacks//cards/" + Then the response should have a status code "200" + When sending "GET" to the API endpoint "/boards//stacks//cards/" + Then the response should have a status code "403" + + Scenario: Cards of a board of another user are not accessible + Given sending "POST" to the API endpoint "/boards//stacks//cards" with body: + | title | Private card | + | type | plain | + | order | 999 | + And the response value "id" is stored as "cardId" + Given acting as user "user1" + When sending "GET" to the API endpoint "/boards//stacks//cards/" + Then the response should have a status code "403" + And the response value "message" should be "Permission denied" + When sending "DELETE" to the API endpoint "/boards//stacks//cards/" + Then the response should have a status code "403" diff --git a/tests/integration/features/api/config.feature b/tests/integration/features/api/config.feature new file mode 100644 index 0000000000..e8a39b8cce --- /dev/null +++ b/tests/integration/features/api/config.feature @@ -0,0 +1,62 @@ +Feature: OCS API - Config + Basic coverage of the config endpoints documented at + https://deck.readthedocs.io/en/stable/API/#config + + Background: + Given user "admin" exists + And user "user0" exists + And user "user1" exists + And acting as user "user0" + + Scenario: GET /api/v1.0/config - Fetch app configuration values + When sending "GET" to the OCS API endpoint "/config" + Then the response should have a status code "200" + And the response value "ocs.meta.statuscode" should be "200" + And the response should contain the key "ocs.data.calendar" + And the response should contain the key "ocs.data.cardDetailsInModal" + And the response should contain the key "ocs.data.cardIdBadge" + + Scenario: GET /api/v1.0/config - The group limit is only exposed to administrators + When sending "GET" to the OCS API endpoint "/config" + Then the response should have a status code "200" + And the response should not contain the key "ocs.data.groupLimit" + Given acting as user "admin" + When sending "GET" to the OCS API endpoint "/config" + Then the response should have a status code "200" + And the response should contain the key "ocs.data.groupLimit" + + Scenario: POST /api/v1.0/config/{key} - Set a user config value + When sending "POST" to the OCS API endpoint "/config/calendar" with body: + | value | false | + Then the response should have a status code "200" + And the response value "ocs.data" should be "false" + When sending "GET" to the OCS API endpoint "/config" + Then the response value "ocs.data.calendar" should be "false" + When sending "POST" to the OCS API endpoint "/config/calendar" with body: + | value | true | + Then the response should have a status code "200" + And the response value "ocs.data" should be "true" + When sending "GET" to the OCS API endpoint "/config" + Then the response value "ocs.data.calendar" should be "true" + + Scenario: POST /api/v1.0/config/{key} - Set a board config value + Given sending "POST" to the API endpoint "/boards" with body: + | title | Config board | + | color | ff0000 | + And the response value "id" is stored as "boardId" + When sending "POST" to the OCS API endpoint "/config/board::notify-due" with body: + | value | assigned | + Then the response should have a status code "200" + And the response value "ocs.data" should be "assigned" + When sending "GET" to the API endpoint "/boards/" + Then the response value "settings.notify-due" should be "assigned" + + Scenario: POST /api/v1.0/config/{key} - Board settings of other users cannot be changed + Given sending "POST" to the API endpoint "/boards" with body: + | title | Private config board | + | color | ff0000 | + And the response value "id" is stored as "boardId" + Given acting as user "user1" + When sending "POST" to the OCS API endpoint "/config/board::notify-due" with body: + | value | all | + Then the response should have a status code "403" diff --git a/tests/integration/features/api/labels.feature b/tests/integration/features/api/labels.feature new file mode 100644 index 0000000000..e573d7a858 --- /dev/null +++ b/tests/integration/features/api/labels.feature @@ -0,0 +1,82 @@ +Feature: REST API - Labels + Basic coverage of the label endpoints documented at + https://deck.readthedocs.io/en/stable/API/#labels + + Background: + Given user "admin" exists + And user "user0" exists + And user "user1" exists + And acting as user "user0" + And sending "POST" to the API endpoint "/boards" with body: + | title | Label board | + | color | ff0000 | + And the response value "id" is stored as "boardId" + + Scenario: GET /boards/{boardId}/labels/{labelId} - Get label details + Given the response value "labels.0.id" is stored as "labelId" + And the response value "labels.0.title" is stored as "labelTitle" + When sending "GET" to the API endpoint "/boards//labels/" + Then the response should have a status code "200" + And the response value "id" should be "" + And the response value "title" should be "" + And the response value "boardId" should be "" + And the response value "cardId" should be "null" + + Scenario: POST /boards/{boardId}/labels - Create a new label + When sending "POST" to the API endpoint "/boards//labels" with body: + | title | Blocked | + | color | 31CC7C | + Then the response should have a status code "200" + And the response value "title" should be "Blocked" + And the response value "color" should be "31CC7C" + And the response value "boardId" should be "" + And the response value "id" is stored as "labelId" + When sending "GET" to the API endpoint "/boards/" + Then the response value "labels" should have 5 entries + + Scenario: POST /boards/{boardId}/labels - Fail to create a label with an invalid title + When sending "POST" to the API endpoint "/boards//labels" with body: + | title | | + | color | 31CC7C | + Then the response should have a status code "400" + And the response value "status" should be "400" + + Scenario: PUT /boards/{boardId}/labels/{labelId} - Update label details + Given sending "POST" to the API endpoint "/boards//labels" with body: + | title | Label to update | + | color | 31CC7C | + And the response value "id" is stored as "labelId" + When sending "PUT" to the API endpoint "/boards//labels/" with body: + | title | Updated label | + | color | 317CCC | + Then the response should have a status code "200" + And the response value "title" should be "Updated label" + And the response value "color" should be "317CCC" + When sending "GET" to the API endpoint "/boards//labels/" + Then the response value "title" should be "Updated label" + And the response value "color" should be "317CCC" + + Scenario: DELETE /boards/{boardId}/labels/{labelId} - Delete a label + Given sending "POST" to the API endpoint "/boards//labels" with body: + | title | Label to delete | + | color | 31CC7C | + And the response value "id" is stored as "labelId" + When sending "DELETE" to the API endpoint "/boards//labels/" + Then the response should have a status code "200" + When sending "GET" to the API endpoint "/boards//labels/" + Then the response should have a status code "403" + When sending "GET" to the API endpoint "/boards/" + Then the response value "labels" should have 4 entries + + Scenario: Labels of a board of another user are not accessible + Given the response value "labels.0.id" is stored as "labelId" + And acting as user "user1" + When sending "GET" to the API endpoint "/boards//labels/" + Then the response should have a status code "403" + And the response value "message" should be "Permission denied" + When sending "POST" to the API endpoint "/boards//labels" with body: + | title | Sneaky label | + | color | 31CC7C | + Then the response should have a status code "403" + When sending "DELETE" to the API endpoint "/boards//labels/" + Then the response should have a status code "403" diff --git a/tests/integration/features/api/stacks.feature b/tests/integration/features/api/stacks.feature new file mode 100644 index 0000000000..bb2d7e090a --- /dev/null +++ b/tests/integration/features/api/stacks.feature @@ -0,0 +1,122 @@ +Feature: REST API - Stacks + Basic coverage of the stack endpoints documented at + https://deck.readthedocs.io/en/stable/API/#stacks + + Background: + Given user "admin" exists + And user "user0" exists + And user "user1" exists + And acting as user "user0" + And sending "POST" to the API endpoint "/boards" with body: + | title | Stack board | + | color | ff0000 | + And the response value "id" is stored as "boardId" + + Scenario: POST /boards/{boardId}/stacks - Create a new stack + When sending "POST" to the API endpoint "/boards//stacks" with body: + | title | ToDo | + | order | 1 | + Then the response should have a status code "200" + And the response value "title" should be "ToDo" + And the response value "order" should be "1" + And the response value "boardId" should be "" + And the response value "deletedAt" should be "0" + + Scenario: POST /boards/{boardId}/stacks - Fail to create a stack with an invalid title + When sending "POST" to the API endpoint "/boards//stacks" with body: + | title | This is a very long name that exceeds the maximum length of a deck stack which is limited to 100 characters | + | order | 1 | + Then the response should have a status code "400" + And the response value "status" should be "400" + + Scenario: GET /boards/{boardId}/stacks - Get the stacks of a board + Given sending "POST" to the API endpoint "/boards//stacks" with body: + | title | ToDo | + | order | 1 | + And sending "POST" to the API endpoint "/boards//stacks" with body: + | title | Doing | + | order | 2 | + When sending "GET" to the API endpoint "/boards//stacks" + Then the response should have a status code "200" + And the response should be a list of objects + And the response list should contain 2 entries + And the response list should contain an entry with "title" set to "ToDo" + And the response list should contain an entry with "title" set to "Doing" + + Scenario: GET /boards/{boardId}/stacks/{stackId} - Get stack details + Given sending "POST" to the API endpoint "/boards//stacks" with body: + | title | ToDo | + | order | 1 | + And the response value "id" is stored as "stackId" + And sending "POST" to the API endpoint "/boards//stacks//cards" with body: + | title | A card in the stack | + | type | plain | + | order | 999 | + When sending "GET" to the API endpoint "/boards//stacks/" + Then the response should have a status code "200" + And the response value "id" should be "" + And the response value "title" should be "ToDo" + And the response value "boardId" should be "" + And the response value "cards" should have 1 entry + And the response value "cards.0.title" should be "A card in the stack" + And the response should have the header "ETag" + + Scenario: PUT /boards/{boardId}/stacks/{stackId} - Update stack details + Given sending "POST" to the API endpoint "/boards//stacks" with body: + | title | ToDo | + | order | 1 | + And the response value "id" is stored as "stackId" + When sending "PUT" to the API endpoint "/boards//stacks/" with body: + | title | Done | + | order | 5 | + Then the response should have a status code "200" + And the response value "title" should be "Done" + And the response value "order" should be "5" + When sending "GET" to the API endpoint "/boards//stacks/" + Then the response value "title" should be "Done" + And the response value "order" should be "5" + + Scenario: DELETE /boards/{boardId}/stacks/{stackId} - Delete a stack + Given sending "POST" to the API endpoint "/boards//stacks" with body: + | title | Stack to delete | + | order | 1 | + And the response value "id" is stored as "stackId" + When sending "DELETE" to the API endpoint "/boards//stacks/" + Then the response should have a status code "200" + When sending "GET" to the API endpoint "/boards//stacks" + Then the response should have a status code "200" + And the response list should contain 0 entries + + Scenario: GET /boards/{boardId}/stacks/archived - Get the archived cards grouped by stack + Given sending "POST" to the API endpoint "/boards//stacks" with body: + | title | ToDo | + | order | 1 | + And the response value "id" is stored as "stackId" + And sending "POST" to the API endpoint "/boards//stacks//cards" with body: + | title | Active card | + | type | plain | + | order | 1 | + And sending "POST" to the API endpoint "/boards//stacks//cards" with body: + | title | Archived card | + | type | plain | + | order | 2 | + And the response value "id" is stored as "cardId" + And sending "PUT" to the API endpoint "/boards//stacks//cards//archive" + When sending "GET" to the API endpoint "/boards//stacks/archived" + Then the response should have a status code "200" + And the response should be a list of objects + And the response list should contain 1 entry + # The endpoint lists the stacks of the board, each one holding its archived cards only + And the response value "0.title" should be "ToDo" + And the response value "0.cards" should have 1 entry + And the response value "0.cards.0.title" should be "Archived card" + + Scenario: Stacks of a board of another user are not accessible + Given acting as user "user1" + When sending "GET" to the API endpoint "/boards//stacks" + Then the response should have a status code "403" + And the response value "message" should be "Permission denied" + When sending "POST" to the API endpoint "/boards//stacks" with body: + | title | Sneaky stack | + | order | 1 | + Then the response should have a status code "403" diff --git a/tests/integration/features/bootstrap/DeckApiContext.php b/tests/integration/features/bootstrap/DeckApiContext.php new file mode 100644 index 0000000000..473bd056db --- /dev/null +++ b/tests/integration/features/bootstrap/DeckApiContext.php @@ -0,0 +1,366 @@ + in a later endpoint or value. + */ +class DeckApiContext implements Context { + use RequestTrait; + + private ServerContext $serverContext; + + private string $apiVersion = '1.0'; + + /** @var array Values remembered from previous responses */ + private array $storedValues = []; + + /** @BeforeScenario */ + public function gatherContexts(BeforeScenarioScope $scope) { + $environment = $scope->getEnvironment(); + + $this->serverContext = $environment->getContext('ServerContext'); + $this->apiVersion = '1.0'; + $this->storedValues = []; + } + + /** + * @Given /^using the Deck API version "([^"]*)"$/ + */ + public function usingTheDeckApiVersion(string $version): void { + $this->apiVersion = $version; + } + + /** + * @When /^sending "([^"]*)" to the API endpoint "([^"]*)"$/ + * @When /^sending "([^"]*)" to the API endpoint "([^"]*)" with body:$/ + */ + public function sendingToTheApiEndpoint(string $method, string $endpoint, ?TableNode $body = null): void { + $options = $body === null + ? [] + : ['json' => $this->parseBody($body)]; + $this->sendRequest($method, $endpoint, $options); + } + + /** + * The OCS part of the API is served below /ocs/v2.php/apps/deck/api/v{apiVersion} + * and wraps its payload into an "ocs" envelope. + * + * @When /^sending "([^"]*)" to the OCS API endpoint "([^"]*)"$/ + * @When /^sending "([^"]*)" to the OCS API endpoint "([^"]*)" with body:$/ + */ + public function sendingToTheOcsApiEndpoint(string $method, string $endpoint, ?TableNode $body = null): void { + $this->requestContext->sendOCSRequest( + strtoupper($method), + '/apps/deck/api/v' . $this->apiVersion . '/' . ltrim($this->resolve($endpoint), '/'), + $body === null ? [] : $this->parseBody($body) + ); + } + + /** + * @When /^sending "([^"]*)" to the API endpoint "([^"]*)" with the header "([^"]*)" set to "([^"]*)"$/ + */ + public function sendingToTheApiEndpointWithHeader(string $method, string $endpoint, string $header, string $value): void { + $this->sendRequest($method, $endpoint, [], [$header => $this->resolve($value)]); + } + + /** + * Integrations are no browsers: they authenticate with basic auth and without + * a CSRF token, so the session of the current user is left out on purpose. + * + * @When /^sending "([^"]*)" to the API endpoint "([^"]*)" as "([^"]*)" with password "([^"]*)"$/ + * @When /^sending "([^"]*)" to the API endpoint "([^"]*)" as "([^"]*)" with password "([^"]*)" and body:$/ + */ + public function sendingToTheApiEndpointAsClient(string $method, string $endpoint, string $user, string $password, ?TableNode $body = null): void { + $options = [ + 'auth' => [$user, $password], + 'cookies' => new CookieJar(), + ]; + if ($body !== null) { + $options['json'] = $this->parseBody($body); + } + + $this->sendRequest($method, $endpoint, $options, [], false); + } + + /** + * @When /^sending "([^"]*)" to the API endpoint "([^"]*)" without authentication$/ + */ + public function sendingToTheApiEndpointWithoutAuthentication(string $method, string $endpoint): void { + $this->sendRequest($method, $endpoint, ['cookies' => new CookieJar()], [], false); + } + + /** + * @When /^uploading the file "([^"]*)" with content "([^"]*)" as attachment type "([^"]*)" to the API endpoint "([^"]*)"$/ + */ + public function uploadingTheFileToTheApiEndpoint(string $filename, string $content, string $type, string $endpoint): void { + $this->sendRequest('POST', $endpoint, [ + 'multipart' => [ + [ + 'name' => 'file', + 'contents' => $content, + 'filename' => $filename, + ], + [ + 'name' => 'type', + 'contents' => $type, + ], + ], + ]); + } + + /** + * @Then /^the response value "([^"]*)" is stored as "([^"]*)"$/ + */ + public function theResponseValueIsStoredAs(string $path, string $name): void { + $this->storedValues[$name] = $this->getResponseValue($path); + } + + /** + * @Then /^the response header "([^"]*)" is stored as "([^"]*)"$/ + */ + public function theResponseHeaderIsStoredAs(string $header, string $name): void { + $values = $this->getResponse()->getHeader($header); + Assert::assertNotEmpty($values, 'Expected a "' . $header . '" header to be present'); + $this->storedValues[$name] = $values[0]; + } + + /** + * @Then /^the response value "([^"]*)" should be "([^"]*)"$/ + */ + public function theResponseValueShouldBe(string $path, string $expected): void { + $actual = $this->getResponseValue($path); + $expectedValue = $this->castValue($this->resolve($expected)); + + Assert::assertTrue( + $this->valuesMatch($expectedValue, $actual), + 'Expected "' . $path . '" to be ' . json_encode($expectedValue) . ' but got ' . json_encode($actual) + ); + } + + /** + * @Then /^the response value "([^"]*)" should not be "([^"]*)"$/ + */ + public function theResponseValueShouldNotBe(string $path, string $expected): void { + $actual = $this->getResponseValue($path); + $expectedValue = $this->castValue($this->resolve($expected)); + + Assert::assertFalse( + $this->valuesMatch($expectedValue, $actual), + 'Expected "' . $path . '" to differ from ' . json_encode($expectedValue) + ); + } + + /** + * @Then /^the response should contain the key "([^"]*)"$/ + */ + public function theResponseShouldContainTheKey(string $path): void { + // Reading the value asserts that every segment of the path is present + $this->getResponseValue($path); + } + + /** + * @Then /^the response should not contain the key "([^"]*)"$/ + */ + public function theResponseShouldNotContainTheKey(string $path): void { + $segments = explode('.', $path); + $last = array_pop($segments); + $parentPath = implode('.', $segments); + $parent = $segments === [] + ? $this->requestContext->getResponseBodyFromJson() + : $this->getResponseValue($parentPath); + + Assert::assertIsArray($parent, 'Expected "' . ($parentPath ?: 'the response') . '" to be an array'); + Assert::assertArrayNotHasKey($last, $parent, 'Unexpected "' . $path . '" in the response'); + } + + /** + * @Then /^the response value "([^"]*)" should be empty$/ + */ + public function theResponseValueShouldBeEmpty(string $path): void { + Assert::assertEmpty($this->getResponseValue($path), 'Expected "' . $path . '" to be empty'); + } + + /** + * @Then /^the response value "([^"]*)" should have (\d+) entr(?:y|ies)$/ + */ + public function theResponseValueShouldHaveEntries(string $path, int $count): void { + $value = $this->getResponseValue($path); + Assert::assertIsArray($value, 'Expected "' . $path . '" to be an array'); + Assert::assertCount($count, $value); + } + + /** + * @Then /^the response list should contain (\d+) entr(?:y|ies)$/ + */ + public function theResponseListShouldContainEntries(int $count): void { + $body = $this->requestContext->getResponseBodyFromJson(); + Assert::assertIsArray($body, 'Expected the response to be a list'); + Assert::assertCount($count, $body); + } + + /** + * @Then /^the response list should contain an entry with "([^"]*)" set to "([^"]*)"$/ + */ + public function theResponseListShouldContainAnEntryWith(string $key, string $value): void { + Assert::assertNotEmpty( + $this->findEntriesWith($key, $value), + 'No entry with "' . $key . '" set to "' . $value . '" in ' . json_encode($this->requestContext->getResponseBodyFromJson()) + ); + } + + /** + * @Then /^the response list should not contain an entry with "([^"]*)" set to "([^"]*)"$/ + */ + public function theResponseListShouldNotContainAnEntryWith(string $key, string $value): void { + Assert::assertEmpty( + $this->findEntriesWith($key, $value), + 'Unexpected entry with "' . $key . '" set to "' . $value . '"' + ); + } + + /** + * @Then /^the response should have the header "([^"]*)"$/ + */ + public function theResponseShouldHaveTheHeader(string $header): void { + Assert::assertNotEmpty($this->getResponse()->getHeader($header), 'Expected a "' . $header . '" header to be present'); + } + + /** + * @Then /^the response body should be "([^"]*)"$/ + */ + public function theResponseBodyShouldBe(string $expected): void { + $this->getResponse()->getBody()->seek(0); + Assert::assertSame($expected, (string)$this->getResponse()->getBody()); + } + + private function sendRequest(string $method, string $endpoint, array $options = [], array $extraHeaders = [], bool $withSession = true): void { + $url = '/index.php/apps/deck/api/v' . $this->apiVersion . '/' . ltrim($this->resolve($endpoint), '/'); + + $headers = [ + // Required for every request according to the API documentation + 'OCS-APIRequest' => 'true', + 'Accept' => 'application/json', + ]; + if ($withSession) { + $headers['requesttoken'] = $this->serverContext->getReqestToken(); + } + $headers = array_merge($headers, $extraHeaders); + + if (!isset($options['json']) && !isset($options['multipart'])) { + $headers['Content-Type'] = 'application/json'; + } + + $this->requestContext->sendPlainRequest( + strtoupper($method), + $url, + array_merge(['headers' => $headers], $options) + ); + } + + private function parseBody(TableNode $body): array { + $parsed = []; + foreach ($body->getRowsHash() as $key => $value) { + $parsed[$key] = $this->castValue($this->resolve($value)); + } + return $parsed; + } + + /** + * Replace references with values remembered from earlier responses + */ + private function resolve(string $value): string { + return preg_replace_callback('/<([a-zA-Z0-9_]+)>/', function (array $matches) { + Assert::assertArrayHasKey($matches[1], $this->storedValues, 'No value has been stored as "' . $matches[1] . '"'); + $stored = $this->storedValues[$matches[1]]; + Assert::assertTrue( + is_string($stored) || is_int($stored) || is_float($stored), + 'The value stored as "' . $matches[1] . '" cannot be used in a placeholder: ' . json_encode($stored) + ); + return (string)$stored; + }, $value); + } + + /** + * Table and step values are strings, so they get converted to the type that + * the API expects. Wrapping a value in quotes keeps it a string. + * + * @return mixed + */ + private function castValue(string $value) { + if (preg_match('/^"(.*)"$/s', $value, $matches)) { + return $matches[1]; + } + if ($value === 'null') { + return null; + } + if ($value === 'true' || $value === 'false') { + return $value === 'true'; + } + if (preg_match('/^-?\d+$/', $value)) { + return (int)$value; + } + return $value; + } + + /** + * @return mixed + */ + private function getResponseValue(string $path) { + $value = $this->requestContext->getResponseBodyFromJson(); + foreach (explode('.', $path) as $segment) { + Assert::assertIsArray($value, 'Cannot read "' . $segment . '" of "' . $path . '" as it is no array'); + Assert::assertArrayHasKey($segment, $value, 'Missing "' . $segment . '" of "' . $path . '" in ' . json_encode($value)); + $value = $value[$segment]; + } + return $value; + } + + private function findEntriesWith(string $key, string $value): array { + $body = $this->requestContext->getResponseBodyFromJson(); + Assert::assertIsArray($body, 'Expected the response to be a list'); + $expected = $this->castValue($this->resolve($value)); + + return array_filter($body, function ($entry) use ($key, $expected) { + return is_array($entry) + && array_key_exists($key, $entry) + && $this->valuesMatch($expected, $entry[$key]); + }); + } + + /** + * Booleans and null have to match exactly, everything else is compared + * loosely so that values the API returns as a string - ids for example - + * still match the numeric value used in a feature file. + * + * @param mixed $expected + * @param mixed $actual + */ + private function valuesMatch($expected, $actual): bool { + if (is_bool($expected) || $expected === null) { + return $expected === $actual; + } + if (is_array($actual)) { + return false; + } + return $expected == $actual; + } +}