diff --git a/app/controllers/api/v1/user_roles_controller.rb b/app/controllers/api/v1/user_roles_controller.rb new file mode 100644 index 000000000..4d6f866c1 --- /dev/null +++ b/app/controllers/api/v1/user_roles_controller.rb @@ -0,0 +1,99 @@ +module Api + module V1 + class UserRolesController < ApiController + before_action :authenticate_with_api_key! + + # 'suspended' is also known as 'suspend' for historical reasons + SUSPEND_MODE = %w[suspend suspended].freeze + ROLES = (SUSPEND_MODE + %w[limited]).freeze + + before_action :check_role + before_action :set_target_user + before_action :authorize_role_management + + rescue_from StandardError, with: :unprocessable_error + + def update + if suspend_mode? # suspend user requires more data, such as note + suspend_target_user + else + add_role_to_target_user + end + + render json: { success: "okay" }, status: :no_content + end + + def destroy + # This mechanism for removing roles is pretty specific to limited & suspended, + # where they revert to "Good standing" — we would need a different approach, + # possibly a whole different service object — to remove *any* roles + remove_role_from_target_user + + render json: { success: "okay" }, status: :no_content + end + + private + + def add_role_to_target_user + manager = Moderator::ManageActivityAndRoles.new(admin: @user, + user: @target_user, + user_params: {}) + manager.handle_user_status(params[:role].titleize, nil) + + payload = { action: "api_user_#{params[:role]}", target_user_id: @target_user.id } + Audit::Logger.log(:admin_api, @user, payload) + end + + def authorize_role_management + authorize(@target_user, :manage_user_roles?) + rescue Pundit::NotAuthorizedError + error_unauthorized + end + + def check_role + return if ROLES.include?(params[:role]) + + raise StandardError, "Unable to process #{params[:role]}" + end + + def remove_role_from_target_user + manager = Moderator::ManageActivityAndRoles.new(admin: @user, + user: @target_user, + user_params: {}) + manager.handle_user_status("Good standing", nil) + + payload = { action: "api_user_remove_#{params[:role]}", target_user_id: @target_user.id } + Audit::Logger.log(:admin_api, @user, payload) + end + + def set_target_user + @target_user = User.find(params[:id]) + rescue ActiveRecord::RecordNotFound + error_not_found + end + + def suspend_mode? + SUSPEND_MODE.include?(params[:role]) + end + + def suspend_target_user + suspend_params = { note_for_current_role: params[:note], user_status: "Suspended" } + Moderator::ManageActivityAndRoles.handle_user_roles(admin: @user, + user: @target_user, + user_params: suspend_params) + + payload = { action: "api_user_suspend", target_user_id: @target_user.id } + Audit::Logger.log(:admin_api, @user, payload) + end + + def unprocessable_error(exception) + message = @target_user&.errors_as_sentence || exception.message + + render json: { + success: false, + message: message + }, status: :unprocessable_entity + end + end + end +end diff --git a/app/controllers/concerns/api/users_controller.rb b/app/controllers/concerns/api/users_controller.rb index 78fd372d5..c3b16ca01 100644 --- a/app/controllers/concerns/api/users_controller.rb +++ b/app/controllers/concerns/api/users_controller.rb @@ -22,29 +22,6 @@ module Api render :show end - def suspend - authorize(@user, :toggle_suspension_status?) - - target_user = User.find(params[:id]) - suspend_params = { note_for_current_role: params[:note], user_status: "Suspended" } - - begin - Moderator::ManageActivityAndRoles.handle_user_roles(admin: @user, - user: target_user, - user_params: suspend_params) - - payload = { action: "api_user_suspend", target_user_id: target_user.id } - Audit::Logger.log(:admin_api, @user, payload) - - render status: :no_content - rescue StandardError - render json: { - success: false, - message: @user.errors_as_sentence - }, status: :unprocessable_entity - end - end - def unpublish authorize(@user, :unpublish_all_articles?) diff --git a/app/policies/user_policy.rb b/app/policies/user_policy.rb index e013fc3d9..6f3da075b 100644 --- a/app/policies/user_policy.rb +++ b/app/policies/user_policy.rb @@ -94,6 +94,7 @@ class UserPolicy < ApplicationPolicy end alias toggle_suspension_status? elevated_user? + alias manage_user_roles? elevated_user? alias unpublish_all_articles? elevated_user? def moderation_routes? diff --git a/app/services/moderator/manage_activity_and_roles.rb b/app/services/moderator/manage_activity_and_roles.rb index 0648b59a0..132e80dc3 100644 --- a/app/services/moderator/manage_activity_and_roles.rb +++ b/app/services/moderator/manage_activity_and_roles.rb @@ -135,6 +135,7 @@ module Moderator end def remove_negative_roles + user.remove_role(:limited) if user.limited? user.remove_role(:suspended) if user.suspended? user.remove_role(:warned) if user.warned? user.remove_role(:comment_suspended) if user.comment_suspended? diff --git a/config/routes.rb b/config/routes.rb index 29761617b..b43220d4d 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -44,7 +44,6 @@ Rails.application.routes.draw do namespace :api, defaults: { format: "json" } do scope module: :v1, constraints: ApiConstraints.new(version: 1, default: false) do # V1 only endpoints - put "/users/:id/suspend", to: "users#suspend", as: :user_suspend put "/articles/:id/unpublish", to: "articles#unpublish", as: :article_unpublish put "/users/:id/unpublish", to: "users#unpublish", as: :user_unpublish @@ -69,6 +68,16 @@ Rails.application.routes.draw do resources :organizations, only: %i[index create update destroy] + scope("/users/:id") do + constraints(role: /suspend|suspended|limited/) do + put "/:role", to: "user_roles#update", as: "user_add_role" + end + + constraints(role: /limited/) do + delete "/:role", to: "user_roles#destroy", as: "user_remove_role" + end + end + draw :api end diff --git a/spec/requests/api/v1/docs/user_roles_spec.rb b/spec/requests/api/v1/docs/user_roles_spec.rb new file mode 100644 index 000000000..f94457fba --- /dev/null +++ b/spec/requests/api/v1/docs/user_roles_spec.rb @@ -0,0 +1,188 @@ +require "rails_helper" +require "swagger_helper" + +# rubocop:disable RSpec/EmptyExampleGroup +# rubocop:disable RSpec/VariableName + +RSpec.describe "Api::V1::Docs::Users" 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 + user.add_role(:admin) + 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 + + describe "PUT /users/:id/limited" do + before do + user.add_role(:admin) + end + + path "/api/users/{id}/limited" do + put "Add limited role for a User" do + tags "users" + description "This endpoint allows the client to limit a user. + +The user associated with the API key must have any 'admin' or 'moderator' role. + +This specified user will be assigned the 'limited' role. Limiting a user will limit notifications +generated from new posts and comments. It doesn't delete any of the user's content or prevent them +from generating new content while limited. Users are not notified of their limits +in the UI, so if you want them to know about this, you must notify them." + operationId "limitUser" + produces "application/json" + parameter name: :id, in: :path, required: true, + description: "The ID of the user to limit.", + schema: { + type: :integer, + format: :int32, + minimum: 1 + }, + example: 1 + + response "204", "User successfully limited" 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 + + describe "DELETE /users/:id/limited" do + before do + user.add_role(:admin) + end + + path "/api/users/{id}/limited" do + delete "Remove limited for a User" do + tags "users" + description "This endpoint allows the client to remove limits for a user. + +The user associated with the API key must have any 'admin' or 'moderator' role. + +This specified user will be restored to 'general' status. Users are not notified +of limits in the UI, so if you want them to know about this, you must +notify them." + operationId "unLimitUser" + produces "application/json" + parameter name: :id, in: :path, required: true, + description: "The ID of the user to un-limit.", + schema: { + type: :integer, + format: :int32, + minimum: 1 + }, + example: 1 + + response "204", "User successfully un-limited" 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/docs/users_spec.rb b/spec/requests/api/v1/docs/users_spec.rb index 830669aff..e4836b29b 100644 --- a/spec/requests/api/v1/docs/users_spec.rb +++ b/spec/requests/api/v1/docs/users_spec.rb @@ -127,62 +127,6 @@ request is completed on the server." 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 - describe "POST /api/admin/users" do before do user.add_role(:super_admin) diff --git a/spec/requests/api/v1/user_roles_spec.rb b/spec/requests/api/v1/user_roles_spec.rb new file mode 100644 index 000000000..eb4a38dd0 --- /dev/null +++ b/spec/requests/api/v1/user_roles_spec.rb @@ -0,0 +1,181 @@ +require "rails_helper" + +RSpec.describe "Api::V1::UserRoles" do + let(:api_secret) { create(:api_secret) } + let(:headers) { { "Accept" => "application/vnd.forem.api-v1+json" } } + let(:auth_headers) { headers.merge({ "api-key" => api_secret.secret }) } + let(:listener) { :admin_api } + + describe "PUT /api/users/:id/suspend", :aggregate_failures do + let(:target_user) { create(:user) } + let(:payload) { { note: "Violated CoC despite multiple warnings" } } + + before { Audit::Subscribe.listen listener } + after { Audit::Subscribe.forget listener } + + context "when unauthenticated" do + it "returns unauthorized" do + put api_user_add_role_path(id: target_user.id, role: "suspended"), + params: payload, + headers: headers + + expect(response).to have_http_status(:unauthorized) + end + end + + context "when unauthorized" do + it "returns unauthorized if api key is invalid" do + put api_user_add_role_path(id: target_user.id, role: "suspended"), + params: payload, + headers: headers.merge({ "api-key" => "invalid api key" }) + + expect(response).to have_http_status(:unauthorized) + end + + it "returns unauthorized if api key belongs to non-admin user" do + put api_user_add_role_path(id: target_user.id, role: "suspended"), + params: payload, + headers: headers + + expect(response).to have_http_status(:unauthorized) + end + end + + context "when request is authenticated" do + before { api_secret.user.add_role(:super_admin) } + + it "is successful in suspending a user", :aggregate_failures do + expect do + put api_user_add_role_path(id: target_user.id, role: "suspended"), + params: payload, + headers: auth_headers + + 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) + end + + it "creates an audit log of the action taken" do + put api_user_add_role_path(id: target_user.id, role: "suspended"), + params: payload, + headers: auth_headers + + log = AuditLog.last + expect(log.category).to eq(AuditLog::ADMIN_API_AUDIT_LOG_CATEGORY) + expect(log.data["action"]).to eq("api_user_suspend") + expect(log.data["target_user_id"]).to eq(target_user.id) + expect(log.user_id).to eq(api_secret.user.id) + end + end + end + + describe "PUT /api/users/:id/limited", :aggregate_failures do + let(:target_user) { create(:user) } + + before { Audit::Subscribe.listen listener } + after { Audit::Subscribe.forget listener } + + context "when unauthenticated" do + it "returns unauthorized" do + put api_user_add_role_path(target_user, "limited"), headers: headers + expect(response).to have_http_status(:unauthorized) + end + end + + context "when unauthorized" do + it "returns unauthorized if api key is invalid" do + put api_user_add_role_path(target_user, "limited"), + headers: headers.merge({ "api-key" => "invalid api key" }) + + expect(response).to have_http_status(:unauthorized) + end + + # Setup via let(:api_secret) creates a user with no admin privileges + it "returns unauthorized if api key belongs to non-admin user" do + put api_user_add_role_path(target_user, "limited"), headers: auth_headers + + expect(response).to have_http_status(:unauthorized) + end + end + + context "when request is authenticated" do + before { api_secret.user.add_role(:super_admin) } + + it "is successful in limiting a user", :aggregate_failures do + expect do + put api_user_add_role_path(target_user, "limited"), headers: auth_headers + + expect(response).to have_http_status(:no_content) + expect(target_user.reload.limited?).to be true + expect(Note.last.content).to match(/username\d+ updated username\d+/) + end.to change(Note, :count).by(1) + end + + it "creates an audit log of the action taken" do + put api_user_add_role_path(target_user, "limited"), headers: auth_headers + + log = AuditLog.last + expect(log.category).to eq(AuditLog::ADMIN_API_AUDIT_LOG_CATEGORY) + expect(log.data["action"]).to eq("api_user_limited") + expect(log.data["target_user_id"]).to eq(target_user.id) + expect(log.user_id).to eq(api_secret.user.id) + end + end + end + + describe "DELETE /api/users/:id/limited", :aggregate_failures do + let(:target_user) { create(:user, :limited) } + + before { Audit::Subscribe.listen listener } + after { Audit::Subscribe.forget listener } + + context "when unauthenticated" do + it "returns unauthorized" do + delete api_user_remove_role_path(target_user, "limited"), headers: headers + expect(response).to have_http_status(:unauthorized) + end + end + + context "when unauthorized" do + it "returns unauthorized if api key is invalid" do + delete api_user_remove_role_path(target_user, "limited"), + headers: headers.merge({ "api-key" => "invalid api key" }) + + expect(response).to have_http_status(:unauthorized) + end + + # Setup via let(:api_secret) creates a user with no admin privileges + it "returns unauthorized if api key belongs to non-admin user" do + delete api_user_remove_role_path(target_user, "limited"), headers: auth_headers + + expect(response).to have_http_status(:unauthorized) + end + end + + context "when request is authenticated" do + before { api_secret.user.add_role(:super_admin) } + + it "is successful in limiting a user", :aggregate_failures do + expect do + delete api_user_remove_role_path(target_user, "limited"), headers: auth_headers + + expect(response).to have_http_status(:no_content) + expect(target_user.reload.limited?).to be false + expect(target_user.roles).to eq([]) + expect(Note.last.content).to match(/username\d+ updated username\d+/) + end.to change(Note, :count).by(1) + end + + it "creates an audit log of the action taken" do + delete api_user_remove_role_path(target_user, "limited"), headers: auth_headers + + log = AuditLog.last + expect(log.category).to eq(AuditLog::ADMIN_API_AUDIT_LOG_CATEGORY) + expect(log.data["action"]).to eq("api_user_remove_limited") + expect(log.data["target_user_id"]).to eq(target_user.id) + expect(log.user_id).to eq(api_secret.user.id) + end + end + end +end diff --git a/spec/requests/api/v1/users_spec.rb b/spec/requests/api/v1/users_spec.rb index 96294de90..f5d1d52cd 100644 --- a/spec/requests/api/v1/users_spec.rb +++ b/spec/requests/api/v1/users_spec.rb @@ -123,71 +123,6 @@ RSpec.describe "Api::V1::Users" do end end - describe "PUT /api/users/:id/suspend", :aggregate_failures do - let(:target_user) { create(:user) } - let(:payload) { { note: "Violated CoC despite multiple warnings" } } - - before { Audit::Subscribe.listen listener } - - after { Audit::Subscribe.forget listener } - - context "when unauthenticated" do - it "returns unauthorized" do - put api_user_suspend_path(id: target_user.id), - params: payload, - headers: headers - - expect(response).to have_http_status(:unauthorized) - end - end - - context "when unauthorized" do - it "returns unauthorized if api key is invalid" do - put api_user_suspend_path(id: target_user.id), - params: payload, - headers: headers.merge({ "api-key" => "invalid api key" }) - - expect(response).to have_http_status(:unauthorized) - end - - it "returns unauthorized if api key belongs to non-admin user" do - put api_user_suspend_path(id: target_user.id), - params: payload, - headers: headers - - expect(response).to have_http_status(:unauthorized) - end - end - - context "when request is authenticated" do - before { api_secret.user.add_role(:super_admin) } - - it "is successful in suspending a user", :aggregate_failures do - expect do - put api_user_suspend_path(id: target_user.id), - params: payload, - headers: auth_headers - - 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) - end - - it "creates an audit log of the action taken" do - put api_user_suspend_path(id: target_user.id), - params: payload, - headers: auth_headers - - log = AuditLog.last - expect(log.category).to eq(AuditLog::ADMIN_API_AUDIT_LOG_CATEGORY) - expect(log.data["action"]).to eq("api_user_suspend") - expect(log.data["target_user_id"]).to eq(target_user.id) - expect(log.user_id).to eq(api_secret.user.id) - end - end - end - describe "PUT /api/users/:id/unpublish", :aggregate_failures do let(:target_user) { create(:user) } let!(:target_articles) { create_list(:article, 3, user: target_user, published: true) } @@ -227,8 +162,8 @@ RSpec.describe "Api::V1::Users" do it "is successful in unpublishing a user's comments and articles", :aggregate_failures do # User's articles are published and comments exist - expect(target_articles.map(&:published?)).to match_array([true, true, true]) - expect(target_comments.map(&:deleted)).to match_array([false, false, false]) + expect(target_articles.map(&:published?)).to contain_exactly(true, true, true) + expect(target_comments.map(&:deleted)).to contain_exactly(false, false, false) sidekiq_perform_enqueued_jobs(only: Moderator::UnpublishAllArticlesWorker) do put api_user_unpublish_path(id: target_user.id), headers: auth_headers @@ -238,8 +173,8 @@ RSpec.describe "Api::V1::Users" do # Ensure article's aren't published and comments deleted # (with boolean attribute so they can be reverted if needed) - expect(target_articles.map(&:reload).map(&:published?)).to match_array([false, false, false]) - expect(target_comments.map(&:reload).map(&:deleted)).to match_array([true, true, true]) + expect(target_articles.map(&:reload).map(&:published?)).to contain_exactly(false, false, false) + expect(target_comments.map(&:reload).map(&:deleted)).to contain_exactly(true, true, true) end it "creates an audit log of the action taken" do diff --git a/spec/services/moderator/manage_activity_and_roles_spec.rb b/spec/services/moderator/manage_activity_and_roles_spec.rb index 7e45fd075..e22c3c2ce 100644 --- a/spec/services/moderator/manage_activity_and_roles_spec.rb +++ b/spec/services/moderator/manage_activity_and_roles_spec.rb @@ -4,95 +4,199 @@ RSpec.describe Moderator::ManageActivityAndRoles, type: :service do let(:user) { create(:user) } let(:admin) { create(:user, :super_admin) } - it "updates user status" do - user.add_role(:suspended) - user.reload + def manage_roles_for(user, user_status:, note: "Test note", acting_as: admin) described_class.handle_user_roles( - admin: admin, + admin: acting_as, user: user, - user_params: { note_for_current_role: "warning user", user_status: "Warned" }, + user_params: { + note_for_current_role: note, + user_status: user_status + }, ) - expect(user.warned?).to be true - expect(user.suspended?).to be false + end + + shared_examples_for "elevated role" do |status| + context "when user is in limited role" do + before { user.add_role(:limited) } + + it "adding #{status} also removes the limited role" do + expect(user.roles.pluck(:name)).to include("limited") # confirm assumptions + manage_roles_for user, user_status: status + expect(user.roles.pluck(:name)).not_to include("limited") # confirm assumptions + end + end + + context "when user is in suspended role" do + before { user.add_role(:suspended) } + + it "adding #{status} also removes the suspended role" do + expect(user.roles.pluck(:name)).to include("suspended") # confirm assumptions + manage_roles_for user, user_status: status + expect(user.roles.pluck(:name)).not_to include("suspended") # confirm assumptions + end + end + + context "when user is in warned role" do + before { user.add_role(:warned) } + + it "adding #{status} also removes the warned role" do + expect(user.roles.pluck(:name)).to include("warned") # confirm assumptions + manage_roles_for user, user_status: status + expect(user.roles.pluck(:name)).not_to include("warned") # confirm assumptions + end + end + + context "when user is in comment_suspended role" do + before { user.add_role(:comment_suspended) } + + it "adding #{status} also removes the comment_suspended role" do + expect(user.roles.pluck(:name)).to include("comment_suspended") # confirm assumptions + manage_roles_for user, user_status: status + expect(user.roles.pluck(:name)).not_to include("comment_suspended") # confirm assumptions + end + end + end + + shared_examples_for "negative role" do |status| + context "when user is in trusted role" do + before { user.add_role(:trusted) } + + it "adding #{status} removes the trusted role" do + expect(user.roles.pluck(:name)).to include("trusted") # confirm assumptions + manage_roles_for user, user_status: status + expect(user.roles.pluck(:name)).not_to include("trusted") # confirm assumptions + end + end + + context "when user is in tag_moderator role" do + before { user.add_role(:tag_moderator) } + + it "adding #{status} removes the tag_moderator role" do + expect(user.roles.pluck(:name)).to include("tag_moderator") # confirm assumptions + manage_roles_for user, user_status: status + expect(user.roles.pluck(:name)).not_to include("tag_moderator") # confirm assumptions + end + end + + context "when user is in admin role" do + before { user.add_role(:admin) } + + it "adding #{status} ignores the admin role" do + expect(user.roles.pluck(:name)).to include("admin") # confirm assumptions + manage_roles_for user, user_status: status + expect(user.roles.pluck(:name)).to include("admin") # confirm assumptions + end + end + + context "when user is in super_moderator role" do + before { user.add_role(:super_moderator) } + + it "adding #{status} ignores the super_moderator role" do + expect(user.roles.pluck(:name)).to include("super_moderator") # confirm assumptions + manage_roles_for user, user_status: status + expect(user.roles.pluck(:name)).to include("super_moderator") # confirm assumptions + end + end + + context "when user is in tech_admin role" do + before { user.add_role(:tech_admin) } + + it "adding #{status} ignores the tech_admin role" do + expect(user.roles.pluck(:name)).to include("tech_admin") # confirm assumptions + manage_roles_for user, user_status: status + expect(user.roles.pluck(:name)).to include("tech_admin") # confirm assumptions + end + end + + context "when user is in limited role" do + before { user.add_role(:limited) } + + it "adding #{status} ignores the limited role" do + expect(user.roles.pluck(:name)).to include("limited") # confirm assumptions + manage_roles_for user, user_status: status + expect(user.roles.pluck(:name)).to include("limited") # confirm assumptions + end + end + + context "when user is in warned role" do + before { user.add_role(:warned) } + + it "adding #{status} ignores the warned role" do + expect(user.roles.pluck(:name)).to include("warned") # confirm assumptions + manage_roles_for user, user_status: status + expect(user.roles.pluck(:name)).to include("warned") # confirm assumptions + end + end + end + + it_behaves_like "elevated role", "Admin" + it_behaves_like "elevated role", "Super Moderator" + it_behaves_like "elevated role", "Resource Admin: Tag" + it_behaves_like "elevated role", "Super Admin" + it_behaves_like "elevated role", "Trusted" + it_behaves_like "elevated role", "Good standing" + it_behaves_like "elevated role", "Tech Admin" + + it_behaves_like "negative role", "Suspended" + it_behaves_like "negative role", "Limited" + it_behaves_like "negative role", "Warned" + + context "when user is in suspended role" do + before { user.add_role(:suspended) } + + it "adding warned removes the suspended role" do + expect(user.roles.pluck(:name)).to include("suspended") # confirm assumptions + manage_roles_for user, user_status: "Warned" + expect(user.roles.pluck(:name)).not_to include("suspended") # confirm assumptions + end end it "updates user status to limited" do - user.add_role(:limited) - user.reload - described_class.handle_user_roles( - admin: admin, - user: user, - user_params: { note_for_current_role: "limited user", user_status: "Limited" }, - ) - expect(user.limited?).to be true + expect(user).not_to be_limited + manage_roles_for(user, user_status: "Limited") + expect(user).to be_limited + manage_roles_for(user, user_status: "Good standing") + expect(user).not_to be_limited end it "updates user to super admin" do - described_class.handle_user_roles( - admin: admin, - user: user, - user_params: { note_for_current_role: "Upgrading to super admin", user_status: "Super Admin" }, - ) - expect(user.super_admin?).to be true - end - - it "assigns trusted role to user that's updated to super admin" do - described_class.handle_user_roles( - admin: admin, - user: user, - user_params: { note_for_current_role: "Upgrading to super admin", user_status: "Super Admin" }, - ) - expect(user.super_admin?).to be true + expect(user).not_to be_super_admin + expect(user.has_trusted_role?).to be false + manage_roles_for(user, user_status: "Super Admin") + expect(user).to be_super_admin expect(user.has_trusted_role?).to be true end it "updates user to admin" do - described_class.handle_user_roles( - admin: admin, - user: user, - user_params: { note_for_current_role: "Upgrading to admin", user_status: "Admin" }, - ) - expect(user.admin?).to be true - end - - it "assigns trusted role to user that's updated to admin" do - described_class.handle_user_roles( - admin: admin, - user: user, - user_params: { note_for_current_role: "Upgrading to admin", user_status: "Admin" }, - ) - expect(user.admin?).to be true + expect(user).not_to be_admin + expect(user.has_trusted_role?).to be false + manage_roles_for(user, user_status: "Admin") + expect(user).to be_admin expect(user.has_trusted_role?).to be true end it "updates user to tech admin" do - described_class.handle_user_roles( - admin: admin, - user: user, - user_params: { note_for_current_role: "Upgrading to tech admin", user_status: "Tech Admin" }, - ) - expect(user.tech_admin?).to be true + expect(user).not_to be_tech_admin + expect(user.single_resource_admin_for?(DataUpdateScript)).to be false + manage_roles_for(user, user_status: "Tech Admin") + expect(user).to be_tech_admin expect(user.single_resource_admin_for?(DataUpdateScript)).to be true end it "updates user to single resource admin" do - described_class.handle_user_roles( - admin: admin, - user: user, - user_params: { note_for_current_role: "Upgrading to super admin", user_status: "Resource Admin: Article" }, - ) + expect(user.single_resource_admin_for?(Article)).to be false + manage_roles_for(user, user_status: "Resource Admin: Article") expect(user.single_resource_admin_for?(Article)).to be true end - it "updates negative role to positive role" do + it "user in 'Good standing' has no negative or elevated roles" do user.add_role(:comment_suspended) - described_class.handle_user_roles( - admin: admin, - user: user, - user_params: { note_for_current_role: "user in good standing", user_status: "Good standing" }, - ) + user.add_role(:warned) + user.add_role(:trusted) + manage_roles_for(user, user_status: "Good standing") expect(user.suspended?).to be false expect(user.roles.count).to eq(0) + expect(user.has_trusted_role?).to be false end describe "Rack::Attack cache invalidation optimization" do @@ -135,43 +239,27 @@ RSpec.describe Moderator::ManageActivityAndRoles, type: :service do admin.add_role(:admin) end - it "updates user to super admin" do + it "raises exception when trying to upgrade user to super admin" do expect do - described_class.handle_user_roles( - admin: admin, - user: user, - user_params: { note_for_current_role: "Upgrading to super admin", user_status: "Super Admin" }, - ) + manage_roles_for(user, user_status: "Super Admin") end.to raise_error(StandardError) end - it "updates user to admin" do + it "raises exception when trying to upgrade user to admin" do expect do - described_class.handle_user_roles( - admin: admin, - user: user, - user_params: { note_for_current_role: "Upgrading to super admin", user_status: "Admin" }, - ) + manage_roles_for(user, user_status: "Admin") end.to raise_error(StandardError) end - it "updates user to single resource admin" do + it "raises exception when trying to upgrade user to single resource admin" do expect do - described_class.handle_user_roles( - admin: admin, - user: user, - user_params: { note_for_current_role: "Upgrading to super admin", user_status: "Resource Admin: Article" }, - ) + manage_roles_for(user, user_status: "Resource Admin: Article") end.to raise_error(StandardError) end - it "updates user to super moderator" do + it "raises exception when trying to upgrade user to super moderator" do expect do - described_class.handle_user_roles( - admin: admin, - user: user, - user_params: { note_for_current_role: "Upgrading to super_moderator", user_status: "Super Moderator" }, - ) + manage_roles_for(user, user_status: "Super Moderator") end.to raise_error(StandardError) end end diff --git a/swagger/v1/api_v1.json b/swagger/v1/api_v1.json index 1f7a315e7..45ff5c02e 100644 --- a/swagger/v1/api_v1.json +++ b/swagger/v1/api_v1.json @@ -20,40 +20,40 @@ "application/json": { "example": { "type_of": "article", - "id": 2, + "id": 2046, "title": "New article", "description": "New post example", - "readable_publish_date": "Aug 31", - "slug": "new-article-31g9", - "path": "/username2/new-article-31g9", - "url": "http://forem.test/username2/new-article-31g9", + "readable_publish_date": "Sep 19", + "slug": "new-article-kif", + "path": "/username539/new-article-kif", + "url": "http://forem.test/username539/new-article-kif", "comments_count": 0, "public_reactions_count": 0, - "collection_id": 1, - "published_timestamp": "2023-08-31T16:14:06Z", + "collection_id": 43, + "published_timestamp": "2023-09-19T09:14:37Z", "positive_reactions_count": 0, "cover_image": "https://thepracticaldev.s3.amazonaws.com/i/5wfo25724gzgk5e5j50g.jpg", "social_image": "https://thepracticaldev.s3.amazonaws.com/i/5wfo25724gzgk5e5j50g.jpg", "canonical_url": "https://dev.to/fdocr/headless-chrome-dual-mode-tests-for-ruby-on-rails-4p6g", - "created_at": "2023-08-31T16:14:06Z", + "created_at": "2023-09-19T09:14:37Z", "edited_at": null, "crossposted_at": null, - "published_at": "2023-08-31T16:14:06Z", - "last_comment_at": "2023-08-31T16:14:06Z", + "published_at": "2023-09-19T09:14:37Z", + "last_comment_at": "2023-09-19T09:14:37Z", "reading_time_minutes": 1, "tag_list": "", "tags": [], "body_html": "

New body for the article

\n\n", "body_markdown": "**New** body for the article", "user": { - "name": "Laurene \"Genaro\" \\:/ Feil", - "username": "username2", - "twitter_username": "twitter2", - "github_username": "github2", - "user_id": 2, + "name": "Liz \"Rhett\" \\:/ Bednar", + "username": "username539", + "twitter_username": "twitter539", + "github_username": "github539", + "user_id": 4565, "website_url": null, - "profile_image": "/uploads/user/profile_image/2/dcd9de82-ae2e-4f76-b410-f419e0e4958b.jpeg", - "profile_image_90": "/uploads/user/profile_image/2/dcd9de82-ae2e-4f76-b410-f419e0e4958b.jpeg" + "profile_image": "/uploads/user/profile_image/4565/ecb785c8-83ba-4c71-99b3-e4d0bf578787.jpeg", + "profile_image_90": "/uploads/user/profile_image/4565/ecb785c8-83ba-4c71-99b3-e4d0bf578787.jpeg" } } } @@ -188,45 +188,45 @@ "example": [ { "type_of": "article", - "id": 5, - "title": "Stranger in a Strange Land4", - "description": "Franzen offal pork belly pour-over muggle magic. Brooklyn ethical iphone viral everyday paleo tumblr....", - "readable_publish_date": "Aug 31", - "slug": "stranger-in-a-strange-land4-4pe2", - "path": "/username6/stranger-in-a-strange-land4-4pe2", - "url": "http://forem.test/username6/stranger-in-a-strange-land4-4pe2", + "id": 2049, + "title": "Sleep the Brave188", + "description": "Knausgaard bushwick twee asymmetrical shabby chic. Portland offal poutine irony crucifix pop-up...", + "readable_publish_date": "Sep 19", + "slug": "sleep-the-brave188-58km", + "path": "/username543/sleep-the-brave188-58km", + "url": "http://forem.test/username543/sleep-the-brave188-58km", "comments_count": 0, "public_reactions_count": 0, "collection_id": null, - "published_timestamp": "2023-08-31T16:14:06Z", + "published_timestamp": "2023-09-19T09:14:38Z", "positive_reactions_count": 0, - "cover_image": "http://forem.test/assets/36-83d24fbff858b9dd4035d1e7d2df14090946ae4fed631055fc1d5862e7018348.png", - "social_image": "http://forem.test/assets/36-83d24fbff858b9dd4035d1e7d2df14090946ae4fed631055fc1d5862e7018348.png", - "canonical_url": "http://forem.test/username6/stranger-in-a-strange-land4-4pe2", - "created_at": "2023-08-31T16:14:06Z", + "cover_image": "http://forem.test/assets/21-8c16e6ef44da175a1e51f1ba9d0cb55af8a920db6aacbf1e4b7a055afc1b3d30.png", + "social_image": "http://forem.test/assets/21-8c16e6ef44da175a1e51f1ba9d0cb55af8a920db6aacbf1e4b7a055afc1b3d30.png", + "canonical_url": "http://forem.test/username543/sleep-the-brave188-58km", + "created_at": "2023-09-19T09:14:38Z", "edited_at": null, "crossposted_at": null, - "published_at": "2023-08-31T16:14:06Z", - "last_comment_at": "2023-08-31T16:14:06Z", + "published_at": "2023-09-19T09:14:38Z", + "last_comment_at": "2023-09-19T09:14:38Z", "reading_time_minutes": 1, "tag_list": ["discuss"], "tags": "discuss", "user": { - "name": "Milagro \"Tabatha\" \\:/ Grady", - "username": "username6", - "twitter_username": "twitter6", - "github_username": "github6", - "user_id": 6, + "name": "Stormy \"Elden\" \\:/ Gottlieb", + "username": "username543", + "twitter_username": "twitter543", + "github_username": "github543", + "user_id": 4569, "website_url": null, - "profile_image": "/uploads/user/profile_image/6/d0e96237-c811-453a-bf1f-03f75dd68680.jpeg", - "profile_image_90": "/uploads/user/profile_image/6/d0e96237-c811-453a-bf1f-03f75dd68680.jpeg" + "profile_image": "/uploads/user/profile_image/4569/ac6fdf2a-367d-4c57-81bb-0820ad14881c.jpeg", + "profile_image_90": "/uploads/user/profile_image/4569/ac6fdf2a-367d-4c57-81bb-0820ad14881c.jpeg" }, "organization": { - "name": "Orn, Walker and Windler", - "username": "org4", - "slug": "org4", - "profile_image": "/uploads/organization/profile_image/4/17e0ce9b-1b48-48ae-848e-73f18577f847.png", - "profile_image_90": "/uploads/organization/profile_image/4/17e0ce9b-1b48-48ae-848e-73f18577f847.png" + "name": "McCullough-Howell", + "username": "org79", + "slug": "org79", + "profile_image": "/uploads/organization/profile_image/411/0250f305-2634-42e5-9180-3a6504adf87d.png", + "profile_image_90": "/uploads/organization/profile_image/411/0250f305-2634-42e5-9180-3a6504adf87d.png" }, "flare_tag": { "name": "discuss", @@ -270,38 +270,38 @@ "example": [ { "type_of": "article", - "id": 8, - "title": "The Cricket on the Hearth7", - "description": "Salvia cleanse direct trade kinfolk blue bottle cold-pressed cronut neutra. Microdosing authentic...", - "readable_publish_date": "Aug 31", - "slug": "the-cricket-on-the-hearth7-7kg", - "path": "/username9/the-cricket-on-the-hearth7-7kg", - "url": "http://forem.test/username9/the-cricket-on-the-hearth7-7kg", + "id": 2052, + "title": "A Passage to India191", + "description": "Deep v vegan disrupt shoreditch whatever sartorial banh mi readymade. Godard ugh craft beer...", + "readable_publish_date": "Sep 19", + "slug": "a-passage-to-india191-529", + "path": "/username546/a-passage-to-india191-529", + "url": "http://forem.test/username546/a-passage-to-india191-529", "comments_count": 0, "public_reactions_count": 0, "collection_id": null, - "published_timestamp": "2023-08-31T16:14:06Z", + "published_timestamp": "2023-09-19T09:14:38Z", "positive_reactions_count": 0, - "cover_image": "http://forem.test/assets/15-680d1f40ea6901cfc18b12fab21c19e432463aa63a44853f8ad84e469adb2e54.png", - "social_image": "http://forem.test/assets/15-680d1f40ea6901cfc18b12fab21c19e432463aa63a44853f8ad84e469adb2e54.png", - "canonical_url": "http://forem.test/username9/the-cricket-on-the-hearth7-7kg", - "created_at": "2023-08-31T16:14:06Z", + "cover_image": "http://forem.test/assets/13-f0e8b5976009c819e9aa46f1be169bdba0f0e3142a6a869981d7868495926c78.png", + "social_image": "http://forem.test/assets/13-f0e8b5976009c819e9aa46f1be169bdba0f0e3142a6a869981d7868495926c78.png", + "canonical_url": "http://forem.test/username546/a-passage-to-india191-529", + "created_at": "2023-09-19T09:14:38Z", "edited_at": null, "crossposted_at": null, - "published_at": "2023-08-31T16:14:06Z", - "last_comment_at": "2023-08-31T16:14:06Z", + "published_at": "2023-09-19T09:14:38Z", + "last_comment_at": "2023-09-19T09:14:38Z", "reading_time_minutes": 1, "tag_list": ["javascript", "html", "discuss"], "tags": "javascript, html, discuss", "user": { - "name": "Carl \"Phillip\" \\:/ Nicolas", - "username": "username9", - "twitter_username": "twitter9", - "github_username": "github9", - "user_id": 9, + "name": "Brandie \"Hal\" \\:/ Kerluke", + "username": "username546", + "twitter_username": "twitter546", + "github_username": "github546", + "user_id": 4572, "website_url": null, - "profile_image": "/uploads/user/profile_image/9/15a67ff8-81eb-43d1-bcbe-c6f01dcbfc7e.jpeg", - "profile_image_90": "/uploads/user/profile_image/9/15a67ff8-81eb-43d1-bcbe-c6f01dcbfc7e.jpeg" + "profile_image": "/uploads/user/profile_image/4572/d755f2d2-f28c-4acf-94a1-0a3efe84c763.jpeg", + "profile_image_90": "/uploads/user/profile_image/4572/d755f2d2-f28c-4acf-94a1-0a3efe84c763.jpeg" }, "flare_tag": { "name": "discuss", @@ -311,38 +311,38 @@ }, { "type_of": "article", - "id": 7, - "title": "The House of Mirth6", - "description": "Pour-over narwhal sustainable kitsch pickled. Jean shorts meditation messenger bag swag bitters...", - "readable_publish_date": "Aug 31", - "slug": "the-house-of-mirth6-32ba", - "path": "/username8/the-house-of-mirth6-32ba", - "url": "http://forem.test/username8/the-house-of-mirth6-32ba", + "id": 2051, + "title": "The Wings of the Dove190", + "description": "Park shoreditch raw denim put a bird on it taxidermy lumbersexual. Austin gastropub cornhole....", + "readable_publish_date": "Sep 19", + "slug": "the-wings-of-the-dove190-5947", + "path": "/username545/the-wings-of-the-dove190-5947", + "url": "http://forem.test/username545/the-wings-of-the-dove190-5947", "comments_count": 0, "public_reactions_count": 0, "collection_id": null, - "published_timestamp": "2023-08-31T16:14:06Z", + "published_timestamp": "2023-09-19T09:14:38Z", "positive_reactions_count": 0, - "cover_image": "http://forem.test/assets/34-d27f3a4a9f6f1f373003c74b31749764691f510b2a18b55039478583864a067e.png", - "social_image": "http://forem.test/assets/34-d27f3a4a9f6f1f373003c74b31749764691f510b2a18b55039478583864a067e.png", - "canonical_url": "http://forem.test/username8/the-house-of-mirth6-32ba", - "created_at": "2023-08-31T16:14:06Z", + "cover_image": "http://forem.test/assets/32-543e7c7f0a939e829c37aab48d705350b855c887dc16dfab30fd9a0825c09291.png", + "social_image": "http://forem.test/assets/32-543e7c7f0a939e829c37aab48d705350b855c887dc16dfab30fd9a0825c09291.png", + "canonical_url": "http://forem.test/username545/the-wings-of-the-dove190-5947", + "created_at": "2023-09-19T09:14:38Z", "edited_at": null, "crossposted_at": null, - "published_at": "2023-08-31T16:14:06Z", - "last_comment_at": "2023-08-31T16:14:06Z", + "published_at": "2023-09-19T09:14:38Z", + "last_comment_at": "2023-09-19T09:14:38Z", "reading_time_minutes": 1, "tag_list": ["javascript", "html", "discuss"], "tags": "javascript, html, discuss", "user": { - "name": "Logan \"Ginger\" \\:/ Tromp", - "username": "username8", - "twitter_username": "twitter8", - "github_username": "github8", - "user_id": 8, + "name": "Pam \"Roma\" \\:/ Effertz", + "username": "username545", + "twitter_username": "twitter545", + "github_username": "github545", + "user_id": 4571, "website_url": null, - "profile_image": "/uploads/user/profile_image/8/75000ec0-f568-44da-9d7f-53d196cd9dcc.jpeg", - "profile_image_90": "/uploads/user/profile_image/8/75000ec0-f568-44da-9d7f-53d196cd9dcc.jpeg" + "profile_image": "/uploads/user/profile_image/4571/b2e8dd56-b556-4c24-b65d-b2cefcba6f78.jpeg", + "profile_image_90": "/uploads/user/profile_image/4571/b2e8dd56-b556-4c24-b65d-b2cefcba6f78.jpeg" }, "flare_tag": { "name": "discuss", @@ -352,38 +352,38 @@ }, { "type_of": "article", - "id": 6, - "title": "The Heart Is Deceitful Above All Things5", - "description": "Pabst heirloom umami. Narwhal trust fund pabst thundercats leggings paleo. Artisan raw denim health...", - "readable_publish_date": "Aug 31", - "slug": "the-heart-is-deceitful-above-all-things5-4ab7", - "path": "/username7/the-heart-is-deceitful-above-all-things5-4ab7", - "url": "http://forem.test/username7/the-heart-is-deceitful-above-all-things5-4ab7", + "id": 2050, + "title": "East of Eden189", + "description": "Beard offal echo neutra messenger bag. Kitsch single-origin coffee deep v five dollar toast 8-bit...", + "readable_publish_date": "Sep 19", + "slug": "east-of-eden189-19f5", + "path": "/username544/east-of-eden189-19f5", + "url": "http://forem.test/username544/east-of-eden189-19f5", "comments_count": 0, "public_reactions_count": 0, "collection_id": null, - "published_timestamp": "2023-08-31T16:14:06Z", + "published_timestamp": "2023-09-19T09:14:38Z", "positive_reactions_count": 0, - "cover_image": "http://forem.test/assets/5-ae4b452d58aedebf5435dbbd1978ea4acacf8657e6bef44304a3fccde0dd04ea.png", - "social_image": "http://forem.test/assets/5-ae4b452d58aedebf5435dbbd1978ea4acacf8657e6bef44304a3fccde0dd04ea.png", - "canonical_url": "http://forem.test/username7/the-heart-is-deceitful-above-all-things5-4ab7", - "created_at": "2023-08-31T16:14:06Z", + "cover_image": "http://forem.test/assets/28-45c03a7ee6bce4cd3ddd541bd942a0c7995fa77e4b680563681529e9dd14a676.png", + "social_image": "http://forem.test/assets/28-45c03a7ee6bce4cd3ddd541bd942a0c7995fa77e4b680563681529e9dd14a676.png", + "canonical_url": "http://forem.test/username544/east-of-eden189-19f5", + "created_at": "2023-09-19T09:14:38Z", "edited_at": null, "crossposted_at": null, - "published_at": "2023-08-31T16:14:06Z", - "last_comment_at": "2023-08-31T16:14:06Z", + "published_at": "2023-09-19T09:14:38Z", + "last_comment_at": "2023-09-19T09:14:38Z", "reading_time_minutes": 1, "tag_list": ["javascript", "html", "discuss"], "tags": "javascript, html, discuss", "user": { - "name": "Faith \"Nathanael\" \\:/ Jenkins", - "username": "username7", - "twitter_username": "twitter7", - "github_username": "github7", - "user_id": 7, + "name": "Twila \"Leigh\" \\:/ Lang", + "username": "username544", + "twitter_username": "twitter544", + "github_username": "github544", + "user_id": 4570, "website_url": null, - "profile_image": "/uploads/user/profile_image/7/ed542112-be37-4932-85a9-5d1e990e7656.jpeg", - "profile_image_90": "/uploads/user/profile_image/7/ed542112-be37-4932-85a9-5d1e990e7656.jpeg" + "profile_image": "/uploads/user/profile_image/4570/e6a6070d-5a37-437d-aff4-cf4cf583b954.jpeg", + "profile_image_90": "/uploads/user/profile_image/4570/e6a6070d-5a37-437d-aff4-cf4cf583b954.jpeg" }, "flare_tag": { "name": "discuss", @@ -428,40 +428,40 @@ "application/json": { "example": { "type_of": "article", - "id": 9, - "title": "Beneath the Bleeding8", - "description": "Schlitz chartreuse deep v selvage skateboard. Tousled hammock kogi crucifix farm-to-table selfies...", - "readable_publish_date": "Aug 31", - "slug": "beneath-the-bleeding8-4k30", - "path": "/username10/beneath-the-bleeding8-4k30", - "url": "http://forem.test/username10/beneath-the-bleeding8-4k30", + "id": 2053, + "title": "Recalled to Life192", + "description": "Tilde viral migas banjo yr yolo chia cred. Park chillwave tousled art party locavore squid. Schlitz...", + "readable_publish_date": "Sep 19", + "slug": "recalled-to-life192-34i9", + "path": "/username547/recalled-to-life192-34i9", + "url": "http://forem.test/username547/recalled-to-life192-34i9", "comments_count": 0, "public_reactions_count": 0, "collection_id": null, - "published_timestamp": "2023-08-31T16:14:06Z", + "published_timestamp": "2023-09-19T09:14:38Z", "positive_reactions_count": 0, - "cover_image": "http://forem.test/assets/17-c3d951980f63ed0823e9ba3e0324b187c8c7842f2e07dd3064978a070650a5ee.png", - "social_image": "http://forem.test/assets/17-c3d951980f63ed0823e9ba3e0324b187c8c7842f2e07dd3064978a070650a5ee.png", - "canonical_url": "http://forem.test/username10/beneath-the-bleeding8-4k30", - "created_at": "2023-08-31T16:14:06Z", + "cover_image": "http://forem.test/assets/4-dcfcc4d8dd259bc0751c2de24b1cf244b181c789934368ca1cb471f0944b695d.png", + "social_image": "http://forem.test/assets/4-dcfcc4d8dd259bc0751c2de24b1cf244b181c789934368ca1cb471f0944b695d.png", + "canonical_url": "http://forem.test/username547/recalled-to-life192-34i9", + "created_at": "2023-09-19T09:14:38Z", "edited_at": null, "crossposted_at": null, - "published_at": "2023-08-31T16:14:06Z", - "last_comment_at": "2023-08-31T16:14:06Z", + "published_at": "2023-09-19T09:14:38Z", + "last_comment_at": "2023-09-19T09:14:38Z", "reading_time_minutes": 1, "tag_list": "discuss", "tags": ["discuss"], - "body_html": "

Schlitz chartreuse deep v selvage skateboard. Tousled hammock kogi crucifix farm-to-table selfies twee.

\n\n

Letterpress leggings craft beer cardigan trust fund schlitz.

\n\n", - "body_markdown": "---\ntitle: Beneath the Bleeding8\npublished: true\ntags: discuss\ndate: \nseries: \ncanonical_url: \n\n---\n\nSchlitz chartreuse deep v selvage skateboard. Tousled hammock kogi crucifix farm-to-table selfies twee.\n\n\nLetterpress leggings craft beer cardigan trust fund schlitz.\n\n", + "body_html": "

Tilde viral migas banjo yr yolo chia cred. Park chillwave tousled art party locavore squid.

\n\n

Schlitz keffiyeh locavore.

\n\n", + "body_markdown": "---\ntitle: Recalled to Life192\npublished: true\ntags: discuss\ndate: \nseries: \ncanonical_url: \n\n---\n\nTilde viral migas banjo yr yolo chia cred. Park chillwave tousled art party locavore squid.\n\n\nSchlitz keffiyeh locavore.\n\n", "user": { - "name": "Mervin \"Zelda\" \\:/ Rogahn", - "username": "username10", - "twitter_username": "twitter10", - "github_username": "github10", - "user_id": 10, + "name": "Marcus \"Lakita\" \\:/ Bayer", + "username": "username547", + "twitter_username": "twitter547", + "github_username": "github547", + "user_id": 4573, "website_url": null, - "profile_image": "/uploads/user/profile_image/10/a1f12535-d889-41a6-8ea0-048255fc504f.jpeg", - "profile_image_90": "/uploads/user/profile_image/10/a1f12535-d889-41a6-8ea0-048255fc504f.jpeg" + "profile_image": "/uploads/user/profile_image/4573/bedf9465-013e-45fc-b970-a62ab6315a1f.jpeg", + "profile_image_90": "/uploads/user/profile_image/4573/bedf9465-013e-45fc-b970-a62ab6315a1f.jpeg" }, "flare_tag": { "name": "discuss", @@ -517,40 +517,40 @@ "application/json": { "example": { "type_of": "article", - "id": 10, - "title": "Frequent Hearses9", - "description": "Taxidermy artisan sartorial. Kickstarter kinfolk viral normcore +1 butcher truffaut lomo. Keytar food...", - "readable_publish_date": "Aug 31", - "slug": "frequent-hearses9-3p8c", - "path": "/username11/frequent-hearses9-3p8c", - "url": "http://forem.test/username11/frequent-hearses9-3p8c", + "id": 2054, + "title": "By Grand Central Station I Sat Down and Wept193", + "description": "Neutra 90's scenester mustache jean shorts. Asymmetrical seitan swag neutra cardigan. You probably...", + "readable_publish_date": "Sep 19", + "slug": "by-grand-central-station-i-sat-down-and-wept193-39od", + "path": "/username548/by-grand-central-station-i-sat-down-and-wept193-39od", + "url": "http://forem.test/username548/by-grand-central-station-i-sat-down-and-wept193-39od", "comments_count": 0, "public_reactions_count": 0, "collection_id": null, - "published_timestamp": "2023-08-31T16:14:06Z", + "published_timestamp": "2023-09-19T09:14:38Z", "positive_reactions_count": 0, - "cover_image": "http://forem.test/assets/25-b4bb206b62bee552880440f638594e41dcd649ed9bd821af2e8dfc671d1d6813.png", - "social_image": "http://forem.test/assets/25-b4bb206b62bee552880440f638594e41dcd649ed9bd821af2e8dfc671d1d6813.png", - "canonical_url": "http://forem.test/username11/frequent-hearses9-3p8c", - "created_at": "2023-08-31T16:14:06Z", - "edited_at": "2023-08-31T16:14:06Z", + "cover_image": "http://forem.test/assets/36-83d24fbff858b9dd4035d1e7d2df14090946ae4fed631055fc1d5862e7018348.png", + "social_image": "http://forem.test/assets/36-83d24fbff858b9dd4035d1e7d2df14090946ae4fed631055fc1d5862e7018348.png", + "canonical_url": "http://forem.test/username548/by-grand-central-station-i-sat-down-and-wept193-39od", + "created_at": "2023-09-19T09:14:39Z", + "edited_at": "2023-09-19T09:14:39Z", "crossposted_at": null, - "published_at": "2023-08-31T16:14:06Z", - "last_comment_at": "2023-08-31T16:14:06Z", + "published_at": "2023-09-19T09:14:38Z", + "last_comment_at": "2023-09-19T09:14:38Z", "reading_time_minutes": 1, "tag_list": "", "tags": [], "body_html": "

New body for the article

\n\n", "body_markdown": "**New** body for the article", "user": { - "name": "Linda \"Ouida\" \\:/ Orn", - "username": "username11", - "twitter_username": "twitter11", - "github_username": "github11", - "user_id": 11, + "name": "Booker \"Rueben\" \\:/ Cremin", + "username": "username548", + "twitter_username": "twitter548", + "github_username": "github548", + "user_id": 4574, "website_url": null, - "profile_image": "/uploads/user/profile_image/11/0a43175f-d7bf-4f53-b6fd-696b9c1fb4d7.jpeg", - "profile_image_90": "/uploads/user/profile_image/11/0a43175f-d7bf-4f53-b6fd-696b9c1fb4d7.jpeg" + "profile_image": "/uploads/user/profile_image/4574/cfe0f9d1-cfba-49fb-905b-883611ade340.jpeg", + "profile_image_90": "/uploads/user/profile_image/4574/cfe0f9d1-cfba-49fb-905b-883611ade340.jpeg" } } } @@ -633,40 +633,40 @@ "application/json": { "example": { "type_of": "article", - "id": 13, - "title": "A Confederacy of Dunces12", - "description": "Sustainable williamsburg polaroid vegan wes anderson tumblr leggings chillwave. Kitsch tumblr kinfolk...", - "readable_publish_date": "Aug 31", - "slug": "a-confederacy-of-dunces12-2m3l", - "path": "/username15/a-confederacy-of-dunces12-2m3l", - "url": "http://forem.test/username15/a-confederacy-of-dunces12-2m3l", + "id": 2057, + "title": "The Heart Is Deceitful Above All Things196", + "description": "Austin banh mi literally pickled cliche hoodie actually. Venmo flannel cleanse letterpress muggle...", + "readable_publish_date": "Sep 19", + "slug": "the-heart-is-deceitful-above-all-things196-2ban", + "path": "/username552/the-heart-is-deceitful-above-all-things196-2ban", + "url": "http://forem.test/username552/the-heart-is-deceitful-above-all-things196-2ban", "comments_count": 0, "public_reactions_count": 0, "collection_id": null, - "published_timestamp": "2023-08-31T16:14:07Z", + "published_timestamp": "2023-09-19T09:14:39Z", "positive_reactions_count": 0, - "cover_image": "http://forem.test/assets/3-93b6b57b5a6115cffe5d63d29a22825eb9e65f647bfef57a88244bc2b98186f0.png", - "social_image": "http://forem.test/assets/3-93b6b57b5a6115cffe5d63d29a22825eb9e65f647bfef57a88244bc2b98186f0.png", - "canonical_url": "http://forem.test/username15/a-confederacy-of-dunces12-2m3l", - "created_at": "2023-08-31T16:14:07Z", + "cover_image": "http://forem.test/assets/22-837b6c737e37b6d229b36d73e95ead7f26e0a346e0aa7dfbca74630ae161fb0d.png", + "social_image": "http://forem.test/assets/22-837b6c737e37b6d229b36d73e95ead7f26e0a346e0aa7dfbca74630ae161fb0d.png", + "canonical_url": "http://forem.test/username552/the-heart-is-deceitful-above-all-things196-2ban", + "created_at": "2023-09-19T09:14:39Z", "edited_at": null, "crossposted_at": null, - "published_at": "2023-08-31T16:14:07Z", - "last_comment_at": "2023-08-31T16:14:07Z", + "published_at": "2023-09-19T09:14:39Z", + "last_comment_at": "2023-09-19T09:14:39Z", "reading_time_minutes": 1, "tag_list": "discuss", "tags": ["discuss"], - "body_html": "

Sustainable williamsburg polaroid vegan wes anderson tumblr leggings chillwave. Kitsch tumblr kinfolk beard venmo etsy. Polaroid wayfarers farm-to-table occupy mlkshk williamsburg letterpress flannel.

\n\n

Actually 90's kale chips vhs farm-to-table literally pop-up. Art party neutra gentrify paleo swag. Ennui retro 8-bit.

\n\n", - "body_markdown": "---\ntitle: A Confederacy of Dunces12\npublished: true\ntags: discuss\ndate: \nseries: \ncanonical_url: \n\n---\n\nSustainable williamsburg polaroid vegan wes anderson tumblr leggings chillwave. Kitsch tumblr kinfolk beard venmo etsy. Polaroid wayfarers farm-to-table occupy mlkshk williamsburg letterpress flannel.\n\n\nActually 90's kale chips vhs farm-to-table literally pop-up. Art party neutra gentrify paleo swag. Ennui retro 8-bit.\n\n", + "body_html": "

Austin banh mi literally pickled cliche hoodie actually. Venmo flannel cleanse letterpress muggle magic echo. Thundercats truffaut shabby chic you probably haven't heard of them vinyl microdosing. Biodiesel 3 wolf moon before they sold out echo disrupt tumblr chicharrones.

\n\n

Skateboard seitan chambray distillery. Phlogiston carry 8-bit shabby chic marfa occupy.

\n\n", + "body_markdown": "---\ntitle: The Heart Is Deceitful Above All Things196\npublished: true\ntags: discuss\ndate: \nseries: \ncanonical_url: \n\n---\n\nAustin banh mi literally pickled cliche hoodie actually. Venmo flannel cleanse letterpress muggle magic echo. Thundercats truffaut shabby chic you probably haven't heard of them vinyl microdosing. Biodiesel 3 wolf moon before they sold out echo disrupt tumblr chicharrones.\n\n\nSkateboard seitan chambray distillery. Phlogiston carry 8-bit shabby chic marfa occupy.\n\n", "user": { - "name": "Joi \"Walton\" \\:/ Klocko", - "username": "username15", - "twitter_username": "twitter15", - "github_username": "github15", - "user_id": 15, + "name": "Filiberto \"Jamey\" \\:/ Schowalter", + "username": "username552", + "twitter_username": "twitter552", + "github_username": "github552", + "user_id": 4578, "website_url": null, - "profile_image": "/uploads/user/profile_image/15/1d474420-4a19-4d51-8434-f011180c6b70.jpeg", - "profile_image_90": "/uploads/user/profile_image/15/1d474420-4a19-4d51-8434-f011180c6b70.jpeg" + "profile_image": "/uploads/user/profile_image/4578/45efa5df-294f-4f3b-9ed4-0949a8514092.jpeg", + "profile_image_90": "/uploads/user/profile_image/4578/45efa5df-294f-4f3b-9ed4-0949a8514092.jpeg" }, "flare_tag": { "name": "discuss", @@ -946,17 +946,17 @@ "application/json": { "example": [ { - "id": 2, - "created_at": "2023-08-31T09:14:08.377-07:00", + "id": 198, + "created_at": "2023-09-19T01:14:41.534-08:00", "type_of": "manual", - "updated_at": "2023-08-31T09:14:08.377-07:00", + "updated_at": "2023-09-19T01:14:41.534-08:00", "user_count": 1 }, { - "id": 1, - "created_at": "2023-08-31T09:14:08.318-07:00", + "id": 197, + "created_at": "2023-09-19T01:14:41.429-08:00", "type_of": "manual", - "updated_at": "2023-08-31T09:14:08.318-07:00", + "updated_at": "2023-09-19T01:14:41.429-08:00", "user_count": 3 } ], @@ -993,10 +993,10 @@ "content": { "application/json": { "example": { - "id": 3, - "created_at": "2023-08-31T09:14:08.631-07:00", + "id": 199, + "created_at": "2023-09-19T01:14:42.013-08:00", "type_of": "manual", - "updated_at": "2023-08-31T09:14:08.631-07:00" + "updated_at": "2023-09-19T01:14:42.013-08:00" } } } @@ -1039,10 +1039,10 @@ "content": { "application/json": { "example": { - "id": 4, - "created_at": "2023-08-31T09:14:08.795-07:00", + "id": 200, + "created_at": "2023-09-19T01:14:42.336-08:00", "type_of": "manual", - "updated_at": "2023-08-31T09:14:08.795-07:00", + "updated_at": "2023-09-19T01:14:42.336-08:00", "user_count": 3 }, "schema": { @@ -1101,10 +1101,10 @@ "content": { "application/json": { "example": { - "id": 8, - "created_at": "2023-08-31T09:14:09.117-07:00", + "id": 204, + "created_at": "2023-09-19T01:14:42.950-08:00", "type_of": "manual", - "updated_at": "2023-08-31T09:14:09.117-07:00" + "updated_at": "2023-09-19T01:14:42.950-08:00" } } } @@ -1173,42 +1173,42 @@ "example": [ { "type_of": "user", - "id": 55, - "username": "username55", - "name": "Makeda \"Casey\" \\:/ Ryan", - "twitter_username": "twitter55", - "github_username": "github55", + "id": 4618, + "username": "username592", + "name": "Rolf \"Myesha\" \\:/ Collins", + "twitter_username": "twitter592", + "github_username": "github592", "summary": null, "location": null, "website_url": null, - "joined_at": "Aug 31, 2023", - "profile_image": "/uploads/user/profile_image/55/420077e0-ad8f-4ce6-94c4-62527657fbd7.jpeg" + "joined_at": "Sep 19, 2023", + "profile_image": "/uploads/user/profile_image/4618/793c29dc-5886-4b9a-be0a-110f43de8191.jpeg" }, { "type_of": "user", - "id": 56, - "username": "username56", - "name": "Kathrine \"Jordan\" \\:/ Jacobson", - "twitter_username": "twitter56", - "github_username": "github56", + "id": 4619, + "username": "username593", + "name": "Annika \"Margret\" \\:/ Windler", + "twitter_username": "twitter593", + "github_username": "github593", "summary": null, "location": null, "website_url": null, - "joined_at": "Aug 31, 2023", - "profile_image": "/uploads/user/profile_image/56/3183a414-75af-4498-ab8a-7f751e7e69f1.jpeg" + "joined_at": "Sep 19, 2023", + "profile_image": "/uploads/user/profile_image/4619/56a40c6c-a58d-43f3-9c89-83e713baa051.jpeg" }, { "type_of": "user", - "id": 57, - "username": "username57", - "name": "Lemuel \"Lindsey\" \\:/ Bayer", - "twitter_username": "twitter57", - "github_username": "github57", + "id": 4620, + "username": "username594", + "name": "Felice \"Samantha\" \\:/ Russel", + "twitter_username": "twitter594", + "github_username": "github594", "summary": null, "location": null, "website_url": null, - "joined_at": "Aug 31, 2023", - "profile_image": "/uploads/user/profile_image/57/515f8bfe-f3a1-4fb9-b425-ea10c7af96d4.jpeg" + "joined_at": "Sep 19, 2023", + "profile_image": "/uploads/user/profile_image/4620/b0fa8a5c-1190-4acc-af88-8e59db2dac77.jpeg" } ], "schema": { @@ -1269,7 +1269,7 @@ "content": { "application/json": { "example": { - "succeeded": [63, 64, 65], + "succeeded": [4626, 4627, 4628], "failed": [] } } @@ -1344,7 +1344,7 @@ "content": { "application/json": { "example": { - "succeeded": [82, 83, 84], + "succeeded": [4645, 4646, 4647], "failed": [] } } @@ -1439,13 +1439,13 @@ "content": { "application/json": { "example": { - "id": 2, + "id": 65, "approved": true, "audience_segment_id": null, "body_markdown": "# Hi, this is ad\nYep, it's an ad", "cached_tag_list": "", "clicks_count": 0, - "created_at": "2023-08-31T09:14:11.245-07:00", + "created_at": "2023-09-19T01:14:47.148-08:00", "creator_id": null, "display_to": "all", "exclude_article_ids": "", @@ -1454,12 +1454,12 @@ "organization_id": null, "placement_area": "post_comments", "priority": false, - "weight": 1.0, "processed_html": "

Hi, this is ad

Yep, it's an ad

", "published": true, "success_rate": 0.0, "type_of": "in_house", - "updated_at": "2023-08-31T09:14:11.245-07:00", + "updated_at": "2023-09-19T01:14:47.148-08:00", + "weight": 1.0, "audience_segment_type": null, "tag_list": "", "target_geolocations": ["US-WA", "CA-BC"] @@ -1535,27 +1535,27 @@ "content": { "application/json": { "example": { - "id": 3, + "id": 66, "approved": false, "audience_segment_id": null, - "body_markdown": "Hello _hey_ Hey hey 2", + "body_markdown": "Hello _hey_ Hey hey 15", "cached_tag_list": "", "clicks_count": 0, - "created_at": "2023-08-31T09:14:11.405-07:00", + "created_at": "2023-09-19T01:14:47.471-08:00", "creator_id": null, "display_to": "all", "exclude_article_ids": "", "impressions_count": 0, - "name": "Billboard 3", - "organization_id": 6, + "name": "Billboard 66", + "organization_id": 413, "placement_area": "sidebar_left", "priority": false, - "weight": 1.0, - "processed_html": "

Hello hey Hey hey 2

", + "processed_html": "

Hello hey Hey hey 15

", "published": false, "success_rate": 0.0, "type_of": "in_house", - "updated_at": "2023-08-31T09:14:11.407-07:00", + "updated_at": "2023-09-19T01:14:47.474-08:00", + "weight": 1.0, "audience_segment_type": null, "tag_list": "", "target_geolocations": [] @@ -1612,11 +1612,11 @@ "application/json": { "example": { "approved": false, - "body_markdown": "Hello _hey_ Hey hey 3", + "body_markdown": "Hello _hey_ Hey hey 16", "creator_id": null, "display_to": "all", - "name": "Billboard 4", - "organization_id": 7, + "name": "Billboard 67", + "organization_id": 414, "placement_area": "sidebar_left", "published": false, "type_of": "in_house", @@ -1625,13 +1625,13 @@ "audience_segment_id": null, "priority": false, "cached_tag_list": "", - "id": 4, + "id": 67, "clicks_count": 0, - "created_at": "2023-08-31T09:14:11.601-07:00", + "created_at": "2023-09-19T01:14:47.802-08:00", "impressions_count": 0, - "processed_html": "

Hello hey Hey hey 3

", + "processed_html": "

Hello hey Hey hey 16

", "success_rate": 0.0, - "updated_at": "2023-08-31T09:14:11.603-07:00", + "updated_at": "2023-09-19T01:14:47.806-08:00", "audience_segment_type": null, "tag_list": "", "target_geolocations": [] @@ -1767,18 +1767,18 @@ "example": [ { "type_of": "comment", - "id_code": "1", - "created_at": "2023-08-31T16:14:12Z", - "body_html": "

Hashtag 3 wolf moon vice kale chips pour-over shoreditch. Selvage biodiesel drinking pork belly roof. Yr humblebrag tattooed deep v bespoke narwhal semiotics.

\n\n", + "id_code": "13a", + "created_at": "2023-09-19T09:14:48Z", + "body_html": "

Hoodie bespoke xoxo yolo tacos. 8-bit knausgaard banjo. Before they sold out narwhal marfa roof godard.

\n\n", "user": { - "name": "Vicente \"Jannie\" \\:/ Murray", - "username": "username121", - "twitter_username": "twitter121", - "github_username": "github121", - "user_id": 121, + "name": "Sheree \"Willian\" \\:/ Turcotte", + "username": "username658", + "twitter_username": "twitter658", + "github_username": "github658", + "user_id": 4684, "website_url": null, - "profile_image": "/uploads/user/profile_image/121/f2c2c08f-4cf7-4386-bd9c-6c91e7fd8743.jpeg", - "profile_image_90": "/uploads/user/profile_image/121/f2c2c08f-4cf7-4386-bd9c-6c91e7fd8743.jpeg" + "profile_image": "/uploads/user/profile_image/4684/dc935d90-5fef-4b3d-9679-41da562ab8be.jpeg", + "profile_image_90": "/uploads/user/profile_image/4684/dc935d90-5fef-4b3d-9679-41da562ab8be.jpeg" }, "children": [] } @@ -1832,18 +1832,18 @@ "application/json": { "example": { "type_of": "comment", - "id_code": "3", - "created_at": "2023-08-31T16:14:12Z", - "body_html": "

Cliche readymade neutra microdosing seitan typewriter mlkshk. Trust fund keffiyeh meh everyday ennui asymmetrical bicycle rights 90's.

\n\n", + "id_code": "13c", + "created_at": "2023-09-19T09:14:49Z", + "body_html": "

Semiotics williamsburg cornhole. Meh biodiesel microdosing poutine.

\n\n", "user": { - "name": "Beau \"Laureen\" \\:/ Oberbrunner", - "username": "username125", - "twitter_username": "twitter125", - "github_username": "github125", - "user_id": 125, + "name": "Sherry \"Genny\" \\:/ Langworth", + "username": "username662", + "twitter_username": "twitter662", + "github_username": "github662", + "user_id": 4688, "website_url": null, - "profile_image": "/uploads/user/profile_image/125/4e16715d-7a3c-4c01-a181-ec350d4dd2f4.jpeg", - "profile_image_90": "/uploads/user/profile_image/125/4e16715d-7a3c-4c01-a181-ec350d4dd2f4.jpeg" + "profile_image": "/uploads/user/profile_image/4688/a738846f-ed40-47f0-95a2-4500b71298fa.jpeg", + "profile_image_90": "/uploads/user/profile_image/4688/a738846f-ed40-47f0-95a2-4500b71298fa.jpeg" }, "children": [] } @@ -1888,12 +1888,12 @@ "application/json": { "example": [ { - "id": 46, + "id": 3455, "name": "tag3", "points": 1.0 }, { - "id": 47, + "id": 3456, "name": "tag4", "points": 1.0 } @@ -1942,23 +1942,23 @@ "example": [ { "type_of": "user_follower", - "id": 6, - "created_at": "2023-08-31T16:14:12Z", - "user_id": 132, - "name": "Arnoldo \"Dan\" \\:/ Simonis", - "path": "/username132", - "username": "username132", - "profile_image": "/uploads/user/profile_image/132/2c8fc42d-49b0-4c0e-a75f-15ccb23e051a.jpeg" + "id": 54, + "created_at": "2023-09-19T09:14:49Z", + "user_id": 4695, + "name": "Austin \"Abdul\" \\:/ Grimes", + "path": "/username669", + "username": "username669", + "profile_image": "/uploads/user/profile_image/4695/81a8b1fe-68b3-4be2-815a-7eca9f4a1601.jpeg" }, { "type_of": "user_follower", - "id": 5, - "created_at": "2023-08-31T16:14:12Z", - "user_id": 130, - "name": "Corey \"Orville\" \\:/ Schneider", - "path": "/username130", - "username": "username130", - "profile_image": "/uploads/user/profile_image/130/8a5261a9-c00a-4c2b-8109-dc6bd5a3955a.jpeg" + "id": 53, + "created_at": "2023-09-19T09:14:49Z", + "user_id": 4693, + "name": "Von \"Tyron\" \\:/ Padberg", + "path": "/username667", + "username": "username667", + "profile_image": "/uploads/user/profile_image/4693/1fba9b09-f9c0-43c5-ac4f-39c52468b367.jpeg" } ], "schema": { @@ -2036,19 +2036,19 @@ "application/json": { "example": { "type_of": "organization", - "id": 12, - "username": "org12", - "name": "Durgan-Mante", - "summary": "Drinking carry fixie kitsch. Gluten-free next level phlogiston typewriter you probably haven't heard of them neutra intelligentsia forage. Irony craft ", - "twitter_username": "org7205", - "github_username": "org6367", - "url": "http://koelpin.biz/kristal.block", + "id": 419, + "username": "org87", + "name": "Ondricka Inc", + "summary": "Mixtape before they sold out seitan. Organic diy plaid.", + "twitter_username": "org6587", + "github_username": "org5640", + "url": "http://mcglynn.co/aurelio_homenick", "location": null, "tech_stack": null, "tag_line": null, "story": null, - "joined_at": "2023-08-31T16:14:12Z", - "profile_image": "/uploads/organization/profile_image/12/fb2030d2-b14a-473d-8e6a-c6803ea3cb24.png" + "joined_at": "2023-09-19T09:14:50Z", + "profile_image": "/uploads/organization/profile_image/419/96a05f9d-ffaf-4090-9b5c-4de87f66875d.png" }, "schema": { "type": "object", @@ -2104,29 +2104,29 @@ "example": [ { "type_of": "user", - "id": 142, - "username": "username142", - "name": "Jerrold \"Jeanene\" \\:/ Parisian", - "twitter_username": "twitter142", - "github_username": "github142", + "id": 4705, + "username": "username679", + "name": "Kory \"Isaiah\" \\:/ Feil", + "twitter_username": "twitter679", + "github_username": "github679", "summary": null, "location": null, "website_url": null, - "joined_at": "Aug 31, 2023", - "profile_image": "/uploads/user/profile_image/142/80de7b82-d28d-444c-a8b1-26da740599bf.jpeg" + "joined_at": "Sep 19, 2023", + "profile_image": "/uploads/user/profile_image/4705/ecb0a753-85c4-42b8-b734-d2211ac795e2.jpeg" }, { "type_of": "user", - "id": 143, - "username": "username143", - "name": "Quinn \"Alexis\" \\:/ Pfeffer", - "twitter_username": "twitter143", - "github_username": "github143", + "id": 4706, + "username": "username680", + "name": "Yvonne \"Teisha\" \\:/ Metz", + "twitter_username": "twitter680", + "github_username": "github680", "summary": null, "location": null, "website_url": null, - "joined_at": "Aug 31, 2023", - "profile_image": "/uploads/user/profile_image/143/c5f4cc9c-7773-4e39-857f-652a38298d5f.jpeg" + "joined_at": "Sep 19, 2023", + "profile_image": "/uploads/user/profile_image/4706/f971253a-d374-464d-bbe0-80304cbf9f45.jpeg" } ], "schema": { @@ -2183,45 +2183,45 @@ "example": [ { "type_of": "article", - "id": 25, - "title": "The Proper Study24", - "description": "Twee sriracha lo-fi vinyl direct trade art party thundercats. Shabby chic vinyl flexitarian bespoke...", - "readable_publish_date": "Aug 31", - "slug": "the-proper-study24-507o", - "path": "/org16/the-proper-study24-507o", - "url": "http://forem.test/org16/the-proper-study24-507o", + "id": 2069, + "title": "The Needle's Eye208", + "description": "Butcher cleanse everyday diy fingerstache. Pabst lo-fi art party. Deep v twee semiotics blog bitters...", + "readable_publish_date": "Sep 19", + "slug": "the-needles-eye208-4i0d", + "path": "/org91/the-needles-eye208-4i0d", + "url": "http://forem.test/org91/the-needles-eye208-4i0d", "comments_count": 0, "public_reactions_count": 0, "collection_id": null, - "published_timestamp": "2023-08-31T16:14:13Z", + "published_timestamp": "2023-09-19T09:14:51Z", "positive_reactions_count": 0, - "cover_image": "http://forem.test/assets/25-b4bb206b62bee552880440f638594e41dcd649ed9bd821af2e8dfc671d1d6813.png", - "social_image": "http://forem.test/assets/25-b4bb206b62bee552880440f638594e41dcd649ed9bd821af2e8dfc671d1d6813.png", - "canonical_url": "http://forem.test/org16/the-proper-study24-507o", - "created_at": "2023-08-31T16:14:13Z", + "cover_image": "http://forem.test/assets/20-92231c1d2ddb3b707b8c1b5cb711ef17632ff2a64495970a58518ce33c3a4f76.png", + "social_image": "http://forem.test/assets/20-92231c1d2ddb3b707b8c1b5cb711ef17632ff2a64495970a58518ce33c3a4f76.png", + "canonical_url": "http://forem.test/org91/the-needles-eye208-4i0d", + "created_at": "2023-09-19T09:14:51Z", "edited_at": null, "crossposted_at": null, - "published_at": "2023-08-31T16:14:13Z", - "last_comment_at": "2023-08-31T16:14:13Z", + "published_at": "2023-09-19T09:14:51Z", + "last_comment_at": "2023-09-19T09:14:51Z", "reading_time_minutes": 1, "tag_list": ["javascript", "html", "discuss"], "tags": "javascript, html, discuss", "user": { - "name": "Kami \"Jerrold\" \\:/ Beahan", - "username": "username150", - "twitter_username": "twitter150", - "github_username": "github150", - "user_id": 150, + "name": "Lauren \"Boyce\" \\:/ Pagac", + "username": "username687", + "twitter_username": "twitter687", + "github_username": "github687", + "user_id": 4713, "website_url": null, - "profile_image": "/uploads/user/profile_image/150/be08afc9-5183-4b4a-8821-519ff3bbfec9.jpeg", - "profile_image_90": "/uploads/user/profile_image/150/be08afc9-5183-4b4a-8821-519ff3bbfec9.jpeg" + "profile_image": "/uploads/user/profile_image/4713/7b0bbf54-0b48-4e26-97fd-926be2809bd0.jpeg", + "profile_image_90": "/uploads/user/profile_image/4713/7b0bbf54-0b48-4e26-97fd-926be2809bd0.jpeg" }, "organization": { - "name": "Ledner-Turcotte", - "username": "org16", - "slug": "org16", - "profile_image": "/uploads/organization/profile_image/16/d3c01a7b-a340-4f88-9328-8013205f57e4.png", - "profile_image_90": "/uploads/organization/profile_image/16/d3c01a7b-a340-4f88-9328-8013205f57e4.png" + "name": "Beer-Sawayn", + "username": "org91", + "slug": "org91", + "profile_image": "/uploads/organization/profile_image/423/cf0141eb-f34a-46c0-8c22-f7bdfc8cebf2.png", + "profile_image_90": "/uploads/organization/profile_image/423/cf0141eb-f34a-46c0-8c22-f7bdfc8cebf2.png" } } ], @@ -2270,15 +2270,15 @@ "application/json": { "example": [ { - "id": 18, - "name": "Kiehn, Robel and Hintz", + "id": 425, + "name": "Torphy, Bruen and Parisian", "profile_image": { - "url": "/uploads/organization/profile_image/18/2fbf8c19-9700-4ceb-b9cc-98865663f471.png" + "url": "/uploads/organization/profile_image/425/a7b3a84c-ff30-4236-a82d-6c72be9ba0af.png" }, - "slug": "org18", - "summary": "Seitan knausgaard keytar shabby chic. Cornhole 8-bit mumblecore pitchfork aesthetic. Keytar skateboard helvetica fanny pack neutra fashion axe park.", + "slug": "org93", + "summary": "Irony readymade trust fund semiotics kale chips phlogiston tofu selfies.", "tag_line": null, - "url": "http://hane-klocko.info/pam_hills" + "url": "http://williamson-towne.org/danyel.jacobi" } ], "schema": { @@ -2304,7 +2304,7 @@ "content": { "application/json": { "example": { - "id": 21, + "id": 428, "name": "New Test Org", "profile_image": "uploads/organization/profile_image/1/400x400.jpg", "slug": "org10001", @@ -2365,19 +2365,19 @@ "application/json": { "example": { "type_of": "organization", - "id": 19, - "username": "org19", - "name": "McGlynn-Schaefer", - "summary": "Fashion axe 3 wolf moon tacos. Art party shoreditch actually +1 lomo deep v xoxo yolo. Shabby chic cold-pressed biodiesel migas chia skateboard polaroi", - "twitter_username": "org4517", - "github_username": "org1008", - "url": "http://ondricka.io/sharee_streich", + "id": 426, + "username": "org94", + "name": "Reynolds-Kilback", + "summary": "Mixtape salvia marfa master kinfolk. Artisan migas pinterest gentrify. Twee farm-to-table knausgaard.", + "twitter_username": "org437", + "github_username": "org8264", + "url": "http://hansen.org/man", "location": null, "tech_stack": null, "tag_line": null, "story": null, - "joined_at": "2023-08-31T16:14:13Z", - "profile_image": "/uploads/organization/profile_image/19/1caad85a-3d0c-4de0-a1c0-56eaf88a41e4.png" + "joined_at": "2023-09-19T09:14:51Z", + "profile_image": "/uploads/organization/profile_image/426/c1dcc00d-1091-4b75-9b7a-df9b1738c0c1.png" }, "schema": { "type": "object", @@ -2425,13 +2425,13 @@ "content": { "application/json": { "example": { - "id": 22, - "name": "Langosh-Smitham", - "profile_image": "/uploads/organization/profile_image/22/e0dcf74d-eb09-432d-9343-20122f5a53aa.png", - "slug": "org21", + "id": 429, + "name": "Spinka-Wolff", + "profile_image": "/uploads/organization/profile_image/429/b7ae8a14-8709-4380-8ad4-b998aa897561.png", + "slug": "org96", "summary": "An updated summary for the organization.", "tag_line": null, - "url": "http://hegmann-walsh.biz/danilo" + "url": "http://strosin-johnston.name/ernesto_kshlerin" } } } @@ -2504,7 +2504,7 @@ "content": { "application/json": { "example": { - "message": "deletion scheduled for organization with ID 26", + "message": "deletion scheduled for organization with ID 433", "status": 200 } } @@ -2537,16 +2537,16 @@ "application/json": { "example": [ { - "id": 1, - "title": "Beyond the Mexique Bay", - "slug": "authority_polish", - "description": "Atque odio est placeat.", + "id": 51, + "title": "To Sail Beyond the Sunset", + "slug": "provision_message", + "description": "Ut laboriosam tenetur mollitia.", "is_top_level_path": false, "landing_page": false, "body_html": null, "body_json": null, - "body_markdown": "Sapiente dolores sint in.", - "processed_html": "

Sapiente dolores sint in.

\n\n", + "body_markdown": "Tenetur sunt autem exercitationem.", + "processed_html": "

Tenetur sunt autem exercitationem.

\n\n", "social_image": { "url": null }, @@ -2575,7 +2575,7 @@ "content": { "application/json": { "example": { - "id": 3, + "id": 53, "title": "Example Page", "slug": "example1", "description": "a new page", @@ -2702,16 +2702,16 @@ "content": { "application/json": { "example": { - "id": 6, - "title": "Of Human Bondage", - "slug": "polish_highway", - "description": "Et provident fugit rerum.", + "id": 56, + "title": "A Confederacy of Dunces", + "slug": "doctor-shock", + "description": "Dolor consequuntur at pariatur.", "is_top_level_path": false, "landing_page": false, "body_html": null, "body_json": null, - "body_markdown": "Quibusdam fugit ullam nesciunt.", - "processed_html": "

Quibusdam fugit ullam nesciunt.

\n\n", + "body_markdown": "Id iusto error maxime.", + "processed_html": "

Id iusto error maxime.

\n\n", "social_image": { "url": null }, @@ -2749,16 +2749,16 @@ "content": { "application/json": { "example": { - "id": 7, + "id": 57, "title": "New Title", - "slug": "theorist_whole", - "description": "Exercitationem libero recusandae suscipit.", + "slug": "dynamic-widen", + "description": "Corporis dignissimos similique quia.", "is_top_level_path": false, "landing_page": false, "body_html": null, "body_json": null, - "body_markdown": "Iure omnis eveniet consequatur.", - "processed_html": "

Iure omnis eveniet consequatur.

\n\n", + "body_markdown": "Cum dolor nisi architecto.", + "processed_html": "

Cum dolor nisi architecto.

\n\n", "social_image": { "url": null }, @@ -2786,16 +2786,16 @@ "content": { "application/json": { "example": { - "id": 9, - "title": "The Man Within", - "slug": "relationship_smile", - "description": "Dolorem in nesciunt neque.", + "id": 59, + "title": "Cabbages and Kings", + "slug": "diagram-report", + "description": "Corporis voluptatem at ut.", "is_top_level_path": false, "landing_page": false, "body_html": null, "body_json": null, - "body_markdown": "Ea ut assumenda non.", - "processed_html": "

Omnis velit in neque.

\n\n", + "body_markdown": "Cupiditate quisquam eos tempore.", + "processed_html": "

Totam culpa nulla mollitia.

\n\n", "social_image": { "url": null }, @@ -2839,16 +2839,16 @@ "content": { "application/json": { "example": { - "id": 10, - "title": "Of Mice and Men", - "slug": "strict-perception", - "description": "Nisi ut id sed.", + "id": 60, + "title": "The Heart Is a Lonely Hunter", + "slug": "exaggerate-thirsty", + "description": "Et cum quia ut.", "is_top_level_path": false, "landing_page": false, "body_html": null, "body_json": null, - "body_markdown": "Sunt rem ut non.", - "processed_html": "

Sunt rem ut non.

\n\n", + "body_markdown": "Ab eveniet ut et.", + "processed_html": "

Ab eveniet ut et.

\n\n", "social_image": { "url": null }, @@ -2924,14 +2924,14 @@ { "type_of": "podcast_episodes", "class_name": "PodcastEpisode", - "id": 2, - "path": "/codenewbie/slug-2", - "title": "22", - "image_url": "/uploads/podcast/image/2/1980fcab-bb04-411e-88ea-a212d0c4034d.jpeg", + "id": 48, + "path": "/codenewbie/slug-4", + "title": "18", + "image_url": "/uploads/podcast/image/38/b9395026-6a95-48d8-952c-db3756cdf017.jpeg", "podcast": { - "title": "HopSlam Ale", + "title": "90 Minute IPA", "slug": "codenewbie", - "image_url": "/uploads/podcast/image/2/1980fcab-bb04-411e-88ea-a212d0c4034d.jpeg" + "image_url": "/uploads/podcast/image/38/b9395026-6a95-48d8-952c-db3756cdf017.jpeg" } } ], @@ -2984,8 +2984,8 @@ "example": { "type_of": "profile_image", "image_of": "user", - "profile_image": "/uploads/user/profile_image/174/fd9c5c0a-cc51-4a92-aa48-0175a0a8cc22.jpeg", - "profile_image_90": "/uploads/user/profile_image/174/fd9c5c0a-cc51-4a92-aa48-0175a0a8cc22.jpeg" + "profile_image": "/uploads/user/profile_image/4737/50120074-eda9-4a99-b4be-3680321207a7.jpeg", + "profile_image_90": "/uploads/user/profile_image/4737/50120074-eda9-4a99-b4be-3680321207a7.jpeg" }, "schema": { "type": "object", @@ -3058,8 +3058,8 @@ "example": { "result": "create", "category": "like", - "id": 1, - "reactable_id": 27, + "id": 52, + "reactable_id": 2071, "reactable_type": "Article" } } @@ -3127,8 +3127,8 @@ "example": { "result": "none", "category": "like", - "id": 3, - "reactable_id": 29, + "id": 54, + "reactable_id": 2073, "reactable_type": "Article" } } @@ -3213,20 +3213,20 @@ "application/json": { "example": [ { - "id": 80, - "name": "tag7", + "id": 3487, + "name": "tag5", "bg_color_hex": null, "text_color_hex": null }, { - "id": 79, + "id": 3488, "name": "tag6", "bg_color_hex": null, "text_color_hex": null }, { - "id": 78, - "name": "tag5", + "id": 3489, + "name": "tag7", "bg_color_hex": null, "text_color_hex": null } @@ -3243,6 +3243,151 @@ } } }, + "/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 + } + } + } + } + } + } + }, + "/api/users/{id}/limited": { + "put": { + "summary": "Add limited role for a User", + "tags": ["users"], + "description": "This endpoint allows the client to limit 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 'limited' role. Limiting a user will limit notifications\ngenerated from new posts and comments. It doesn't delete any of the user's content or prevent them\nfrom generating new content while limited. Users are not notified of their limits\nin the UI, so if you want them to know about this, you must notify them.", + "operationId": "limitUser", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "description": "The ID of the user to limit.", + "schema": { + "type": "integer", + "format": "int32", + "minimum": 1 + }, + "example": 1 + } + ], + "responses": { + "204": { + "description": "User successfully limited" + }, + "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 + } + } + } + } + } + }, + "delete": { + "summary": "Remove limited for a User", + "tags": ["users"], + "description": "This endpoint allows the client to remove limits for a user.\n\nThe user associated with the API key must have any 'admin' or 'moderator' role.\n\nThis specified user will be restored to 'general' status. Users are not notified\nof limits in the UI, so if you want them to know about this, you must\nnotify them.", + "operationId": "unLimitUser", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "description": "The ID of the user to un-limit.", + "schema": { + "type": "integer", + "format": "int32", + "minimum": 1 + }, + "example": 1 + } + ], + "responses": { + "204": { + "description": "User successfully un-limited" + }, + "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 + } + } + } + } + } + } + }, "/api/users/me": { "get": { "summary": "The authenticated user", @@ -3256,16 +3401,16 @@ "application/json": { "example": { "type_of": "user", - "id": 186, - "username": "username186", - "name": "Ira \"Guadalupe\" \\:/ Pacocha", - "twitter_username": "twitter186", - "github_username": "github186", + "id": 4767, + "username": "username741", + "name": "Alejandrina \"Regenia\" \\:/ Miller", + "twitter_username": "twitter741", + "github_username": "github741", "summary": null, "location": null, "website_url": null, - "joined_at": "Aug 31, 2023", - "profile_image": "/uploads/user/profile_image/186/c6c197c2-ee86-4078-aff5-fc602016f8d8.jpeg" + "joined_at": "Sep 19, 2023", + "profile_image": "/uploads/user/profile_image/4767/b0fc9e82-bb0f-4df3-9820-d914d340b9ef.jpeg" }, "schema": { "type": "object", @@ -3372,55 +3517,6 @@ } } }, - "/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 - } - } - } - } - } - } - }, "/api/admin/users": { "post": { "summary": "Invite a User", @@ -3489,28 +3585,28 @@ "example": [ { "type_of": "video_article", - "id": 31, - "path": "/username205/jacob-have-i-loved30-3lh3", + "id": 2076, + "path": "/username761/by-grand-central-station-i-sat-down-and-wept215-5jc", "cloudinary_video_url": "https://dw71fyauz7yz9.cloudfront.net/video-upload__1e42eb0bab4abb3c63baeb5e8bdfe69f/thumbs-video-upload__1e42eb0bab4abb3c63baeb5e8bdfe69f-00001.png", - "title": "Jacob Have I Loved30", - "user_id": 206, + "title": "By Grand Central Station I Sat Down and Wept215", + "user_id": 4788, "video_duration_in_minutes": "00:00", "video_source_url": "https://dw71fyauz7yz9.cloudfront.net/video-upload__1e42eb0bab4abb3c63baeb5e8bdfe69f/video-upload__1e42eb0bab4abb3c63baeb5e8bdfe69f.m3u8", "user": { - "name": "Cyrus \"Enrique\" \\:/ Hilpert" + "name": "Doug \"Lance\" \\:/ Tremblay" } }, { "type_of": "video_article", - "id": 32, - "path": "/username206/if-i-forget-thee-jerusalem31-1nfn", + "id": 2075, + "path": "/username760/wildfire-at-midnight214-349", "cloudinary_video_url": "https://dw71fyauz7yz9.cloudfront.net/video-upload__1e42eb0bab4abb3c63baeb5e8bdfe69f/thumbs-video-upload__1e42eb0bab4abb3c63baeb5e8bdfe69f-00001.png", - "title": "If I Forget Thee Jerusalem31", - "user_id": 207, + "title": "Wildfire at Midnight214", + "user_id": 4787, "video_duration_in_minutes": "00:00", "video_source_url": "https://dw71fyauz7yz9.cloudfront.net/video-upload__1e42eb0bab4abb3c63baeb5e8bdfe69f/video-upload__1e42eb0bab4abb3c63baeb5e8bdfe69f.m3u8", "user": { - "name": "Dara \"Alaina\" \\:/ Legros" + "name": "Serita \"Carrol\" \\:/ Rosenbaum" } } ],