From 74e44aeb61f02ff4edf36bb4cdd075a367461975 Mon Sep 17 00:00:00 2001 From: Josh Puetz Date: Thu, 28 Jul 2022 14:30:32 -0500 Subject: [PATCH] Documentation for Negative Behavior APIs (#18187) Co-authored-by: Suzanne Aitchison --- .../concerns/api/articles_controller.rb | 2 +- .../concerns/api/users_controller.rb | 4 +- spec/requests/api/v1/articles_spec.rb | 4 +- spec/requests/api/v1/docs/articles_spec.rb | 80 ++++++- spec/requests/api/v1/docs/users_spec.rb | 139 ++++++++++++ spec/requests/api/v1/users_spec.rb | 4 +- swagger/v1/swagger.json | 197 +++++++++++++++--- 7 files changed, 392 insertions(+), 38 deletions(-) create mode 100644 spec/requests/api/v1/docs/users_spec.rb diff --git a/app/controllers/concerns/api/articles_controller.rb b/app/controllers/concerns/api/articles_controller.rb index 34320eaad..2de55ab3e 100644 --- a/app/controllers/concerns/api/articles_controller.rb +++ b/app/controllers/concerns/api/articles_controller.rb @@ -112,7 +112,7 @@ module Api payload = { action: "api_article_unpublish", article_id: @article.id } Audit::Logger.log(:admin_api, @user, payload) - render head: :ok + render status: :no_content else render json: { message: @article.errors.full_messages }, status: :unprocessable_entity end diff --git a/app/controllers/concerns/api/users_controller.rb b/app/controllers/concerns/api/users_controller.rb index bcd27d405..d4f81adad 100644 --- a/app/controllers/concerns/api/users_controller.rb +++ b/app/controllers/concerns/api/users_controller.rb @@ -36,7 +36,7 @@ module Api payload = { action: "api_user_suspend", target_user_id: target_user.id } Audit::Logger.log(:admin_api, @user, payload) - render head: :ok + render status: :no_content rescue StandardError render json: { success: false, @@ -67,7 +67,7 @@ module Api } Audit::Logger.log(:admin_api, @user, payload) - render head: :ok + render status: :no_content end end end diff --git a/spec/requests/api/v1/articles_spec.rb b/spec/requests/api/v1/articles_spec.rb index 4fbaa87bb..e1f25892f 100644 --- a/spec/requests/api/v1/articles_spec.rb +++ b/spec/requests/api/v1/articles_spec.rb @@ -1217,7 +1217,7 @@ RSpec.describe "Api::V1::Articles", type: :request do it "unpublishes an article" do expect(published_article.published).to be true put path, headers: v1_headers - expect(response).to have_http_status(:ok) + expect(response).to have_http_status(:no_content) expect(published_article.reload.published).to be false end end @@ -1228,7 +1228,7 @@ RSpec.describe "Api::V1::Articles", type: :request do it "unpublishes an article" do expect(published_article.published).to be true put path, headers: v1_headers - expect(response).to have_http_status(:ok) + expect(response).to have_http_status(:no_content) expect(published_article.reload.published).to be false end diff --git a/spec/requests/api/v1/docs/articles_spec.rb b/spec/requests/api/v1/docs/articles_spec.rb index 1f94278a3..51914e077 100644 --- a/spec/requests/api/v1/docs/articles_spec.rb +++ b/spec/requests/api/v1/docs/articles_spec.rb @@ -1,23 +1,28 @@ require "rails_helper" require "swagger_helper" +# rubocop:disable RSpec/EmptyExampleGroup +# rubocop:disable RSpec/VariableName + RSpec.describe "Api::V1::Docs::Articles", type: :request do let(:organization) { create(:organization) } # not used by every spec but lower times overall let(:tag) { create(:tag, :with_colors, name: "discuss") } - let(:article) { create(:article, featured: true, tags: "discuss") } + let(:article) { create(:article, featured: true, tags: "discuss", published: true) } + let(:unpublished_aricle) { create(:article, published: false) } + let(:api_secret) { create(:api_secret) } + let(:user) { api_secret.user } + let(:Accept) { "application/vnd.forem.api-v1+json" } before do stub_const("FlareTag::FLARE_TAG_IDS_HASH", { "discuss" => tag.id }) allow(FeatureFlag).to receive(:enabled?).with(:api_v1).and_return(true) end - # rubocop:disable RSpec/EmptyExampleGroup describe "GET /articles" do before do article.update_columns(organization_id: organization.id) end - # rubocop:disable RSpec/VariableName path "/api/articles" do get "Published articles" do tags "articles" @@ -88,7 +93,7 @@ belonging to the requested collection, ordered by ascending publication date.", example: 99 response "200", "A List of Articles" do - let(:"api-key") { "valid" } + let(:"api-key") { api_secret.secret } schema type: :array, items: { "$ref": "#/components/schemas/ArticleIndex" } add_examples @@ -97,13 +102,76 @@ belonging to the requested collection, ordered by ascending publication date.", end response "401", "unauthorized" do - let(:Accept) { "application/vnd.forem.api-v1+json" } let(:"api-key") { "invalid" } run_test! end end end - # rubocop:enable RSpec/VariableName end + + describe "PUT /articles/:id/unpublish" do + before do + user.add_role(:admin) + end + + path "/api/articles/{id}/unpublish" do + put "Unpublish an article" do + tags "articles" + description "This endpoint allows the client to unpublish an article. + +The user associated with the API key must have any 'admin' or 'moderator' role. + +The article will be unpublished and will no longer be visible to the public. It will remain +in the database and will set back to draft status on the author's posts dashboard. Any +notifications associated with the article will be deleted. Any comments on the article +will remain." + operationId "unpublishArticle" + produces "application/json" + parameter name: :id, in: :path, required: true, + description: "The ID of the article to unpublish.", + schema: { + type: :integer, + format: :int32, + minimum: 1 + }, + example: 1 + + response "204", "Article successfully unpublished" do + let(:"api-key") { api_secret.secret } + let(:id) { article.id } + add_examples + + run_test! + end + + response "401", "Article already unpublished" do + let(:"api-key") { api_secret.secret } + let(:id) { unpublished_aricle.id } + add_examples + + run_test! + end + + response "401", "Unauthorized" do + let(:regular_user) { create(:user) } + let(:low_security_api_secret) { create(:api_secret, user: regular_user) } + let(:"api-key") { low_security_api_secret.secret } + let(:id) { unpublished_aricle.id } + add_examples + + run_test! + end + + response "404", "Article Not Found" do + let(:"api-key") { api_secret.secret } + let(:id) { 0 } + add_examples + + run_test! + end + end + end + end + # rubocop:enable RSpec/VariableName # rubocop:enable RSpec/EmptyExampleGroup end diff --git a/spec/requests/api/v1/docs/users_spec.rb b/spec/requests/api/v1/docs/users_spec.rb new file mode 100644 index 000000000..2168ba7fe --- /dev/null +++ b/spec/requests/api/v1/docs/users_spec.rb @@ -0,0 +1,139 @@ +require "rails_helper" +require "swagger_helper" + +# rubocop:disable RSpec/EmptyExampleGroup +# rubocop:disable RSpec/VariableName + +RSpec.describe "Api::V1::Docs::Users", type: :request do + let(:Accept) { "application/vnd.forem.api-v1+json" } + let(:api_secret) { create(:api_secret) } + let(:user) { api_secret.user } + + let(:banned_user) { create(:user) } + let(:article) { create(:article, user: banned_user, published: true) } + let(:comment) { create(:comment, user: banned_user, article: article) } + + before do + allow(FeatureFlag).to receive(:enabled?).with(:api_v1).and_return(true) + user.add_role(:admin) + end + + describe "PUT /users/:id/unpublish" do + before do + user.add_role(:admin) + end + + path "/api/users/{id}/unpublish" do + put "Unpublish a User's Articles and Comments" do + tags "users" + description "This endpoint allows the client to unpublish all of the articles and +comments created by a user. + +The user associated with the API key must have any 'admin' or 'moderator' role. + +This specified user's articles and comments will be unpublished and will no longer be +visible to the public. They will remain in the database and will set back to draft status +on the specified user's dashboard. Any notifications associated with the specified user's +articles and comments will be deleted. + +Note this endpoint unpublishes articles and comments asychronously: it will return a 204 NO CONTENT +status code immediately, but the articles and comments will not be unpublished until the +request is completed on the server." + operationId "unpublishUser" + produces "application/json" + parameter name: :id, in: :path, required: true, + description: "The ID of the user to unpublish.", + schema: { + type: :integer, + format: :int32, + minimum: 1 + }, + example: 1 + + response "204", "User's articles and comments successfully unpublished" do + let(:"api-key") { api_secret.secret } + let(:id) { banned_user.id } + add_examples + + run_test! + end + + response "401", "Unauthorized" do + let(:regular_user) { create(:user) } + let(:low_security_api_secret) { create(:api_secret, user: regular_user) } + let(:"api-key") { low_security_api_secret.secret } + let(:id) { banned_user.id } + add_examples + + run_test! + end + + response "404", "Unknown User ID (still accepted for async processing)" do + let(:"api-key") { api_secret.secret } + let(:id) { 10_000 } + add_examples + + run_test! + end + end + end + end + + describe "PUT /users/:id/suspend" do + before do + user.add_role(:admin) + end + + path "/api/users/{id}/suspend" do + put "Suspend a User" do + tags "users" + description "This endpoint allows the client to suspend a user. + +The user associated with the API key must have any 'admin' or 'moderator' role. + +This specified user will be assigned the 'suspended' role. Suspending a user will stop the +user from posting new posts and comments. It doesn't delete any of the user's content, just +prevents them from creating new content while suspended. Users are not notified of their suspension +in the UI, so if you want them to know about this, you must notify them." + operationId "suspendUser" + produces "application/json" + parameter name: :id, in: :path, required: true, + description: "The ID of the user to suspend.", + schema: { + type: :integer, + format: :int32, + minimum: 1 + }, + example: 1 + + response "204", "User successfully unpublished" do + let(:"api-key") { api_secret.secret } + let(:id) { banned_user.id } + add_examples + + run_test! + end + + response "401", "Unauthorized" do + let(:regular_user) { create(:user) } + let(:low_security_api_secret) { create(:api_secret, user: regular_user) } + let(:"api-key") { low_security_api_secret.secret } + let(:id) { banned_user.id } + add_examples + + run_test! + end + + response "404", "Unknown User ID" do + let(:"api-key") { api_secret.secret } + let(:id) { 10_000 } + add_examples + + run_test! + end + end + end + end +end +# rubocop:enable RSpec/VariableName +# rubocop:enable RSpec/EmptyExampleGroup diff --git a/spec/requests/api/v1/users_spec.rb b/spec/requests/api/v1/users_spec.rb index d94a29553..00a4a7814 100644 --- a/spec/requests/api/v1/users_spec.rb +++ b/spec/requests/api/v1/users_spec.rb @@ -190,7 +190,7 @@ RSpec.describe "Api::V0::Users", type: :request do params: payload, headers: v1_headers - expect(response).to have_http_status(:ok) + expect(response).to have_http_status(:no_content) expect(target_user.reload.suspended?).to be true expect(Note.last.content).to eq(payload[:note]) end.to change(Note, :count).by(1) @@ -255,7 +255,7 @@ RSpec.describe "Api::V0::Users", type: :request do put api_user_unpublish_path(id: target_user.id), headers: v1_headers - expect(response).to have_http_status(:ok) + expect(response).to have_http_status(:no_content) sidekiq_perform_enqueued_jobs diff --git a/swagger/v1/swagger.json b/swagger/v1/swagger.json index 3d782f417..66e489498 100644 --- a/swagger/v1/swagger.json +++ b/swagger/v1/swagger.json @@ -102,44 +102,44 @@ "example": [ { "type_of": "article", - "id": 286, - "title": "Terrible Swift Sword1", - "description": "Cardigan taxidermy stumptown muggle magic. Tumblr lumbersexual yolo gluten-free taxidermy truffaut...", - "readable_publish_date": "Jul 6", - "slug": "terrible-swift-sword1-4gja", - "path": "/username1/terrible-swift-sword1-4gja", - "url": "http://localhost:3000/username1/terrible-swift-sword1-4gja", + "id": 2661, + "title": "To a God Unknown172", + "description": "Thundercats kitsch park bespoke scenester cray irony occupy. Crucifix salvia vegan. Disrupt offal 3...", + "readable_publish_date": "Jul 27", + "slug": "to-a-god-unknown172-3lh1", + "path": "/username469/to-a-god-unknown172-3lh1", + "url": "http://localhost:3000/username469/to-a-god-unknown172-3lh1", "comments_count": 0, "public_reactions_count": 0, "collection_id": null, - "published_timestamp": "2022-07-06T16:49:01Z", + "published_timestamp": "2022-07-27T20:31:54Z", "positive_reactions_count": 0, - "cover_image": "http://localhost:3000/assets/12-f9d673ae4ff98002f782ab82c641f2f26673be728e8f5409bea83f2d1de15323.png", - "social_image": "http://localhost:3000/assets/12-f9d673ae4ff98002f782ab82c641f2f26673be728e8f5409bea83f2d1de15323.png", - "canonical_url": "http://localhost:3000/username1/terrible-swift-sword1-4gja", - "created_at": "2022-07-06T16:49:07Z", + "cover_image": "http://localhost:3000/assets/19-ed58d3e8defcefc445020631589697a05e725243e834b5192aee4e6b91a3e927.png", + "social_image": "http://localhost:3000/assets/19-ed58d3e8defcefc445020631589697a05e725243e834b5192aee4e6b91a3e927.png", + "canonical_url": "http://localhost:3000/username469/to-a-god-unknown172-3lh1", + "created_at": "2022-07-27T20:31:54Z", "edited_at": null, "crossposted_at": null, - "published_at": "2022-07-06T16:49:01Z", - "last_comment_at": "2022-07-06T16:49:01Z", + "published_at": "2022-07-27T20:31:54Z", + "last_comment_at": "2022-07-27T20:31:54Z", "reading_time_minutes": 1, "tag_list": ["discuss"], "tags": "discuss", "user": { - "name": "Malorie \"Herman\" \\:/ Trantow", - "username": "username1", - "twitter_username": "twitter1", - "github_username": "github1", + "name": "Carey \"Maya\" \\:/ Corwin", + "username": "username469", + "twitter_username": "twitter469", + "github_username": "github469", "website_url": null, - "profile_image": "/uploads/user/profile_image/407/bcaaa39b-60d0-4dc2-97d2-9ec5c9e05b47.jpeg", - "profile_image_90": "/uploads/user/profile_image/407/bcaaa39b-60d0-4dc2-97d2-9ec5c9e05b47.jpeg" + "profile_image": "/uploads/user/profile_image/6332/5475485a-eb89-4e7f-b2d2-c548389462e8.jpeg", + "profile_image_90": "/uploads/user/profile_image/6332/5475485a-eb89-4e7f-b2d2-c548389462e8.jpeg" }, "organization": { - "name": "Stamm Group", - "username": "org1", - "slug": "org1", - "profile_image": "/uploads/organization/profile_image/109/fb157fe5-9a66-4baf-91cb-216f0a206e5e.png", - "profile_image_90": "/uploads/organization/profile_image/109/fb157fe5-9a66-4baf-91cb-216f0a206e5e.png" + "name": "Blanda, Barton and Tremblay", + "username": "org56", + "slug": "org56", + "profile_image": "/uploads/organization/profile_image/934/ba061551-7cbe-4adc-80d0-bc60abcdaa32.png", + "profile_image_90": "/uploads/organization/profile_image/934/ba061551-7cbe-4adc-80d0-bc60abcdaa32.png" }, "flare_tag": { "name": "discuss", @@ -162,6 +162,153 @@ } } } + }, + "/api/articles/{id}/unpublish": { + "put": { + "summary": "Unpublish an article", + "tags": ["articles"], + "description": "This endpoint allows the client to unpublish an article.\n\nThe user associated with the API key must have any 'admin' or 'moderator' role.\n\nThe article will be unpublished and will no longer be visible to the public. It will remain\nin the database and will set back to draft status on the author's posts dashboard. Any\nnotifications associated with the article will be deleted. Any comments on the article\nwill remain.", + "operationId": "unpublishArticle", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "description": "The ID of the article to unpublish.", + "schema": { + "type": "integer", + "format": "int32", + "minimum": 1 + }, + "example": 1 + } + ], + "responses": { + "204": { + "description": "Article successfully unpublished" + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "example": { + "error": "unauthorized", + "status": 401 + } + } + } + }, + "404": { + "description": "Article Not Found", + "content": { + "application/json": { + "example": { + "error": "not found", + "status": 404 + } + } + } + } + } + } + }, + "/api/users/{id}/unpublish": { + "put": { + "summary": "Unpublish a User's Articles and Comments", + "tags": ["users"], + "description": "This endpoint allows the client to unpublish all of the articles and\ncomments created by a user.\n\nThe user associated with the API key must have any 'admin' or 'moderator' role.\n\nThis specified user's articles and comments will be unpublished and will no longer be\nvisible to the public. They will remain in the database and will set back to draft status\non the specified user's dashboard. Any notifications associated with the specified user's\narticles and comments will be deleted.\n\nNote this endpoint unpublishes articles and comments asychronously: it will return a 204 NO CONTENT\nstatus code immediately, but the articles and comments will not be unpublished until the\nrequest is completed on the server.", + "operationId": "unpublishUser", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "description": "The ID of the user to unpublish.", + "schema": { + "type": "integer", + "format": "int32", + "minimum": 1 + }, + "example": 1 + } + ], + "responses": { + "204": { + "description": "User's articles and comments successfully unpublished" + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "example": { + "error": "unauthorized", + "status": 401 + } + } + } + }, + "404": { + "description": "Unknown User ID (still accepted for async processing)", + "content": { + "application/json": { + "example": { + "error": "not found", + "status": 404 + } + } + } + } + } + } + }, + "/api/users/{id}/suspend": { + "put": { + "summary": "Suspend a User", + "tags": ["users"], + "description": "This endpoint allows the client to suspend a user.\n\nThe user associated with the API key must have any 'admin' or 'moderator' role.\n\nThis specified user will be assigned the 'suspended' role. Suspending a user will stop the\nuser from posting new posts and comments. It doesn't delete any of the user's content, just\nprevents them from creating new content while suspended. Users are not notified of their suspension\nin the UI, so if you want them to know about this, you must notify them.", + "operationId": "suspendUser", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "description": "The ID of the user to suspend.", + "schema": { + "type": "integer", + "format": "int32", + "minimum": 1 + }, + "example": 1 + } + ], + "responses": { + "204": { + "description": "User successfully unpublished" + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "example": { + "error": "unauthorized", + "status": 401 + } + } + } + }, + "404": { + "description": "Unknown User ID", + "content": { + "application/json": { + "example": { + "error": "not found", + "status": 404 + } + } + } + } + } + } } }, "servers": [