Page MenuHomePhorge

No OneTemporary

Authored By
Unknown
Size
274 KB
Referenced Files
None
Subscribers
None
This file is larger than 256 KB, so syntax highlighting was skipped.
diff --git a/changelog.d/mastodon-quote-id-api.change b/changelog.d/mastodon-quote-id-api.change
new file mode 100644
index 000000000..8b9f267b3
--- /dev/null
+++ b/changelog.d/mastodon-quote-id-api.change
@@ -0,0 +1 @@
+Support `quoted_status_id` parameter in post creation request
diff --git a/lib/pleroma/web/api_spec/operations/status_operation.ex b/lib/pleroma/web/api_spec/operations/status_operation.ex
index 500fbb64e..fed37f51d 100644
--- a/lib/pleroma/web/api_spec/operations/status_operation.ex
+++ b/lib/pleroma/web/api_spec/operations/status_operation.ex
@@ -1,903 +1,909 @@
# Pleroma: A lightweight social networking server
# Copyright © 2017-2022 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Web.ApiSpec.StatusOperation do
alias OpenApiSpex.Operation
alias OpenApiSpex.Schema
alias Pleroma.Web.ApiSpec.AccountOperation
alias Pleroma.Web.ApiSpec.Schemas.Account
alias Pleroma.Web.ApiSpec.Schemas.ApiError
alias Pleroma.Web.ApiSpec.Schemas.Attachment
alias Pleroma.Web.ApiSpec.Schemas.BooleanLike
alias Pleroma.Web.ApiSpec.Schemas.Emoji
alias Pleroma.Web.ApiSpec.Schemas.FlakeID
alias Pleroma.Web.ApiSpec.Schemas.Poll
alias Pleroma.Web.ApiSpec.Schemas.ScheduledStatus
alias Pleroma.Web.ApiSpec.Schemas.Status
alias Pleroma.Web.ApiSpec.Schemas.VisibilityScope
import Pleroma.Web.ApiSpec.Helpers
def open_api_operation(action) do
operation = String.to_existing_atom("#{action}_operation")
apply(__MODULE__, operation, [])
end
def index_operation do
%Operation{
tags: ["Retrieve status information"],
summary: "Multiple statuses",
security: [%{"oAuth" => ["read:statuses"]}],
parameters: [
Operation.parameter(
:id,
:query,
%Schema{type: :array, items: FlakeID},
"Array of status IDs"
),
Operation.parameter(
:ids,
:query,
%Schema{type: :array, items: FlakeID},
"Deprecated, use `id` instead"
),
Operation.parameter(
:with_muted,
:query,
BooleanLike.schema(),
"Include reactions from muted acccounts."
)
],
operationId: "StatusController.index",
responses: %{
200 => Operation.response("Array of Status", "application/json", array_of_statuses())
}
}
end
def create_operation do
%Operation{
tags: ["Status actions"],
summary: "Publish new status",
security: [%{"oAuth" => ["write:statuses"]}],
description: "Post a new status",
operationId: "StatusController.create",
requestBody: request_body("Parameters", create_request(), required: true),
responses: %{
200 =>
Operation.response(
"Status. When `scheduled_at` is present, ScheduledStatus is returned instead",
"application/json",
%Schema{anyOf: [Status, ScheduledStatus]}
),
422 => Operation.response("Bad Request / MRF Rejection", "application/json", ApiError)
}
}
end
def show_operation do
%Operation{
tags: ["Retrieve status information"],
summary: "Status",
description: "View information about a status",
operationId: "StatusController.show",
security: [%{"oAuth" => ["read:statuses"]}],
parameters: [
id_param(),
Operation.parameter(
:with_muted,
:query,
BooleanLike.schema(),
"Include reactions from muted acccounts."
)
],
responses: %{
200 => status_response(),
404 => Operation.response("Not Found", "application/json", ApiError)
}
}
end
def delete_operation do
%Operation{
tags: ["Status actions"],
summary: "Delete",
security: [%{"oAuth" => ["write:statuses"]}],
description: "Delete one of your own statuses",
operationId: "StatusController.delete",
parameters: [id_param()],
responses: %{
200 => status_response(),
403 => Operation.response("Forbidden", "application/json", ApiError),
404 => Operation.response("Not Found", "application/json", ApiError)
}
}
end
def reblog_operation do
%Operation{
tags: ["Status actions"],
summary: "Reblog",
security: [%{"oAuth" => ["write:statuses"]}],
description: "Share a status",
operationId: "StatusController.reblog",
parameters: [id_param()],
requestBody:
request_body("Parameters", %Schema{
type: :object,
properties: %{
visibility: %Schema{allOf: [VisibilityScope]}
}
}),
responses: %{
200 => status_response(),
404 => Operation.response("Not Found", "application/json", ApiError)
}
}
end
def unreblog_operation do
%Operation{
tags: ["Status actions"],
summary: "Undo reblog",
security: [%{"oAuth" => ["write:statuses"]}],
description: "Undo a reshare of a status",
operationId: "StatusController.unreblog",
parameters: [id_param()],
responses: %{
200 => status_response(),
404 => Operation.response("Not Found", "application/json", ApiError)
}
}
end
def favourite_operation do
%Operation{
tags: ["Status actions"],
summary: "Favourite",
security: [%{"oAuth" => ["write:favourites"]}],
description: "Add a status to your favourites list",
operationId: "StatusController.favourite",
parameters: [id_param()],
responses: %{
200 => status_response(),
404 => Operation.response("Not Found", "application/json", ApiError)
}
}
end
def unfavourite_operation do
%Operation{
tags: ["Status actions"],
summary: "Undo favourite",
security: [%{"oAuth" => ["write:favourites"]}],
description: "Remove a status from your favourites list",
operationId: "StatusController.unfavourite",
parameters: [id_param()],
responses: %{
200 => status_response(),
404 => Operation.response("Not Found", "application/json", ApiError)
}
}
end
def pin_operation do
%Operation{
tags: ["Status actions"],
summary: "Pin to profile",
security: [%{"oAuth" => ["write:accounts"]}],
description: "Feature one of your own public statuses at the top of your profile",
operationId: "StatusController.pin",
parameters: [id_param()],
responses: %{
200 => status_response(),
400 =>
Operation.response("Bad Request", "application/json", %Schema{
allOf: [ApiError],
title: "Unprocessable Entity",
example: %{
"error" => "You have already pinned the maximum number of statuses"
}
}),
404 =>
Operation.response("Not found", "application/json", %Schema{
allOf: [ApiError],
title: "Unprocessable Entity",
example: %{
"error" => "Record not found"
}
}),
422 =>
Operation.response(
"Unprocessable Entity",
"application/json",
%Schema{
allOf: [ApiError],
title: "Unprocessable Entity",
example: %{
"error" => "Someone else's status cannot be pinned"
}
}
)
}
}
end
def unpin_operation do
%Operation{
tags: ["Status actions"],
summary: "Unpin from profile",
security: [%{"oAuth" => ["write:accounts"]}],
description: "Unfeature a status from the top of your profile",
operationId: "StatusController.unpin",
parameters: [id_param()],
responses: %{
200 => status_response(),
400 =>
Operation.response("Bad Request", "application/json", %Schema{
allOf: [ApiError],
title: "Unprocessable Entity",
example: %{
"error" => "You have already pinned the maximum number of statuses"
}
}),
404 =>
Operation.response("Not found", "application/json", %Schema{
allOf: [ApiError],
title: "Unprocessable Entity",
example: %{
"error" => "Record not found"
}
})
}
}
end
def bookmark_operation do
%Operation{
tags: ["Status actions"],
summary: "Bookmark",
security: [%{"oAuth" => ["write:bookmarks"]}],
description: "Privately bookmark a status",
operationId: "StatusController.bookmark",
parameters: [id_param()],
requestBody:
request_body("Parameters", %Schema{
title: "StatusUpdateRequest",
type: :object,
properties: %{
folder_id: %Schema{
nullable: true,
allOf: [FlakeID],
description: "ID of bookmarks folder, if any"
}
}
}),
responses: %{
200 => status_response()
}
}
end
def unbookmark_operation do
%Operation{
tags: ["Status actions"],
summary: "Undo bookmark",
security: [%{"oAuth" => ["write:bookmarks"]}],
description: "Remove a status from your private bookmarks",
operationId: "StatusController.unbookmark",
parameters: [id_param()],
responses: %{
200 => status_response()
}
}
end
def mute_conversation_operation do
%Operation{
tags: ["Status actions"],
summary: "Mute conversation",
security: [%{"oAuth" => ["write:mutes"]}],
description: "Do not receive notifications for the thread that this status is part of.",
operationId: "StatusController.mute_conversation",
requestBody:
request_body("Parameters", %Schema{
type: :object,
properties: %{
expires_in: %Schema{
type: :integer,
nullable: true,
description: "Expire the mute in `expires_in` seconds. Default 0 for infinity",
default: 0
}
}
}),
parameters: [
id_param(),
Operation.parameter(
:expires_in,
:query,
%Schema{type: :integer, default: 0},
"Expire the mute in `expires_in` seconds. Default 0 for infinity"
)
],
responses: %{
200 => status_response(),
400 => Operation.response("Error", "application/json", ApiError)
}
}
end
def unmute_conversation_operation do
%Operation{
tags: ["Status actions"],
summary: "Unmute conversation",
security: [%{"oAuth" => ["write:mutes"]}],
description:
"Start receiving notifications again for the thread that this status is part of",
operationId: "StatusController.unmute_conversation",
parameters: [id_param()],
responses: %{
200 => status_response(),
400 => Operation.response("Error", "application/json", ApiError)
}
}
end
def card_operation do
%Operation{
tags: ["Retrieve status information"],
deprecated: true,
summary: "Preview card",
description: "Deprecated in favor of card property inlined on Status entity",
operationId: "StatusController.card",
parameters: [id_param()],
security: [%{"oAuth" => ["read:statuses"]}],
responses: %{
200 =>
Operation.response("Card", "application/json", %Schema{
type: :object,
nullable: true,
properties: %{
type: %Schema{type: :string, enum: ["link", "photo", "video", "rich"]},
provider_name: %Schema{type: :string, nullable: true},
provider_url: %Schema{type: :string, format: :uri},
url: %Schema{type: :string, format: :uri},
image: %Schema{type: :string, nullable: true, format: :uri},
title: %Schema{type: :string},
description: %Schema{type: :string}
}
})
}
}
end
def favourited_by_operation do
%Operation{
tags: ["Retrieve status information"],
summary: "Favourited by",
description: "View who favourited a given status",
operationId: "StatusController.favourited_by",
security: [%{"oAuth" => ["read:accounts"]}],
parameters: [id_param()],
responses: %{
200 =>
Operation.response(
"Array of Accounts",
"application/json",
AccountOperation.array_of_accounts()
),
404 => Operation.response("Not Found", "application/json", ApiError)
}
}
end
def reblogged_by_operation do
%Operation{
tags: ["Retrieve status information"],
summary: "Reblogged by",
description: "View who reblogged a given status",
operationId: "StatusController.reblogged_by",
security: [%{"oAuth" => ["read:accounts"]}],
parameters: [id_param()],
responses: %{
200 =>
Operation.response(
"Array of Accounts",
"application/json",
AccountOperation.array_of_accounts()
),
404 => Operation.response("Not Found", "application/json", ApiError)
}
}
end
def context_operation do
%Operation{
tags: ["Retrieve status information"],
summary: "Parent and child statuses",
description: "View statuses above and below this status in the thread",
operationId: "StatusController.context",
security: [%{"oAuth" => ["read:statuses"]}],
parameters: [id_param()],
responses: %{
200 => Operation.response("Context", "application/json", context())
}
}
end
def translate_operation do
%Operation{
tags: ["Retrieve status information"],
summary: "Translate status",
description: "Translate status with an external API",
operationId: "StatusController.translate",
security: [%{"oAuth" => ["read:statuses"]}],
parameters: [id_param()],
requestBody:
request_body(
"Parameters",
%Schema{
type: :object,
properties: %{
lang: %Schema{
type: :string,
nullable: true,
description: "Translation target language."
}
}
},
required: false
),
responses: %{
200 => Operation.response("Translation", "application/json", translation()),
400 => Operation.response("Error", "application/json", ApiError),
404 => Operation.response("Error", "application/json", ApiError),
503 => Operation.response("Error", "application/json", ApiError)
}
}
end
def favourites_operation do
%Operation{
tags: ["Timelines"],
summary: "Favourited statuses",
description:
"Statuses the user has favourited. Please note that you have to use the link headers to paginate this. You can not build the query parameters yourself.",
operationId: "StatusController.favourites",
parameters: pagination_params(),
security: [%{"oAuth" => ["read:favourites"]}],
responses: %{
200 => Operation.response("Array of Statuses", "application/json", array_of_statuses())
}
}
end
def bookmarks_operation do
%Operation{
tags: ["Timelines"],
summary: "Bookmarked statuses",
description: "Statuses the user has bookmarked",
operationId: "StatusController.bookmarks",
parameters: [
Operation.parameter(
:folder_id,
:query,
FlakeID.schema(),
"If provided, only display bookmarks from given folder"
)
| pagination_params()
],
security: [%{"oAuth" => ["read:bookmarks"]}],
responses: %{
200 => Operation.response("Array of Statuses", "application/json", array_of_statuses())
}
}
end
def show_history_operation do
%Operation{
tags: ["Retrieve status information"],
summary: "Status history",
description: "View history of a status",
operationId: "StatusController.show_history",
security: [%{"oAuth" => ["read:statuses"]}],
parameters: [
id_param()
],
responses: %{
200 => status_history_response(),
404 => Operation.response("Not Found", "application/json", ApiError)
}
}
end
def show_source_operation do
%Operation{
tags: ["Retrieve status information"],
summary: "Status source",
description: "View source of a status",
operationId: "StatusController.show_source",
security: [%{"oAuth" => ["read:statuses"]}],
parameters: [
id_param()
],
responses: %{
200 => status_source_response(),
404 => Operation.response("Not Found", "application/json", ApiError)
}
}
end
def update_operation do
%Operation{
tags: ["Status actions"],
summary: "Update status",
description: "Change the content of a status",
operationId: "StatusController.update",
security: [%{"oAuth" => ["write:statuses"]}],
parameters: [
id_param()
],
requestBody: request_body("Parameters", update_request(), required: true),
responses: %{
200 => status_response(),
403 => Operation.response("Forbidden", "application/json", ApiError),
404 => Operation.response("Not Found", "application/json", ApiError)
}
}
end
def quotes_operation do
%Operation{
tags: ["Retrieve status information"],
summary: "Quoted by",
description: "View quotes for a given status",
operationId: "StatusController.quotes",
parameters: [id_param() | pagination_params()],
security: [%{"oAuth" => ["read:statuses"]}],
responses: %{
200 =>
Operation.response(
"Array of Status",
"application/json",
array_of_statuses()
),
403 => Operation.response("Forbidden", "application/json", ApiError),
404 => Operation.response("Not Found", "application/json", ApiError)
}
}
end
def array_of_statuses do
%Schema{type: :array, items: Status, example: [Status.schema().example]}
end
defp create_request do
%Schema{
title: "StatusCreateRequest",
type: :object,
properties: %{
status: %Schema{
type: :string,
nullable: true,
description:
"Text content of the status. If `media_ids` is provided, this becomes optional. Attaching a `poll` is optional while `status` is provided."
},
media_ids: %Schema{
nullable: true,
type: :array,
items: %Schema{type: :string},
description: "Array of Attachment ids to be attached as media."
},
poll: poll_params(),
in_reply_to_id: %Schema{
nullable: true,
allOf: [FlakeID],
description: "ID of the status being replied to, if status is a reply"
},
sensitive: %Schema{
allOf: [BooleanLike],
nullable: true,
description: "Mark status and attached media as sensitive?"
},
spoiler_text: %Schema{
type: :string,
nullable: true,
description:
"Text to be shown as a warning or subject before the actual content. Statuses are generally collapsed behind this field."
},
scheduled_at: %Schema{
type: :string,
format: :"date-time",
nullable: true,
description:
"ISO 8601 Datetime at which to schedule a status. Providing this parameter will cause ScheduledStatus to be returned instead of Status. Must be at least 5 minutes in the future."
},
language: %Schema{
type: :string,
nullable: true,
description: "ISO 639 language code for this status."
},
+ visibility: %Schema{
+ nullable: true,
+ anyOf: [
+ VisibilityScope,
+ %Schema{type: :string, description: "`list:LIST_ID`", example: "LIST:123"}
+ ],
+ description:
+ "Visibility of the posted status. Besides standard MastoAPI values (`direct`, `private`, `unlisted` or `public`) it can be used to address a List by setting it to `list:LIST_ID`"
+ },
+ quoted_status_id: %Schema{
+ nullable: true,
+ allOf: [FlakeID],
+ description: "ID of the status being quoted, if any"
+ },
# Pleroma-specific properties:
preview: %Schema{
allOf: [BooleanLike],
nullable: true,
description:
"If set to `true` the post won't be actually posted, but the status entity would still be rendered back. This could be useful for previewing rich text/custom emoji, for example"
},
content_type: %Schema{
type: :string,
nullable: true,
description:
"The MIME type of the status, it is transformed into HTML by the backend. You can get the list of the supported MIME types with the nodeinfo endpoint."
},
to: %Schema{
type: :array,
nullable: true,
items: %Schema{type: :string},
description:
"A list of nicknames (like `lain@soykaf.club` or `lain` on the local server) that will be used to determine who is going to be addressed by this post. Using this will disable the implicit addressing by mentioned names in the `status` body, only the people in the `to` list will be addressed. The normal rules for for post visibility are not affected by this and will still apply"
},
- visibility: %Schema{
- nullable: true,
- anyOf: [
- VisibilityScope,
- %Schema{type: :string, description: "`list:LIST_ID`", example: "LIST:123"}
- ],
- description:
- "Visibility of the posted status. Besides standard MastoAPI values (`direct`, `private`, `unlisted` or `public`) it can be used to address a List by setting it to `list:LIST_ID`"
- },
expires_in: %Schema{
nullable: true,
type: :integer,
description:
"The number of seconds the posted activity should expire in. When a posted activity expires it will be deleted from the server, and a delete request for it will be federated. This needs to be longer than an hour."
},
in_reply_to_conversation_id: %Schema{
nullable: true,
type: :string,
description:
"Will reply to a given conversation, addressing only the people who are part of the recipient set of that conversation. Sets the visibility to `direct`."
},
quote_id: %Schema{
nullable: true,
allOf: [FlakeID],
- description: "ID of the status being quoted, if any"
+ description: "Deprecated in favor of `quoted_status_id`",
+ deprecated: true
}
},
example: %{
"status" => "What time is it?",
"sensitive" => "false",
"poll" => %{
"options" => ["Cofe", "Adventure"],
"expires_in" => 420
}
}
}
end
defp update_request do
%Schema{
title: "StatusUpdateRequest",
type: :object,
properties: %{
status: %Schema{
type: :string,
nullable: true,
description:
"Text content of the status. If `media_ids` is provided, this becomes optional. Attaching a `poll` is optional while `status` is provided."
},
media_ids: %Schema{
nullable: true,
type: :array,
items: %Schema{type: :string},
description: "Array of Attachment ids to be attached as media."
},
poll: poll_params(),
sensitive: %Schema{
allOf: [BooleanLike],
nullable: true,
description: "Mark status and attached media as sensitive?"
},
spoiler_text: %Schema{
type: :string,
nullable: true,
description:
"Text to be shown as a warning or subject before the actual content. Statuses are generally collapsed behind this field."
},
content_type: %Schema{
type: :string,
nullable: true,
description:
"The MIME type of the status, it is transformed into HTML by the backend. You can get the list of the supported MIME types with the nodeinfo endpoint."
},
to: %Schema{
type: :array,
nullable: true,
items: %Schema{type: :string},
description:
"A list of nicknames (like `lain@soykaf.club` or `lain` on the local server) that will be used to determine who is going to be addressed by this post. Using this will disable the implicit addressing by mentioned names in the `status` body, only the people in the `to` list will be addressed. The normal rules for for post visibility are not affected by this and will still apply"
}
},
example: %{
"status" => "What time is it?",
"sensitive" => "false",
"poll" => %{
"options" => ["Cofe", "Adventure"],
"expires_in" => 420
}
}
}
end
def poll_params do
%Schema{
nullable: true,
type: :object,
required: [:options, :expires_in],
properties: %{
options: %Schema{
type: :array,
items: %Schema{type: :string},
description: "Array of possible answers. Must be provided with `poll[expires_in]`."
},
expires_in: %Schema{
type: :integer,
nullable: true,
description:
"Duration the poll should be open, in seconds. Must be provided with `poll[options]`"
},
multiple: %Schema{
allOf: [BooleanLike],
nullable: true,
description: "Allow multiple choices?"
},
hide_totals: %Schema{
allOf: [BooleanLike],
nullable: true,
description: "Hide vote counts until the poll ends?"
}
}
}
end
def id_param do
Operation.parameter(:id, :path, FlakeID.schema(), "Status ID",
example: "9umDrYheeY451cQnEe",
required: true
)
end
defp status_response do
Operation.response("Status", "application/json", Status)
end
defp status_history_response do
Operation.response(
"Status History",
"application/json",
%Schema{
title: "Status history",
description: "Response schema for history of a status",
type: :array,
items: %Schema{
type: :object,
properties: %{
account: %Schema{
allOf: [Account],
description: "The account that authored this status"
},
content: %Schema{
type: :string,
format: :html,
description: "HTML-encoded status content"
},
sensitive: %Schema{
type: :boolean,
description: "Is this status marked as sensitive content?"
},
spoiler_text: %Schema{
type: :string,
description:
"Subject or summary line, below which status content is collapsed until expanded"
},
created_at: %Schema{
type: :string,
format: "date-time",
description: "The date when this status was created"
},
media_attachments: %Schema{
type: :array,
items: Attachment,
description: "Media that is attached to this status"
},
emojis: %Schema{
type: :array,
items: Emoji,
description: "Custom emoji to be used when rendering status content"
},
poll: %Schema{
allOf: [Poll],
nullable: true,
description: "The poll attached to the status"
}
}
}
}
)
end
defp status_source_response do
Operation.response(
"Status Source",
"application/json",
%Schema{
type: :object,
properties: %{
id: FlakeID,
text: %Schema{
type: :string,
description: "Raw source of status content"
},
spoiler_text: %Schema{
type: :string,
description:
"Subject or summary line, below which status content is collapsed until expanded"
},
content_type: %Schema{
type: :string,
description: "The content type of the source"
}
}
}
)
end
defp context do
%Schema{
title: "StatusContext",
description:
"Represents the tree around a given status. Used for reconstructing threads of statuses.",
type: :object,
required: [:ancestors, :descendants],
properties: %{
ancestors: array_of_statuses(),
descendants: array_of_statuses()
},
example: %{
"ancestors" => [Status.schema().example],
"descendants" => [Status.schema().example]
}
}
end
defp translation do
%Schema{
title: "StatusTranslation",
description: "Represents status translation with related information.",
type: :object,
required: [:content, :detected_source_language, :provider],
properties: %{
content: %Schema{
type: :string,
description: "Translated status content"
},
detected_source_language: %Schema{
type: :string,
description: "Detected source language"
},
provider: %Schema{
type: :string,
description: "Translation provider service name"
}
},
example: %{
"content" => "Software für die nächste Generation der sozialen Medien.",
"detected_source_language" => "en",
"provider" => "Deepl"
}
}
end
end
diff --git a/lib/pleroma/web/common_api/activity_draft.ex b/lib/pleroma/web/common_api/activity_draft.ex
index f60ed8b02..8c7e42f3c 100644
--- a/lib/pleroma/web/common_api/activity_draft.ex
+++ b/lib/pleroma/web/common_api/activity_draft.ex
@@ -1,352 +1,356 @@
# Pleroma: A lightweight social networking server
# Copyright © 2017-2022 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Web.CommonAPI.ActivityDraft do
alias Pleroma.Activity
alias Pleroma.Conversation.Participation
alias Pleroma.Language.LanguageDetector
alias Pleroma.Object
alias Pleroma.Web.ActivityPub.Builder
alias Pleroma.Web.ActivityPub.Visibility
alias Pleroma.Web.CommonAPI
alias Pleroma.Web.CommonAPI.Utils
import Pleroma.EctoType.ActivityPub.ObjectValidators.LanguageCode,
only: [good_locale_code?: 1]
import Pleroma.Web.Gettext
import Pleroma.Web.Utils.Guards, only: [not_empty_string: 1]
@type t :: %__MODULE__{}
defstruct valid?: true,
errors: [],
user: nil,
params: %{},
status: nil,
summary: nil,
full_payload: nil,
attachments: [],
in_reply_to: nil,
in_reply_to_conversation: nil,
quote_post: nil,
visibility: nil,
expires_at: nil,
extra: nil,
emoji: %{},
content_html: nil,
mentions: [],
tags: [],
to: [],
cc: [],
context: nil,
sensitive: false,
language: nil,
object: nil,
preview?: false,
changes: %{}
def new(user, params) do
%__MODULE__{user: user}
|> put_params(params)
end
def create(user, params) do
user
|> new(params)
|> status()
|> summary()
|> with_valid(&attachments/1)
|> full_payload()
|> expires_at()
|> poll()
|> with_valid(&in_reply_to/1)
|> with_valid(&in_reply_to_conversation/1)
|> with_valid(&quote_post/1)
|> with_valid(&visibility/1)
|> with_valid(&quoting_visibility/1)
|> content()
|> with_valid(&to_and_cc/1)
|> with_valid(&context/1)
|> with_valid(&language/1)
|> sensitive()
|> with_valid(&object/1)
|> preview?()
|> with_valid(&changes/1)
|> validate()
end
def listen(user, params) do
user
|> new(params)
|> visibility()
|> to_and_cc()
|> context()
|> listen_object()
|> with_valid(&changes/1)
|> validate()
end
defp listen_object(draft) do
object =
draft.params
|> Map.take([:album, :artist, :title, :length])
|> Map.put(:externalLink, Map.get(draft.params, :external_link))
|> Map.new(fn {key, value} -> {to_string(key), value} end)
|> Map.put("type", "Audio")
|> Map.put("to", draft.to)
|> Map.put("cc", draft.cc)
|> Map.put("actor", draft.user.ap_id)
%__MODULE__{draft | object: object}
end
defp put_params(draft, params) do
params = Map.put_new(params, :in_reply_to_status_id, params[:in_reply_to_id])
%__MODULE__{draft | params: params}
end
defp status(%{params: %{status: status}} = draft) do
%__MODULE__{draft | status: String.trim(status)}
end
defp summary(%{params: params} = draft) do
%__MODULE__{draft | summary: Map.get(params, :spoiler_text, "")}
end
defp full_payload(%{status: status, summary: summary} = draft) do
full_payload = String.trim(status <> summary)
case Utils.validate_character_limit(full_payload, draft.attachments) do
:ok -> %__MODULE__{draft | full_payload: full_payload}
{:error, message} -> add_error(draft, message)
end
end
defp attachments(%{params: params} = draft) do
attachments = Utils.attachments_from_ids(params, draft.user)
draft = %__MODULE__{draft | attachments: attachments}
case Utils.validate_attachments_count(attachments) do
:ok -> draft
{:error, message} -> add_error(draft, message)
end
end
defp in_reply_to(%{params: %{in_reply_to_status_id: ""}} = draft), do: draft
defp in_reply_to(%{params: %{in_reply_to_status_id: :deleted}} = draft) do
add_error(draft, dgettext("errors", "Cannot reply to a deleted status"))
end
defp in_reply_to(%{params: %{in_reply_to_status_id: id} = params} = draft) when is_binary(id) do
activity = Activity.get_by_id(id)
params =
if is_nil(activity) do
# Deleted activities are returned as nil
Map.put(params, :in_reply_to_status_id, :deleted)
else
Map.put(params, :in_reply_to_status_id, activity)
end
in_reply_to(%{draft | params: params})
end
defp in_reply_to(%{params: %{in_reply_to_status_id: %Activity{} = in_reply_to}} = draft) do
%__MODULE__{draft | in_reply_to: in_reply_to}
end
defp in_reply_to(draft), do: draft
- defp quote_post(%{params: %{quote_id: id}} = draft) when not_empty_string(id) do
+ defp quote_post(%{params: %{quoted_status_id: id}} = draft) when not_empty_string(id) do
case Activity.get_by_id_with_object(id) do
%Activity{} = activity ->
%__MODULE__{draft | quote_post: activity}
_ ->
draft
end
end
+ defp quote_post(%{params: %{quote_id: id}} = draft) when not_empty_string(id) do
+ quote_post(%{draft | params: Map.put(draft.params, :quoted_status_id, id)})
+ end
+
defp quote_post(draft), do: draft
defp in_reply_to_conversation(draft) do
in_reply_to_conversation = Participation.get(draft.params[:in_reply_to_conversation_id])
%__MODULE__{draft | in_reply_to_conversation: in_reply_to_conversation}
end
defp visibility(%{params: params} = draft) do
case CommonAPI.get_visibility(params, draft.in_reply_to, draft.in_reply_to_conversation) do
{visibility, "direct"} when visibility != "direct" ->
add_error(draft, dgettext("errors", "The message visibility must be direct"))
{visibility, _} ->
%__MODULE__{draft | visibility: visibility}
end
end
defp can_quote?(_draft, _object, visibility) when visibility in ~w(public unlisted local) do
true
end
defp can_quote?(draft, object, "private") do
draft.user.ap_id == object.data["actor"]
end
defp can_quote?(_, _, _) do
false
end
defp quoting_visibility(%{quote_post: %Activity{}} = draft) do
with %Object{} = object <- Object.normalize(draft.quote_post, fetch: false),
true <- can_quote?(draft, object, Visibility.get_visibility(object)) do
draft
else
_ -> add_error(draft, dgettext("errors", "Cannot quote private message"))
end
end
defp quoting_visibility(draft), do: draft
defp expires_at(draft) do
case CommonAPI.check_expiry_date(draft.params[:expires_in]) do
{:ok, expires_at} -> %__MODULE__{draft | expires_at: expires_at}
{:error, message} -> add_error(draft, message)
end
end
defp poll(draft) do
case Utils.make_poll_data(draft.params) do
{:ok, {poll, poll_emoji}} ->
%__MODULE__{draft | extra: poll, emoji: Map.merge(draft.emoji, poll_emoji)}
{:error, message} ->
add_error(draft, message)
end
end
defp content(%{mentions: mentions} = draft) do
{content_html, mentioned_users, tags} = Utils.make_content_html(draft)
mentioned_ap_ids =
Enum.map(mentioned_users, fn {_, mentioned_user} -> mentioned_user.ap_id end)
mentions =
mentions
|> Kernel.++(mentioned_ap_ids)
|> Utils.get_addressed_users(draft.params[:to])
%__MODULE__{draft | content_html: content_html, mentions: mentions, tags: tags}
end
defp to_and_cc(draft) do
{to, cc} = Utils.get_to_and_cc(draft)
%__MODULE__{draft | to: to, cc: cc}
end
defp context(draft) do
context = Utils.make_context(draft.in_reply_to, draft.in_reply_to_conversation)
%__MODULE__{draft | context: context}
end
defp sensitive(draft) do
sensitive = draft.params[:sensitive]
%__MODULE__{draft | sensitive: sensitive}
end
defp language(draft) do
language =
with language <- draft.params[:language],
true <- good_locale_code?(language) do
language
else
_ -> LanguageDetector.detect(draft.content_html <> " " <> draft.summary)
end
%__MODULE__{draft | language: language}
end
defp object(draft) do
emoji = Map.merge(Pleroma.Emoji.Formatter.get_emoji_map(draft.full_payload), draft.emoji)
# Sometimes people create posts with subject containing emoji,
# since subjects are usually copied this will result in a broken
# subject when someone replies from an instance that does not have
# the emoji or has it under different shortcode. This is an attempt
# to mitigate this by copying emoji from inReplyTo if they are present
# in the subject.
summary_emoji =
with %Activity{} <- draft.in_reply_to,
%Object{data: %{"tag" => [_ | _] = tag}} <- Object.normalize(draft.in_reply_to) do
Enum.reduce(tag, %{}, fn
%{"type" => "Emoji", "name" => name, "icon" => %{"url" => url}}, acc ->
if String.contains?(draft.summary, name) do
Map.put(acc, name, url)
else
acc
end
_, acc ->
acc
end)
else
_ -> %{}
end
emoji = Map.merge(emoji, summary_emoji)
{:ok, note_data, _meta} = Builder.note(draft)
object =
note_data
|> Map.put("emoji", emoji)
|> Map.put("source", %{
"content" => draft.status,
"mediaType" => Utils.get_content_type(draft.params[:content_type])
})
|> Map.put("generator", draft.params[:generator])
|> Map.put("language", draft.language)
%__MODULE__{draft | object: object}
end
defp preview?(draft) do
preview? = Pleroma.Web.Utils.Params.truthy_param?(draft.params[:preview])
%__MODULE__{draft | preview?: preview?}
end
defp changes(draft) do
direct? = draft.visibility == "direct"
additional = %{"cc" => draft.cc, "directMessage" => direct?}
additional =
case draft.expires_at do
%DateTime{} = expires_at -> Map.put(additional, "expires_at", expires_at)
_ -> additional
end
changes =
%{
to: draft.to,
actor: draft.user,
context: draft.context,
object: draft.object,
additional: additional
}
|> Utils.maybe_add_list_data(draft.user, draft.visibility)
%__MODULE__{draft | changes: changes}
end
defp with_valid(%{valid?: true} = draft, func), do: func.(draft)
defp with_valid(draft, _func), do: draft
defp add_error(draft, message) do
%__MODULE__{draft | valid?: false, errors: [message | draft.errors]}
end
defp validate(%{valid?: true} = draft), do: {:ok, draft}
defp validate(%{errors: [message | _]}), do: {:error, message}
end
diff --git a/test/pleroma/web/activity_pub/activity_pub_test.exs b/test/pleroma/web/activity_pub/activity_pub_test.exs
index c10735d45..73f53db56 100644
--- a/test/pleroma/web/activity_pub/activity_pub_test.exs
+++ b/test/pleroma/web/activity_pub/activity_pub_test.exs
@@ -1,2791 +1,2791 @@
# Pleroma: A lightweight social networking server
# Copyright © 2017-2022 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Web.ActivityPub.ActivityPubTest do
use Pleroma.DataCase
use Oban.Testing, repo: Pleroma.Repo
alias Pleroma.Activity
alias Pleroma.Builders.ActivityBuilder
alias Pleroma.Config
alias Pleroma.Notification
alias Pleroma.Object
alias Pleroma.UnstubbedConfigMock, as: ConfigMock
alias Pleroma.User
alias Pleroma.Web.ActivityPub.ActivityPub
alias Pleroma.Web.ActivityPub.Utils
alias Pleroma.Web.AdminAPI.AccountView
alias Pleroma.Web.CommonAPI
import ExUnit.CaptureLog
import Mock
import Mox
import Pleroma.Factory
import Tesla.Mock
setup do
mock(fn env -> apply(HttpRequestMock, :request, [env]) end)
ConfigMock
|> stub_with(Pleroma.Test.StaticConfig)
:ok
end
setup do: clear_config([:instance, :federating])
describe "streaming out participations" do
test "it streams them out" do
user = insert(:user)
{:ok, activity} = CommonAPI.post(user, %{status: ".", visibility: "direct"})
{:ok, conversation} = Pleroma.Conversation.create_or_bump_for(activity)
participations =
conversation.participations
|> Repo.preload(:user)
with_mock Pleroma.Web.Streamer,
stream: fn _, _ -> nil end do
ActivityPub.stream_out_participations(conversation.participations)
assert called(Pleroma.Web.Streamer.stream("participation", participations))
end
end
test "streams them out on activity creation" do
user_one = insert(:user)
user_two = insert(:user)
with_mock Pleroma.Web.Streamer,
stream: fn _, _ -> nil end do
{:ok, activity} =
CommonAPI.post(user_one, %{
status: "@#{user_two.nickname}",
visibility: "direct"
})
conversation =
activity.data["context"]
|> Pleroma.Conversation.get_for_ap_id()
|> Repo.preload(participations: :user)
assert called(Pleroma.Web.Streamer.stream("participation", conversation.participations))
end
end
end
describe "fetching restricted by visibility" do
test "it restricts by the appropriate visibility" do
user = insert(:user)
{:ok, public_activity} = CommonAPI.post(user, %{status: ".", visibility: "public"})
{:ok, direct_activity} = CommonAPI.post(user, %{status: ".", visibility: "direct"})
{:ok, unlisted_activity} = CommonAPI.post(user, %{status: ".", visibility: "unlisted"})
{:ok, private_activity} = CommonAPI.post(user, %{status: ".", visibility: "private"})
activities = ActivityPub.fetch_activities([], %{visibility: "direct", actor_id: user.ap_id})
assert activities == [direct_activity]
activities =
ActivityPub.fetch_activities([], %{visibility: "unlisted", actor_id: user.ap_id})
assert activities == [unlisted_activity]
activities =
ActivityPub.fetch_activities([], %{visibility: "private", actor_id: user.ap_id})
assert activities == [private_activity]
activities = ActivityPub.fetch_activities([], %{visibility: "public", actor_id: user.ap_id})
assert activities == [public_activity]
activities =
ActivityPub.fetch_activities([], %{
visibility: ~w[private public],
actor_id: user.ap_id
})
assert activities == [public_activity, private_activity]
end
end
describe "fetching excluded by visibility" do
test "it excludes by the appropriate visibility" do
user = insert(:user)
{:ok, public_activity} = CommonAPI.post(user, %{status: ".", visibility: "public"})
{:ok, direct_activity} = CommonAPI.post(user, %{status: ".", visibility: "direct"})
{:ok, unlisted_activity} = CommonAPI.post(user, %{status: ".", visibility: "unlisted"})
{:ok, private_activity} = CommonAPI.post(user, %{status: ".", visibility: "private"})
activities =
ActivityPub.fetch_activities([], %{
exclude_visibilities: "direct",
actor_id: user.ap_id
})
assert public_activity in activities
assert unlisted_activity in activities
assert private_activity in activities
refute direct_activity in activities
activities =
ActivityPub.fetch_activities([], %{
exclude_visibilities: "unlisted",
actor_id: user.ap_id
})
assert public_activity in activities
refute unlisted_activity in activities
assert private_activity in activities
assert direct_activity in activities
activities =
ActivityPub.fetch_activities([], %{
exclude_visibilities: "private",
actor_id: user.ap_id
})
assert public_activity in activities
assert unlisted_activity in activities
refute private_activity in activities
assert direct_activity in activities
activities =
ActivityPub.fetch_activities([], %{
exclude_visibilities: "public",
actor_id: user.ap_id
})
refute public_activity in activities
assert unlisted_activity in activities
assert private_activity in activities
assert direct_activity in activities
end
end
describe "building a user from his ap id" do
test "it returns a user" do
user_id = "http://mastodon.example.org/users/admin"
{:ok, user} = ActivityPub.make_user_from_ap_id(user_id)
assert user.ap_id == user_id
assert user.nickname == "admin@mastodon.example.org"
assert user.follower_address == "http://mastodon.example.org/users/admin/followers"
end
test "it returns a user that is invisible" do
user_id = "http://mastodon.example.org/users/relay"
{:ok, user} = ActivityPub.make_user_from_ap_id(user_id)
assert User.invisible?(user)
end
test "it returns a user that accepts chat messages" do
user_id = "http://mastodon.example.org/users/admin"
{:ok, user} = ActivityPub.make_user_from_ap_id(user_id)
assert user.accepts_chat_messages
end
test "works for guppe actors" do
user_id = "https://gup.pe/u/bernie2020"
Tesla.Mock.mock(fn
%{method: :get, url: ^user_id} ->
%Tesla.Env{
status: 200,
body: File.read!("test/fixtures/guppe-actor.json"),
headers: [{"content-type", "application/activity+json"}]
}
end)
{:ok, user} = ActivityPub.make_user_from_ap_id(user_id)
assert user.name == "Bernie2020 group"
assert user.actor_type == "Group"
end
test "works for bridgy actors" do
user_id = "https://fed.brid.gy/jk.nipponalba.scot"
Tesla.Mock.mock(fn
%{method: :get, url: ^user_id} ->
%Tesla.Env{
status: 200,
body: File.read!("test/fixtures/bridgy/actor.json"),
headers: [{"content-type", "application/activity+json"}]
}
end)
{:ok, user} = ActivityPub.make_user_from_ap_id(user_id)
assert user.actor_type == "Person"
assert user.avatar == %{
"type" => "Image",
"url" => [%{"href" => "https://jk.nipponalba.scot/images/profile.jpg"}],
"name" => "profile picture"
}
assert user.banner == %{
"type" => "Image",
"url" => [%{"href" => "https://jk.nipponalba.scot/images/profile.jpg"}],
"name" => "profile picture"
}
end
test "fetches user featured collection" do
ap_id = "https://example.com/users/lain"
featured_url = "https://example.com/users/lain/collections/featured"
user_data =
"test/fixtures/users_mock/user.json"
|> File.read!()
|> String.replace("{{nickname}}", "lain")
|> Jason.decode!()
|> Map.put("featured", featured_url)
|> Jason.encode!()
object_id = Ecto.UUID.generate()
featured_data =
"test/fixtures/mastodon/collections/featured.json"
|> File.read!()
|> String.replace("{{domain}}", "example.com")
|> String.replace("{{nickname}}", "lain")
|> String.replace("{{object_id}}", object_id)
object_url = "https://example.com/objects/#{object_id}"
object_data =
"test/fixtures/statuses/note.json"
|> File.read!()
|> String.replace("{{object_id}}", object_id)
|> String.replace("{{nickname}}", "lain")
Tesla.Mock.mock(fn
%{
method: :get,
url: ^ap_id
} ->
%Tesla.Env{
status: 200,
body: user_data,
headers: [{"content-type", "application/activity+json"}]
}
%{
method: :get,
url: ^featured_url
} ->
%Tesla.Env{
status: 200,
body: featured_data,
headers: [{"content-type", "application/activity+json"}]
}
%{
method: :get,
url: ^object_url
} ->
%Tesla.Env{
status: 200,
body: object_data,
headers: [{"content-type", "application/activity+json"}]
}
end)
{:ok, user} = ActivityPub.make_user_from_ap_id(ap_id)
assert_enqueued(
worker: Pleroma.Workers.RemoteFetcherWorker,
args: %{
"op" => "fetch_remote",
"id" => object_url,
"depth" => 1
}
)
# wait for oban
Pleroma.Tests.ObanHelpers.perform_all()
assert user.featured_address == featured_url
assert Map.has_key?(user.pinned_objects, object_url)
in_db = Pleroma.User.get_by_ap_id(ap_id)
assert in_db.featured_address == featured_url
assert Map.has_key?(user.pinned_objects, object_url)
assert %{data: %{"id" => ^object_url}} = Object.get_by_ap_id(object_url)
end
test "fetches user featured collection without embedded object" do
ap_id = "https://example.com/users/lain"
featured_url = "https://example.com/users/lain/collections/featured"
user_data =
"test/fixtures/users_mock/user.json"
|> File.read!()
|> String.replace("{{nickname}}", "lain")
|> Jason.decode!()
|> Map.put("featured", featured_url)
|> Jason.encode!()
object_id = Ecto.UUID.generate()
featured_data =
"test/fixtures/mastodon/collections/external_featured.json"
|> File.read!()
|> String.replace("{{domain}}", "example.com")
|> String.replace("{{nickname}}", "lain")
|> String.replace("{{object_id}}", object_id)
object_url = "https://example.com/objects/#{object_id}"
object_data =
"test/fixtures/statuses/note.json"
|> File.read!()
|> String.replace("{{object_id}}", object_id)
|> String.replace("{{nickname}}", "lain")
Tesla.Mock.mock(fn
%{
method: :get,
url: ^ap_id
} ->
%Tesla.Env{
status: 200,
body: user_data,
headers: [{"content-type", "application/activity+json"}]
}
%{
method: :get,
url: ^featured_url
} ->
%Tesla.Env{
status: 200,
body: featured_data,
headers: [{"content-type", "application/activity+json"}]
}
%{
method: :get,
url: ^object_url
} ->
%Tesla.Env{
status: 200,
body: object_data,
headers: [{"content-type", "application/activity+json"}]
}
end)
{:ok, user} = ActivityPub.make_user_from_ap_id(ap_id)
assert_enqueued(
worker: Pleroma.Workers.RemoteFetcherWorker,
args: %{
"op" => "fetch_remote",
"id" => object_url,
"depth" => 1
}
)
# wait for oban
Pleroma.Tests.ObanHelpers.perform_all()
assert user.featured_address == featured_url
assert Map.has_key?(user.pinned_objects, object_url)
in_db = Pleroma.User.get_by_ap_id(ap_id)
assert in_db.featured_address == featured_url
assert Map.has_key?(user.pinned_objects, object_url)
assert %{data: %{"id" => ^object_url}} = Object.get_by_ap_id(object_url)
end
test "fetches user birthday information from misskey" do
user_id = "https://misskey.io/@mkljczk"
Tesla.Mock.mock(fn
%{
method: :get,
url: ^user_id
} ->
%Tesla.Env{
status: 200,
body: File.read!("test/fixtures/birthdays/misskey-user.json"),
headers: [{"content-type", "application/activity+json"}]
}
end)
{:ok, user} = ActivityPub.make_user_from_ap_id(user_id)
assert user.birthday == ~D[2001-02-12]
end
test "fetches avatar description" do
user_id = "https://example.com/users/marcin"
user_data =
"test/fixtures/users_mock/user.json"
|> File.read!()
|> String.replace("{{nickname}}", "marcin")
|> Jason.decode!()
|> Map.delete("featured")
|> Map.update("icon", %{}, fn image -> Map.put(image, "name", "image description") end)
|> Jason.encode!()
Tesla.Mock.mock(fn
%{
method: :get,
url: ^user_id
} ->
%Tesla.Env{
status: 200,
body: user_data,
headers: [{"content-type", "application/activity+json"}]
}
end)
{:ok, user} = ActivityPub.make_user_from_ap_id(user_id)
assert user.avatar["name"] == "image description"
end
end
test "works with avatar/banner href as list" do
user_id = "https://queef.in/cute_cat"
user_data =
"test/fixtures/users_mock/href_as_array.json"
|> File.read!()
|> Jason.decode!()
|> Map.delete("featured")
|> Jason.encode!()
Tesla.Mock.mock(fn
%{
method: :get,
url: ^user_id
} ->
%Tesla.Env{
status: 200,
body: user_data,
headers: [{"content-type", "application/activity+json"}]
}
end)
{:ok, user} = ActivityPub.make_user_from_ap_id(user_id)
assert length(user.avatar["url"]) == 1
assert length(user.banner["url"]) == 1
assert user.avatar["url"] |> List.first() |> Map.fetch!("href") ==
"https://queef.in/storage/profile.webp"
assert user.banner["url"] |> List.first() |> Map.fetch!("href") ==
"https://queef.in/storage/banner.gif"
end
test "it fetches the appropriate tag-restricted posts" do
user = insert(:user)
{:ok, status_one} = CommonAPI.post(user, %{status: ". #TEST"})
{:ok, status_two} = CommonAPI.post(user, %{status: ". #essais"})
{:ok, status_three} = CommonAPI.post(user, %{status: ". #test #Reject"})
{:ok, status_four} = CommonAPI.post(user, %{status: ". #Any1 #any2"})
{:ok, status_five} = CommonAPI.post(user, %{status: ". #Any2 #any1"})
for hashtag_timeline_strategy <- [:enabled, :disabled] do
clear_config([:features, :improved_hashtag_timeline], hashtag_timeline_strategy)
fetch_one = ActivityPub.fetch_activities([], %{type: "Create", tag: "test"})
fetch_two = ActivityPub.fetch_activities([], %{type: "Create", tag: ["TEST", "essais"]})
fetch_three =
ActivityPub.fetch_activities([], %{
type: "Create",
tag: ["test", "Essais"],
tag_reject: ["reject"]
})
fetch_four =
ActivityPub.fetch_activities([], %{
type: "Create",
tag: ["test"],
tag_all: ["test", "REJECT"]
})
# Testing that deduplication (if needed) is done on DB (not Ecto) level; :limit is important
fetch_five =
ActivityPub.fetch_activities([], %{
type: "Create",
tag: ["ANY1", "any2"],
limit: 2
})
fetch_six =
ActivityPub.fetch_activities([], %{
type: "Create",
tag: ["any1", "Any2"],
tag_all: [],
tag_reject: []
})
# Regression test: passing empty lists as filter options shouldn't affect the results
assert fetch_five == fetch_six
[fetch_one, fetch_two, fetch_three, fetch_four, fetch_five] =
Enum.map([fetch_one, fetch_two, fetch_three, fetch_four, fetch_five], fn statuses ->
Enum.map(statuses, fn s -> Repo.preload(s, object: :hashtags) end)
end)
assert fetch_one == [status_one, status_three]
assert fetch_two == [status_one, status_two, status_three]
assert fetch_three == [status_one, status_two]
assert fetch_four == [status_three]
assert fetch_five == [status_four, status_five]
end
end
describe "insertion" do
test "drops activities beyond a certain limit" do
limit = Config.get([:instance, :remote_limit])
random_text =
:crypto.strong_rand_bytes(limit + 1)
|> Base.encode64()
|> binary_part(0, limit + 1)
data = %{
"ok" => true,
"object" => %{
"content" => random_text
}
}
assert {:error, :remote_limit} = ActivityPub.insert(data)
end
test "doesn't drop activities with content being null" do
user = insert(:user)
data = %{
"actor" => user.ap_id,
"to" => [],
"object" => %{
"actor" => user.ap_id,
"to" => [],
"type" => "Note",
"content" => nil
}
}
assert {:ok, _} = ActivityPub.insert(data)
end
test "returns the activity if one with the same id is already in" do
activity = insert(:note_activity)
{:ok, new_activity} = ActivityPub.insert(activity.data)
assert activity.id == new_activity.id
end
test "inserts a given map into the activity database, giving it an id if it has none." do
user = insert(:user)
data = %{
"actor" => user.ap_id,
"to" => [],
"object" => %{
"actor" => user.ap_id,
"to" => [],
"type" => "Note",
"content" => "hey"
}
}
{:ok, %Activity{} = activity} = ActivityPub.insert(data)
assert activity.data["ok"] == data["ok"]
assert is_binary(activity.data["id"])
given_id = "bla"
data = %{
"id" => given_id,
"actor" => user.ap_id,
"to" => [],
"context" => "blabla",
"object" => %{
"actor" => user.ap_id,
"to" => [],
"type" => "Note",
"content" => "hey"
}
}
{:ok, %Activity{} = activity} = ActivityPub.insert(data)
assert activity.data["ok"] == data["ok"]
assert activity.data["id"] == given_id
assert activity.data["context"] == "blabla"
end
test "adds a context when none is there" do
user = insert(:user)
data = %{
"actor" => user.ap_id,
"to" => [],
"object" => %{
"actor" => user.ap_id,
"to" => [],
"type" => "Note",
"content" => "hey"
}
}
{:ok, %Activity{} = activity} = ActivityPub.insert(data)
object = Pleroma.Object.normalize(activity, fetch: false)
assert is_binary(activity.data["context"])
assert is_binary(object.data["context"])
end
test "adds an id to a given object if it lacks one and is a note and inserts it to the object database" do
user = insert(:user)
data = %{
"actor" => user.ap_id,
"to" => [],
"object" => %{
"actor" => user.ap_id,
"to" => [],
"type" => "Note",
"content" => "hey"
}
}
{:ok, %Activity{} = activity} = ActivityPub.insert(data)
assert object = Object.normalize(activity, fetch: false)
assert is_binary(object.data["id"])
end
end
describe "listen activities" do
test "does not increase user note count" do
user = insert(:user)
{:ok, activity} =
ActivityPub.listen(%{
to: ["https://www.w3.org/ns/activitystreams#Public"],
actor: user,
context: "",
object: %{
"actor" => user.ap_id,
"to" => ["https://www.w3.org/ns/activitystreams#Public"],
"artist" => "lain",
"title" => "lain radio episode 1",
"length" => 180_000,
"type" => "Audio"
}
})
assert activity.actor == user.ap_id
user = User.get_cached_by_id(user.id)
assert user.note_count == 0
end
test "can be fetched into a timeline" do
_listen_activity_1 = insert(:listen)
_listen_activity_2 = insert(:listen)
_listen_activity_3 = insert(:listen)
timeline = ActivityPub.fetch_activities([], %{type: ["Listen"]})
assert length(timeline) == 3
end
end
describe "create activities" do
setup do
[user: insert(:user)]
end
test "it reverts create", %{user: user} do
with_mock(Utils, [:passthrough], maybe_federate: fn _ -> {:error, :reverted} end) do
assert {:error, :reverted} =
ActivityPub.create(%{
to: ["user1", "user2"],
actor: user,
context: "",
object: %{
"to" => ["user1", "user2"],
"type" => "Note",
"content" => "testing"
}
})
end
assert Repo.aggregate(Activity, :count, :id) == 0
assert Repo.aggregate(Object, :count, :id) == 0
end
test "creates activity if expiration is not configured and expires_at is not passed", %{
user: user
} do
clear_config([Pleroma.Workers.PurgeExpiredActivity, :enabled], false)
assert {:ok, _} =
ActivityPub.create(%{
to: ["user1", "user2"],
actor: user,
context: "",
object: %{
"to" => ["user1", "user2"],
"type" => "Note",
"content" => "testing"
}
})
end
test "rejects activity if expires_at present but expiration is not configured", %{user: user} do
clear_config([Pleroma.Workers.PurgeExpiredActivity, :enabled], false)
assert {:error, :expired_activities_disabled} =
ActivityPub.create(%{
to: ["user1", "user2"],
actor: user,
context: "",
object: %{
"to" => ["user1", "user2"],
"type" => "Note",
"content" => "testing"
},
additional: %{
"expires_at" => DateTime.utc_now()
}
})
assert Repo.aggregate(Activity, :count, :id) == 0
assert Repo.aggregate(Object, :count, :id) == 0
end
test "removes doubled 'to' recipients", %{user: user} do
{:ok, activity} =
ActivityPub.create(%{
to: ["user1", "user1", "user2"],
actor: user,
context: "",
object: %{
"to" => ["user1", "user1", "user2"],
"type" => "Note",
"content" => "testing"
}
})
assert activity.data["to"] == ["user1", "user2"]
assert activity.actor == user.ap_id
assert activity.recipients == ["user1", "user2", user.ap_id]
end
test "increases user note count only for public activities", %{user: user} do
{:ok, _} =
CommonAPI.post(User.get_cached_by_id(user.id), %{
status: "1",
visibility: "public"
})
{:ok, _} =
CommonAPI.post(User.get_cached_by_id(user.id), %{
status: "2",
visibility: "unlisted"
})
{:ok, _} =
CommonAPI.post(User.get_cached_by_id(user.id), %{
status: "2",
visibility: "private"
})
{:ok, _} =
CommonAPI.post(User.get_cached_by_id(user.id), %{
status: "3",
visibility: "direct"
})
user = User.get_cached_by_id(user.id)
assert user.note_count == 2
end
test "increases replies count", %{user: user} do
user2 = insert(:user)
{:ok, activity} = CommonAPI.post(user, %{status: "1", visibility: "public"})
ap_id = activity.data["id"]
reply_data = %{status: "1", in_reply_to_status_id: activity.id}
# public
{:ok, _} = CommonAPI.post(user2, Map.put(reply_data, :visibility, "public"))
assert %{data: _data, object: object} = Activity.get_by_ap_id_with_object(ap_id)
assert object.data["repliesCount"] == 1
# unlisted
{:ok, _} = CommonAPI.post(user2, Map.put(reply_data, :visibility, "unlisted"))
assert %{data: _data, object: object} = Activity.get_by_ap_id_with_object(ap_id)
assert object.data["repliesCount"] == 2
# private
{:ok, _} = CommonAPI.post(user2, Map.put(reply_data, :visibility, "private"))
assert %{data: _data, object: object} = Activity.get_by_ap_id_with_object(ap_id)
assert object.data["repliesCount"] == 2
# direct
{:ok, _} = CommonAPI.post(user2, Map.put(reply_data, :visibility, "direct"))
assert %{data: _data, object: object} = Activity.get_by_ap_id_with_object(ap_id)
assert object.data["repliesCount"] == 2
end
test "increases quotes count", %{user: user} do
user2 = insert(:user)
{:ok, activity} = CommonAPI.post(user, %{status: "1", visibility: "public"})
ap_id = activity.data["id"]
- quote_data = %{status: "1", quote_id: activity.id}
+ quote_data = %{status: "1", quoted_status_id: activity.id}
# public
{:ok, _} = CommonAPI.post(user2, Map.put(quote_data, :visibility, "public"))
assert %{data: _data, object: object} = Activity.get_by_ap_id_with_object(ap_id)
assert object.data["quotesCount"] == 1
# unlisted
{:ok, _} = CommonAPI.post(user2, Map.put(quote_data, :visibility, "unlisted"))
assert %{data: _data, object: object} = Activity.get_by_ap_id_with_object(ap_id)
assert object.data["quotesCount"] == 2
# private
{:ok, _} = CommonAPI.post(user2, Map.put(quote_data, :visibility, "private"))
assert %{data: _data, object: object} = Activity.get_by_ap_id_with_object(ap_id)
assert object.data["quotesCount"] == 2
# direct
{:ok, _} = CommonAPI.post(user2, Map.put(quote_data, :visibility, "direct"))
assert %{data: _data, object: object} = Activity.get_by_ap_id_with_object(ap_id)
assert object.data["quotesCount"] == 2
end
end
describe "fetch activities for recipients" do
test "retrieve the activities for certain recipients" do
{:ok, activity_one} = ActivityBuilder.insert(%{"to" => ["someone"]})
{:ok, activity_two} = ActivityBuilder.insert(%{"to" => ["someone_else"]})
{:ok, _activity_three} = ActivityBuilder.insert(%{"to" => ["noone"]})
activities = ActivityPub.fetch_activities(["someone", "someone_else"])
assert length(activities) == 2
assert activities == [activity_one, activity_two]
end
end
describe "fetch activities for followed hashtags" do
test "it should return public activities that reference a given hashtag" do
hashtag = insert(:hashtag, name: "tenshi")
user = insert(:user)
other_user = insert(:user)
{:ok, normally_visible} =
CommonAPI.post(other_user, %{status: "hello :)", visibility: "public"})
{:ok, public} = CommonAPI.post(user, %{status: "maji #tenshi", visibility: "public"})
{:ok, _unrelated} = CommonAPI.post(user, %{status: "dai #tensh", visibility: "public"})
{:ok, unlisted} = CommonAPI.post(user, %{status: "maji #tenshi", visibility: "unlisted"})
{:ok, _private} = CommonAPI.post(user, %{status: "maji #tenshi", visibility: "private"})
activities =
ActivityPub.fetch_activities([other_user.follower_address], %{
followed_hashtags: [hashtag.id]
})
assert length(activities) == 3
normal_id = normally_visible.id
public_id = public.id
unlisted_id = unlisted.id
assert [%{id: ^normal_id}, %{id: ^public_id}, %{id: ^unlisted_id}] = activities
end
end
describe "fetch activities in context" do
test "retrieves activities that have a given context" do
{:ok, activity} = ActivityBuilder.insert(%{"type" => "Create", "context" => "2hu"})
{:ok, activity_two} = ActivityBuilder.insert(%{"type" => "Create", "context" => "2hu"})
{:ok, _activity_three} = ActivityBuilder.insert(%{"type" => "Create", "context" => "3hu"})
{:ok, _activity_four} = ActivityBuilder.insert(%{"type" => "Announce", "context" => "2hu"})
activity_five = insert(:note_activity)
user = insert(:user)
{:ok, _user_relationship} = User.block(user, %{ap_id: activity_five.data["actor"]})
activities = ActivityPub.fetch_activities_for_context("2hu", %{blocking_user: user})
assert activities == [activity_two, activity]
end
test "doesn't return activities with filtered words" do
user = insert(:user)
user_two = insert(:user)
insert(:filter, user: user, phrase: "test", hide: true)
{:ok, %{id: id1, data: %{"context" => context}}} = CommonAPI.post(user, %{status: "1"})
{:ok, %{id: id2}} = CommonAPI.post(user_two, %{status: "2", in_reply_to_status_id: id1})
{:ok, %{id: id3} = user_activity} =
CommonAPI.post(user, %{status: "3 test?", in_reply_to_status_id: id2})
{:ok, %{id: id4} = filtered_activity} =
CommonAPI.post(user_two, %{status: "4 test!", in_reply_to_status_id: id3})
{:ok, _} = CommonAPI.post(user, %{status: "5", in_reply_to_status_id: id4})
activities =
context
|> ActivityPub.fetch_activities_for_context(%{user: user})
|> Enum.map(& &1.id)
assert length(activities) == 4
assert user_activity.id in activities
refute filtered_activity.id in activities
end
end
test "doesn't return blocked activities" do
activity_one = insert(:note_activity)
activity_two = insert(:note_activity)
activity_three = insert(:note_activity)
user = insert(:user)
booster = insert(:user)
{:ok, _user_relationship} = User.block(user, %{ap_id: activity_one.data["actor"]})
activities = ActivityPub.fetch_activities([], %{blocking_user: user, skip_preload: true})
assert Enum.member?(activities, activity_two)
assert Enum.member?(activities, activity_three)
refute Enum.member?(activities, activity_one)
{:ok, _user_block} = User.unblock(user, %{ap_id: activity_one.data["actor"]})
activities = ActivityPub.fetch_activities([], %{blocking_user: user, skip_preload: true})
assert Enum.member?(activities, activity_two)
assert Enum.member?(activities, activity_three)
assert Enum.member?(activities, activity_one)
{:ok, _user_relationship} = User.block(user, %{ap_id: activity_three.data["actor"]})
{:ok, %{data: %{"object" => id}}} = CommonAPI.repeat(activity_three.id, booster)
%Activity{} = boost_activity = Activity.get_create_by_object_ap_id(id)
activity_three = Activity.get_by_id(activity_three.id)
activities = ActivityPub.fetch_activities([], %{blocking_user: user, skip_preload: true})
assert Enum.member?(activities, activity_two)
refute Enum.member?(activities, activity_three)
refute Enum.member?(activities, boost_activity)
assert Enum.member?(activities, activity_one)
activities = ActivityPub.fetch_activities([], %{blocking_user: nil, skip_preload: true})
assert Enum.member?(activities, activity_two)
assert Enum.member?(activities, activity_three)
assert Enum.member?(activities, boost_activity)
assert Enum.member?(activities, activity_one)
end
test "doesn't return activities from deactivated users" do
_user = insert(:user)
deactivated = insert(:user)
active = insert(:user)
{:ok, activity_one} = CommonAPI.post(deactivated, %{status: "hey!"})
{:ok, activity_two} = CommonAPI.post(active, %{status: "yay!"})
{:ok, _updated_user} = User.set_activation(deactivated, false)
activities = ActivityPub.fetch_activities([], %{})
refute Enum.member?(activities, activity_one)
assert Enum.member?(activities, activity_two)
end
test "always see your own posts even when they address people you block" do
user = insert(:user)
blockee = insert(:user)
{:ok, _} = User.block(user, blockee)
{:ok, activity} = CommonAPI.post(user, %{status: "hey! @#{blockee.nickname}"})
activities = ActivityPub.fetch_activities([], %{blocking_user: user})
assert Enum.member?(activities, activity)
end
test "doesn't return transitive interactions concerning blocked users" do
blocker = insert(:user)
blockee = insert(:user)
friend = insert(:user)
{:ok, _user_relationship} = User.block(blocker, blockee)
{:ok, activity_one} = CommonAPI.post(friend, %{status: "hey!"})
{:ok, activity_two} = CommonAPI.post(friend, %{status: "hey! @#{blockee.nickname}"})
{:ok, activity_three} = CommonAPI.post(blockee, %{status: "hey! @#{friend.nickname}"})
{:ok, activity_four} = CommonAPI.post(blockee, %{status: "hey! @#{blocker.nickname}"})
activities = ActivityPub.fetch_activities([], %{blocking_user: blocker})
assert Enum.member?(activities, activity_one)
refute Enum.member?(activities, activity_two)
refute Enum.member?(activities, activity_three)
refute Enum.member?(activities, activity_four)
end
test "doesn't return announce activities with blocked users in 'to'" do
blocker = insert(:user)
blockee = insert(:user)
friend = insert(:user)
{:ok, _user_relationship} = User.block(blocker, blockee)
{:ok, activity_one} = CommonAPI.post(friend, %{status: "hey!"})
{:ok, activity_two} = CommonAPI.post(blockee, %{status: "hey! @#{friend.nickname}"})
{:ok, activity_three} = CommonAPI.repeat(activity_two.id, friend)
activities =
ActivityPub.fetch_activities([], %{blocking_user: blocker})
|> Enum.map(fn act -> act.id end)
assert Enum.member?(activities, activity_one.id)
refute Enum.member?(activities, activity_two.id)
refute Enum.member?(activities, activity_three.id)
end
test "doesn't return announce activities with blocked users in 'cc'" do
blocker = insert(:user)
blockee = insert(:user)
friend = insert(:user)
{:ok, _user_relationship} = User.block(blocker, blockee)
{:ok, activity_one} = CommonAPI.post(friend, %{status: "hey!"})
{:ok, activity_two} = CommonAPI.post(blockee, %{status: "hey! @#{friend.nickname}"})
assert object = Pleroma.Object.normalize(activity_two, fetch: false)
data = %{
"actor" => friend.ap_id,
"object" => object.data["id"],
"context" => object.data["context"],
"type" => "Announce",
"to" => ["https://www.w3.org/ns/activitystreams#Public"],
"cc" => [blockee.ap_id]
}
assert {:ok, activity_three} = ActivityPub.insert(data)
activities =
ActivityPub.fetch_activities([], %{blocking_user: blocker})
|> Enum.map(fn act -> act.id end)
assert Enum.member?(activities, activity_one.id)
refute Enum.member?(activities, activity_two.id)
refute Enum.member?(activities, activity_three.id)
end
test "doesn't return activities from blocked domains" do
domain = "dogwhistle.zone"
domain_user = insert(:user, %{ap_id: "https://#{domain}/@pundit"})
note = insert(:note, %{data: %{"actor" => domain_user.ap_id}})
activity = insert(:note_activity, %{note: note})
user = insert(:user)
{:ok, user} = User.block_domain(user, domain)
activities = ActivityPub.fetch_activities([], %{blocking_user: user, skip_preload: true})
refute activity in activities
followed_user = insert(:user)
CommonAPI.follow(followed_user, user)
{:ok, repeat_activity} = CommonAPI.repeat(activity.id, followed_user)
activities = ActivityPub.fetch_activities([], %{blocking_user: user, skip_preload: true})
refute repeat_activity in activities
end
test "see your own posts even when they address actors from blocked domains" do
user = insert(:user)
domain = "dogwhistle.zone"
domain_user = insert(:user, %{ap_id: "https://#{domain}/@pundit"})
{:ok, user} = User.block_domain(user, domain)
{:ok, activity} = CommonAPI.post(user, %{status: "hey! @#{domain_user.nickname}"})
activities = ActivityPub.fetch_activities([], %{blocking_user: user})
assert Enum.member?(activities, activity)
end
test "does return activities from followed users on blocked domains" do
domain = "meanies.social"
domain_user = insert(:user, %{ap_id: "https://#{domain}/@pundit"})
blocker = insert(:user)
{:ok, blocker, domain_user} = User.follow(blocker, domain_user)
{:ok, blocker} = User.block_domain(blocker, domain)
assert User.following?(blocker, domain_user)
assert User.blocks_domain?(blocker, domain_user)
refute User.blocks?(blocker, domain_user)
note = insert(:note, %{data: %{"actor" => domain_user.ap_id}})
activity = insert(:note_activity, %{note: note})
activities = ActivityPub.fetch_activities([], %{blocking_user: blocker, skip_preload: true})
assert activity in activities
# And check that if the guy we DO follow boosts someone else from their domain,
# that should be hidden
another_user = insert(:user, %{ap_id: "https://#{domain}/@meanie2"})
bad_note = insert(:note, %{data: %{"actor" => another_user.ap_id}})
bad_activity = insert(:note_activity, %{note: bad_note})
{:ok, repeat_activity} = CommonAPI.repeat(bad_activity.id, domain_user)
activities = ActivityPub.fetch_activities([], %{blocking_user: blocker, skip_preload: true})
refute repeat_activity in activities
end
test "returns your own posts regardless of mute" do
user = insert(:user)
muted = insert(:user)
{:ok, muted_post} = CommonAPI.post(muted, %{status: "Im stupid"})
{:ok, reply} =
CommonAPI.post(user, %{status: "I'm muting you", in_reply_to_status_id: muted_post.id})
{:ok, _} = User.mute(user, muted)
[activity] = ActivityPub.fetch_activities([], %{muting_user: user, skip_preload: true})
assert activity.id == reply.id
end
test "doesn't return muted activities" do
activity_one = insert(:note_activity)
activity_two = insert(:note_activity)
activity_three = insert(:note_activity)
user = insert(:user)
booster = insert(:user)
activity_one_actor = User.get_by_ap_id(activity_one.data["actor"])
{:ok, _user_relationships} = User.mute(user, activity_one_actor)
activities = ActivityPub.fetch_activities([], %{muting_user: user, skip_preload: true})
assert Enum.member?(activities, activity_two)
assert Enum.member?(activities, activity_three)
refute Enum.member?(activities, activity_one)
# Calling with 'with_muted' will deliver muted activities, too.
activities =
ActivityPub.fetch_activities([], %{
muting_user: user,
with_muted: true,
skip_preload: true
})
assert Enum.member?(activities, activity_two)
assert Enum.member?(activities, activity_three)
assert Enum.member?(activities, activity_one)
{:ok, _user_mute} = User.unmute(user, activity_one_actor)
activities = ActivityPub.fetch_activities([], %{muting_user: user, skip_preload: true})
assert Enum.member?(activities, activity_two)
assert Enum.member?(activities, activity_three)
assert Enum.member?(activities, activity_one)
activity_three_actor = User.get_by_ap_id(activity_three.data["actor"])
{:ok, _user_relationships} = User.mute(user, activity_three_actor)
{:ok, %{data: %{"object" => id}}} = CommonAPI.repeat(activity_three.id, booster)
%Activity{} = boost_activity = Activity.get_create_by_object_ap_id(id)
activity_three = Activity.get_by_id(activity_three.id)
activities = ActivityPub.fetch_activities([], %{muting_user: user, skip_preload: true})
assert Enum.member?(activities, activity_two)
refute Enum.member?(activities, activity_three)
refute Enum.member?(activities, boost_activity)
assert Enum.member?(activities, activity_one)
activities = ActivityPub.fetch_activities([], %{muting_user: nil, skip_preload: true})
assert Enum.member?(activities, activity_two)
assert Enum.member?(activities, activity_three)
assert Enum.member?(activities, boost_activity)
assert Enum.member?(activities, activity_one)
end
test "doesn't return thread muted activities" do
user = insert(:user)
_activity_one = insert(:note_activity)
note_two = insert(:note, data: %{"context" => "suya.."})
activity_two = insert(:note_activity, note: note_two)
{:ok, _activity_two} = CommonAPI.add_mute(activity_two, user)
assert [_activity_one] = ActivityPub.fetch_activities([], %{muting_user: user})
end
test "returns thread muted activities when with_muted is set" do
user = insert(:user)
_activity_one = insert(:note_activity)
note_two = insert(:note, data: %{"context" => "suya.."})
activity_two = insert(:note_activity, note: note_two)
{:ok, _activity_two} = CommonAPI.add_mute(activity_two, user)
assert [_activity_two, _activity_one] =
ActivityPub.fetch_activities([], %{muting_user: user, with_muted: true})
end
test "does include announces on request" do
activity_three = insert(:note_activity)
user = insert(:user)
booster = insert(:user)
{:ok, user, booster} = User.follow(user, booster)
{:ok, announce} = CommonAPI.repeat(activity_three.id, booster)
[announce_activity] = ActivityPub.fetch_activities([user.ap_id | User.following(user)])
assert announce_activity.id == announce.id
end
test "excludes reblogs on request" do
user = insert(:user)
{:ok, expected_activity} = ActivityBuilder.insert(%{"type" => "Create"}, %{:user => user})
{:ok, _} = ActivityBuilder.insert(%{"type" => "Announce"}, %{:user => user})
[activity] = ActivityPub.fetch_user_activities(user, nil, %{exclude_reblogs: true})
assert activity == expected_activity
end
test "includes only reblogs on request" do
user = insert(:user)
{:ok, _} = ActivityBuilder.insert(%{"type" => "Create"}, %{:user => user})
{:ok, expected_activity} = ActivityBuilder.insert(%{"type" => "Announce"}, %{:user => user})
[activity] = ActivityPub.fetch_user_activities(user, nil, %{only_reblogs: true})
assert activity == expected_activity
end
describe "irreversible filters" do
setup do
user = insert(:user)
user_two = insert(:user)
insert(:filter, user: user_two, phrase: "cofe", hide: true)
insert(:filter, user: user_two, phrase: "ok boomer", hide: true)
insert(:filter, user: user_two, phrase: "test", hide: false)
params = %{
type: ["Create", "Announce"],
user: user_two
}
{:ok, %{user: user, user_two: user_two, params: params}}
end
test "it returns statuses if they don't contain exact filter words", %{
user: user,
params: params
} do
{:ok, _} = CommonAPI.post(user, %{status: "hey"})
{:ok, _} = CommonAPI.post(user, %{status: "got cofefe?"})
{:ok, _} = CommonAPI.post(user, %{status: "I am not a boomer"})
{:ok, _} = CommonAPI.post(user, %{status: "ok boomers"})
{:ok, _} = CommonAPI.post(user, %{status: "ccofee is not a word"})
{:ok, _} = CommonAPI.post(user, %{status: "this is a test"})
activities = ActivityPub.fetch_activities([], params)
assert Enum.count(activities) == 6
end
test "it does not filter user's own statuses", %{user_two: user_two, params: params} do
{:ok, _} = CommonAPI.post(user_two, %{status: "Give me some cofe!"})
{:ok, _} = CommonAPI.post(user_two, %{status: "ok boomer"})
activities = ActivityPub.fetch_activities([], params)
assert Enum.count(activities) == 2
end
test "it excludes statuses with filter words", %{user: user, params: params} do
{:ok, _} = CommonAPI.post(user, %{status: "Give me some cofe!"})
{:ok, _} = CommonAPI.post(user, %{status: "ok boomer"})
{:ok, _} = CommonAPI.post(user, %{status: "is it a cOfE?"})
{:ok, _} = CommonAPI.post(user, %{status: "cofe is all I need"})
{:ok, _} = CommonAPI.post(user, %{status: "— ok BOOMER\n"})
activities = ActivityPub.fetch_activities([], params)
assert Enum.empty?(activities)
end
test "it returns all statuses if user does not have any filters" do
another_user = insert(:user)
{:ok, _} = CommonAPI.post(another_user, %{status: "got cofe?"})
{:ok, _} = CommonAPI.post(another_user, %{status: "test!"})
activities =
ActivityPub.fetch_activities([], %{
type: ["Create", "Announce"],
user: another_user
})
assert Enum.count(activities) == 2
end
end
describe "public fetch activities" do
test "doesn't retrieve unlisted activities" do
user = insert(:user)
{:ok, _unlisted_activity} = CommonAPI.post(user, %{status: "yeah", visibility: "unlisted"})
{:ok, listed_activity} = CommonAPI.post(user, %{status: "yeah"})
[activity] = ActivityPub.fetch_public_activities()
assert activity == listed_activity
end
test "retrieves public activities" do
_activities = ActivityPub.fetch_public_activities()
%{public: public} = ActivityBuilder.public_and_non_public()
activities = ActivityPub.fetch_public_activities()
assert length(activities) == 1
assert Enum.at(activities, 0) == public
end
test "retrieves a maximum of 20 activities" do
ActivityBuilder.insert_list(10)
expected_activities = ActivityBuilder.insert_list(20)
activities = ActivityPub.fetch_public_activities()
assert collect_ids(activities) == collect_ids(expected_activities)
assert length(activities) == 20
end
test "retrieves ids starting from a since_id" do
activities = ActivityBuilder.insert_list(30)
expected_activities = ActivityBuilder.insert_list(10)
since_id = List.last(activities).id
activities = ActivityPub.fetch_public_activities(%{since_id: since_id})
assert collect_ids(activities) == collect_ids(expected_activities)
assert length(activities) == 10
end
test "retrieves ids up to max_id" do
ActivityBuilder.insert_list(10)
expected_activities = ActivityBuilder.insert_list(20)
%{id: max_id} =
10
|> ActivityBuilder.insert_list()
|> List.first()
activities = ActivityPub.fetch_public_activities(%{max_id: max_id})
assert length(activities) == 20
assert collect_ids(activities) == collect_ids(expected_activities)
end
test "paginates via offset/limit" do
_first_part_activities = ActivityBuilder.insert_list(10)
second_part_activities = ActivityBuilder.insert_list(10)
later_activities = ActivityBuilder.insert_list(10)
activities = ActivityPub.fetch_public_activities(%{page: "2", page_size: "20"}, :offset)
assert length(activities) == 20
assert collect_ids(activities) ==
collect_ids(second_part_activities) ++ collect_ids(later_activities)
end
test "doesn't return reblogs for users for whom reblogs have been muted" do
activity = insert(:note_activity)
user = insert(:user)
booster = insert(:user)
{:ok, _reblog_mute} = CommonAPI.hide_reblogs(booster, user)
{:ok, activity} = CommonAPI.repeat(activity.id, booster)
activities = ActivityPub.fetch_activities([], %{muting_user: user})
refute Enum.any?(activities, fn %{id: id} -> id == activity.id end)
end
test "returns reblogs for users for whom reblogs have not been muted" do
activity = insert(:note_activity)
user = insert(:user)
booster = insert(:user)
{:ok, _reblog_mute} = CommonAPI.hide_reblogs(booster, user)
{:ok, _reblog_mute} = CommonAPI.show_reblogs(booster, user)
{:ok, activity} = CommonAPI.repeat(activity.id, booster)
activities = ActivityPub.fetch_activities([], %{muting_user: user})
assert Enum.any?(activities, fn %{id: id} -> id == activity.id end)
end
end
describe "uploading files" do
setup do
test_file = %Plug.Upload{
content_type: "image/jpeg",
path: Path.absname("test/fixtures/image.jpg"),
filename: "an_image.jpg"
}
%{test_file: test_file}
end
test "strips / from filename", %{test_file: file} do
file = %Plug.Upload{file | filename: "../../../../../nested/bad.jpg"}
{:ok, %Object{} = object} = ActivityPub.upload(file)
[%{"href" => href}] = object.data["url"]
assert Regex.match?(~r"/bad.jpg$", href)
refute Regex.match?(~r"/nested/", href)
end
test "sets a description if given", %{test_file: file} do
{:ok, %Object{} = object} = ActivityPub.upload(file, description: "a cool file")
assert object.data["name"] == "a cool file"
end
test "it sets the default description depending on the configuration", %{test_file: file} do
clear_config([Pleroma.Upload, :default_description])
clear_config([Pleroma.Upload, :default_description], nil)
{:ok, %Object{} = object} = ActivityPub.upload(file)
assert object.data["name"] == ""
clear_config([Pleroma.Upload, :default_description], :filename)
{:ok, %Object{} = object} = ActivityPub.upload(file)
assert object.data["name"] == "an_image.jpg"
clear_config([Pleroma.Upload, :default_description], "unnamed attachment")
{:ok, %Object{} = object} = ActivityPub.upload(file)
assert object.data["name"] == "unnamed attachment"
end
test "copies the file to the configured folder", %{test_file: file} do
clear_config([Pleroma.Upload, :default_description], :filename)
{:ok, %Object{} = object} = ActivityPub.upload(file)
assert object.data["name"] == "an_image.jpg"
end
test "works with base64 encoded images" do
file = %{
img: data_uri()
}
{:ok, %Object{}} = ActivityPub.upload(file)
end
end
describe "fetch the latest Follow" do
test "fetches the latest Follow activity" do
%Activity{data: %{"type" => "Follow"}} = activity = insert(:follow_activity)
follower = Repo.get_by(User, ap_id: activity.data["actor"])
followed = Repo.get_by(User, ap_id: activity.data["object"])
assert activity == Utils.fetch_latest_follow(follower, followed)
end
end
describe "unfollowing" do
test "it reverts unfollow activity" do
follower = insert(:user)
followed = insert(:user)
{:ok, _, _, follow_activity} = CommonAPI.follow(followed, follower)
with_mock(Utils, [:passthrough], maybe_federate: fn _ -> {:error, :reverted} end) do
assert {:error, :reverted} = ActivityPub.unfollow(follower, followed)
end
activity = Activity.get_by_id(follow_activity.id)
assert activity.data["type"] == "Follow"
assert activity.data["actor"] == follower.ap_id
assert activity.data["object"] == followed.ap_id
end
test "creates an undo activity for the last follow" do
follower = insert(:user)
followed = insert(:user)
{:ok, _, _, follow_activity} = CommonAPI.follow(followed, follower)
{:ok, activity} = ActivityPub.unfollow(follower, followed)
assert activity.data["type"] == "Undo"
assert activity.data["actor"] == follower.ap_id
embedded_object = activity.data["object"]
assert is_map(embedded_object)
assert embedded_object["type"] == "Follow"
assert embedded_object["object"] == followed.ap_id
assert embedded_object["id"] == follow_activity.data["id"]
end
test "creates an undo activity for a pending follow request" do
follower = insert(:user)
followed = insert(:user, %{is_locked: true})
{:ok, _, _, follow_activity} = CommonAPI.follow(followed, follower)
{:ok, activity} = ActivityPub.unfollow(follower, followed)
assert activity.data["type"] == "Undo"
assert activity.data["actor"] == follower.ap_id
embedded_object = activity.data["object"]
assert is_map(embedded_object)
assert embedded_object["type"] == "Follow"
assert embedded_object["object"] == followed.ap_id
assert embedded_object["id"] == follow_activity.data["id"]
end
end
describe "timeline post-processing" do
test "it filters broken threads" do
user1 = insert(:user)
user2 = insert(:user)
user3 = insert(:user)
{:ok, user1, user3} = User.follow(user1, user3)
assert User.following?(user1, user3)
{:ok, user2, user3} = User.follow(user2, user3)
assert User.following?(user2, user3)
{:ok, user3, user2} = User.follow(user3, user2)
assert User.following?(user3, user2)
{:ok, public_activity} = CommonAPI.post(user3, %{status: "hi 1"})
{:ok, private_activity_1} = CommonAPI.post(user3, %{status: "hi 2", visibility: "private"})
{:ok, private_activity_2} =
CommonAPI.post(user2, %{
status: "hi 3",
visibility: "private",
in_reply_to_status_id: private_activity_1.id
})
{:ok, private_activity_3} =
CommonAPI.post(user3, %{
status: "hi 4",
visibility: "private",
in_reply_to_status_id: private_activity_2.id
})
activities =
ActivityPub.fetch_activities([user1.ap_id | User.following(user1)])
|> Enum.map(fn a -> a.id end)
private_activity_1 = Activity.get_by_ap_id_with_object(private_activity_1.data["id"])
assert [public_activity.id, private_activity_1.id, private_activity_3.id] == activities
assert length(activities) == 3
activities =
ActivityPub.fetch_activities([user1.ap_id | User.following(user1)], %{user: user1})
|> Enum.map(fn a -> a.id end)
assert [public_activity.id, private_activity_1.id] == activities
assert length(activities) == 2
end
end
describe "flag/1" do
setup do
reporter = insert(:user)
target_account = insert(:user)
content = "foobar"
{:ok, activity} = CommonAPI.post(target_account, %{status: content})
context = Utils.generate_context_id()
reporter_ap_id = reporter.ap_id
target_ap_id = target_account.ap_id
activity_ap_id = activity.data["id"]
object_ap_id = activity.object.data["id"]
activity_with_object = Activity.get_by_ap_id_with_object(activity_ap_id)
{:ok,
%{
reporter: reporter,
context: context,
target_account: target_account,
reported_activity: activity,
content: content,
activity_ap_id: activity_ap_id,
object_ap_id: object_ap_id,
activity_with_object: activity_with_object,
reporter_ap_id: reporter_ap_id,
target_ap_id: target_ap_id
}}
end
test "it can create a Flag activity",
%{
reporter: reporter,
context: context,
target_account: target_account,
reported_activity: reported_activity,
content: content,
object_ap_id: object_ap_id,
activity_with_object: activity_with_object,
reporter_ap_id: reporter_ap_id,
target_ap_id: target_ap_id
} do
assert {:ok, activity} =
ActivityPub.flag(%{
actor: reporter,
context: context,
account: target_account,
statuses: [reported_activity],
content: content
})
note_obj = %{
"type" => "Note",
"id" => object_ap_id,
"content" => content,
"published" => activity_with_object.object.data["published"],
"actor" =>
AccountView.render("show.json", %{user: target_account, skip_visibility_check: true})
}
assert %Activity{
actor: ^reporter_ap_id,
data: %{
"type" => "Flag",
"content" => ^content,
"context" => ^context,
"object" => [^target_ap_id, ^note_obj]
}
} = activity
end
test_with_mock "reverts on error",
%{
reporter: reporter,
context: context,
target_account: target_account,
reported_activity: reported_activity,
content: content
},
Utils,
[:passthrough],
maybe_federate: fn _ -> {:error, :reverted} end do
assert {:error, :reverted} =
ActivityPub.flag(%{
actor: reporter,
context: context,
account: target_account,
statuses: [reported_activity],
content: content
})
assert Repo.aggregate(Activity, :count, :id) == 1
assert Repo.aggregate(Object, :count, :id) == 1
assert Repo.aggregate(Notification, :count, :id) == 0
end
end
test "fetch_activities/2 returns activities addressed to a list " do
user = insert(:user)
member = insert(:user)
{:ok, list} = Pleroma.List.create("foo", user)
{:ok, list} = Pleroma.List.follow(list, member)
{:ok, activity} = CommonAPI.post(user, %{status: "foobar", visibility: "list:#{list.id}"})
activity = Repo.preload(activity, :bookmark)
activity = %Activity{activity | thread_muted?: !!activity.thread_muted?}
assert ActivityPub.fetch_activities([], %{user: user}) == [activity]
end
def data_uri do
File.read!("test/fixtures/avatar_data_uri")
end
describe "fetch_activities_bounded" do
test "fetches private posts for followed users" do
user = insert(:user)
{:ok, activity} =
CommonAPI.post(user, %{
status: "thought I looked cute might delete later :3",
visibility: "private"
})
[result] = ActivityPub.fetch_activities_bounded([user.follower_address], [])
assert result.id == activity.id
end
test "fetches only public posts for other users" do
user = insert(:user)
{:ok, activity} = CommonAPI.post(user, %{status: "#cofe", visibility: "public"})
{:ok, _private_activity} =
CommonAPI.post(user, %{
status: "why is tenshi eating a corndog so cute?",
visibility: "private"
})
[result] = ActivityPub.fetch_activities_bounded([], [user.follower_address])
assert result.id == activity.id
end
end
describe "fetch_follow_information_for_user" do
test "synchronizes following/followers counters" do
user =
insert(:user,
local: false,
follower_address: "https://remote.org/users/fuser2/followers",
following_address: "https://remote.org/users/fuser2/following"
)
{:ok, info} = ActivityPub.fetch_follow_information_for_user(user)
assert info.follower_count == 527
assert info.following_count == 267
end
test "detects hidden followers" do
mock(fn env ->
case env.url do
"https://remote.org/users/masto_closed/followers?page=1" ->
%Tesla.Env{status: 403, body: ""}
_ ->
apply(HttpRequestMock, :request, [env])
end
end)
user =
insert(:user,
local: false,
follower_address: "https://remote.org/users/masto_closed/followers",
following_address: "https://remote.org/users/masto_closed/following"
)
{:ok, follow_info} = ActivityPub.fetch_follow_information_for_user(user)
assert follow_info.hide_followers == true
assert follow_info.hide_follows == false
end
test "detects hidden follows" do
mock(fn env ->
case env.url do
"https://remote.org/users/masto_closed/following?page=1" ->
%Tesla.Env{status: 403, body: ""}
_ ->
apply(HttpRequestMock, :request, [env])
end
end)
user =
insert(:user,
local: false,
follower_address: "https://remote.org/users/masto_closed/followers",
following_address: "https://remote.org/users/masto_closed/following"
)
{:ok, follow_info} = ActivityPub.fetch_follow_information_for_user(user)
assert follow_info.hide_followers == false
assert follow_info.hide_follows == true
end
test "detects hidden follows/followers for friendica" do
user =
insert(:user,
local: false,
follower_address: "https://remote.org/followers/fuser3",
following_address: "https://remote.org/following/fuser3"
)
{:ok, follow_info} = ActivityPub.fetch_follow_information_for_user(user)
assert follow_info.hide_followers == true
assert follow_info.follower_count == 296
assert follow_info.following_count == 32
assert follow_info.hide_follows == true
end
test "doesn't crash when follower and following counters are hidden" do
mock(fn env ->
case env.url do
"https://remote.org/users/masto_hidden_counters/following" ->
json(
%{
"@context" => "https://www.w3.org/ns/activitystreams",
"id" => "https://remote.org/users/masto_hidden_counters/followers"
},
headers: HttpRequestMock.activitypub_object_headers()
)
"https://remote.org/users/masto_hidden_counters/following?page=1" ->
%Tesla.Env{status: 403, body: ""}
"https://remote.org/users/masto_hidden_counters/followers" ->
json(
%{
"@context" => "https://www.w3.org/ns/activitystreams",
"id" => "https://remote.org/users/masto_hidden_counters/following"
},
headers: HttpRequestMock.activitypub_object_headers()
)
"https://remote.org/users/masto_hidden_counters/followers?page=1" ->
%Tesla.Env{status: 403, body: ""}
end
end)
user =
insert(:user,
local: false,
follower_address: "https://remote.org/users/masto_hidden_counters/followers",
following_address: "https://remote.org/users/masto_hidden_counters/following"
)
{:ok, follow_info} = ActivityPub.fetch_follow_information_for_user(user)
assert follow_info.hide_followers == true
assert follow_info.follower_count == 0
assert follow_info.hide_follows == true
assert follow_info.following_count == 0
end
end
describe "fetch_favourites/3" do
test "returns a favourite activities sorted by adds to favorite" do
user = insert(:user)
other_user = insert(:user)
user1 = insert(:user)
user2 = insert(:user)
{:ok, a1} = CommonAPI.post(user1, %{status: "bla"})
{:ok, _a2} = CommonAPI.post(user2, %{status: "traps are happy"})
{:ok, a3} = CommonAPI.post(user2, %{status: "Trees Are "})
{:ok, a4} = CommonAPI.post(user2, %{status: "Agent Smith "})
{:ok, a5} = CommonAPI.post(user1, %{status: "Red or Blue "})
{:ok, _} = CommonAPI.favorite(a4.id, user)
{:ok, _} = CommonAPI.favorite(a3.id, other_user)
{:ok, _} = CommonAPI.favorite(a3.id, user)
{:ok, _} = CommonAPI.favorite(a5.id, other_user)
{:ok, _} = CommonAPI.favorite(a5.id, user)
{:ok, _} = CommonAPI.favorite(a4.id, other_user)
{:ok, _} = CommonAPI.favorite(a1.id, user)
{:ok, _} = CommonAPI.favorite(a1.id, other_user)
result = ActivityPub.fetch_favourites(user)
assert Enum.map(result, & &1.id) == [a1.id, a5.id, a3.id, a4.id]
result = ActivityPub.fetch_favourites(user, %{limit: 2})
assert Enum.map(result, & &1.id) == [a1.id, a5.id]
end
end
describe "Move activity" do
test "create" do
%{ap_id: old_ap_id} = old_user = insert(:user)
%{ap_id: new_ap_id} = new_user = insert(:user, also_known_as: [old_ap_id])
follower = insert(:user)
follower_move_opted_out = insert(:user, allow_following_move: false)
User.follow(follower, old_user)
User.follow(follower_move_opted_out, old_user)
assert User.following?(follower, old_user)
assert User.following?(follower_move_opted_out, old_user)
assert {:ok, activity} = ActivityPub.move(old_user, new_user)
assert %Activity{
actor: ^old_ap_id,
data: %{
"actor" => ^old_ap_id,
"object" => ^old_ap_id,
"target" => ^new_ap_id,
"type" => "Move"
},
local: true,
recipients: recipients
} = activity
assert old_user.follower_address in recipients
params = %{
"op" => "move_following",
"origin_id" => old_user.id,
"target_id" => new_user.id
}
assert_enqueued(worker: Pleroma.Workers.BackgroundWorker, args: params)
Pleroma.Workers.BackgroundWorker.perform(%Oban.Job{args: params})
refute User.following?(follower, old_user)
assert User.following?(follower, new_user)
assert User.following?(follower_move_opted_out, old_user)
refute User.following?(follower_move_opted_out, new_user)
activity = %Activity{activity | object: nil}
assert [%Notification{activity: ^activity}] = Notification.for_user(follower)
assert [%Notification{activity: ^activity}] = Notification.for_user(follower_move_opted_out)
end
test "old user must be in the new user's `also_known_as` list" do
old_user = insert(:user)
new_user = insert(:user)
assert {:error, "Target account must have the origin in `alsoKnownAs`"} =
ActivityPub.move(old_user, new_user)
end
test "do not move remote user following relationships" do
%{ap_id: old_ap_id} = old_user = insert(:user)
%{ap_id: new_ap_id} = new_user = insert(:user, also_known_as: [old_ap_id])
follower_remote = insert(:user, local: false)
User.follow(follower_remote, old_user)
assert User.following?(follower_remote, old_user)
assert {:ok, activity} = ActivityPub.move(old_user, new_user)
assert %Activity{
actor: ^old_ap_id,
data: %{
"actor" => ^old_ap_id,
"object" => ^old_ap_id,
"target" => ^new_ap_id,
"type" => "Move"
},
local: true
} = activity
params = %{
"op" => "move_following",
"origin_id" => old_user.id,
"target_id" => new_user.id
}
assert_enqueued(worker: Pleroma.Workers.BackgroundWorker, args: params)
Pleroma.Workers.BackgroundWorker.perform(%Oban.Job{args: params})
assert User.following?(follower_remote, old_user)
refute User.following?(follower_remote, new_user)
end
end
test "doesn't retrieve replies activities with exclude_replies" do
user = insert(:user)
{:ok, activity} = CommonAPI.post(user, %{status: "yeah"})
{:ok, _reply} = CommonAPI.post(user, %{status: "yeah", in_reply_to_status_id: activity.id})
[result] = ActivityPub.fetch_public_activities(%{exclude_replies: true})
assert result.id == activity.id
assert length(ActivityPub.fetch_public_activities()) == 2
end
describe "replies filtering with public messages" do
setup :public_messages
test "public timeline", %{users: %{u1: user}} do
activities_ids =
%{}
|> Map.put(:type, ["Create", "Announce"])
|> Map.put(:local_only, false)
|> Map.put(:blocking_user, user)
|> Map.put(:muting_user, user)
|> Map.put(:reply_filtering_user, user)
|> ActivityPub.fetch_public_activities()
|> Enum.map(& &1.id)
assert length(activities_ids) == 16
end
test "public timeline with reply_visibility `following`", %{
users: %{u1: user},
u1: u1,
u2: u2,
u3: u3,
u4: u4,
activities: activities
} do
activities_ids =
%{}
|> Map.put(:type, ["Create", "Announce"])
|> Map.put(:local_only, false)
|> Map.put(:blocking_user, user)
|> Map.put(:muting_user, user)
|> Map.put(:reply_visibility, "following")
|> Map.put(:reply_filtering_user, user)
|> ActivityPub.fetch_public_activities()
|> Enum.map(& &1.id)
assert length(activities_ids) == 14
visible_ids =
Map.values(u1) ++ Map.values(u2) ++ Map.values(u4) ++ Map.values(activities) ++ [u3[:r1]]
assert Enum.all?(visible_ids, &(&1 in activities_ids))
end
test "public timeline with reply_visibility `self`", %{
users: %{u1: user},
u1: u1,
u2: u2,
u3: u3,
u4: u4,
activities: activities
} do
activities_ids =
%{}
|> Map.put(:type, ["Create", "Announce"])
|> Map.put(:local_only, false)
|> Map.put(:blocking_user, user)
|> Map.put(:muting_user, user)
|> Map.put(:reply_visibility, "self")
|> Map.put(:reply_filtering_user, user)
|> ActivityPub.fetch_public_activities()
|> Enum.map(& &1.id)
assert length(activities_ids) == 10
visible_ids = Map.values(u1) ++ [u2[:r1], u3[:r1], u4[:r1]] ++ Map.values(activities)
assert Enum.all?(visible_ids, &(&1 in activities_ids))
end
test "home timeline", %{
users: %{u1: user},
activities: activities,
u1: u1,
u2: u2,
u3: u3,
u4: u4
} do
params =
%{}
|> Map.put(:type, ["Create", "Announce"])
|> Map.put(:blocking_user, user)
|> Map.put(:muting_user, user)
|> Map.put(:user, user)
|> Map.put(:reply_filtering_user, user)
activities_ids =
ActivityPub.fetch_activities([user.ap_id | User.following(user)], params)
|> Enum.map(& &1.id)
assert length(activities_ids) == 13
visible_ids =
Map.values(u1) ++
Map.values(u3) ++
[
activities[:a1],
activities[:a2],
activities[:a4],
u2[:r1],
u2[:r3],
u4[:r1],
u4[:r2]
]
assert Enum.all?(visible_ids, &(&1 in activities_ids))
end
test "home timeline with reply_visibility `following`", %{
users: %{u1: user},
activities: activities,
u1: u1,
u2: u2,
u3: u3,
u4: u4
} do
params =
%{}
|> Map.put(:type, ["Create", "Announce"])
|> Map.put(:blocking_user, user)
|> Map.put(:muting_user, user)
|> Map.put(:user, user)
|> Map.put(:reply_visibility, "following")
|> Map.put(:reply_filtering_user, user)
activities_ids =
ActivityPub.fetch_activities([user.ap_id | User.following(user)], params)
|> Enum.map(& &1.id)
assert length(activities_ids) == 11
visible_ids =
Map.values(u1) ++
[
activities[:a1],
activities[:a2],
activities[:a4],
u2[:r1],
u2[:r3],
u3[:r1],
u4[:r1],
u4[:r2]
]
assert Enum.all?(visible_ids, &(&1 in activities_ids))
end
test "home timeline with reply_visibility `self`", %{
users: %{u1: user},
activities: activities,
u1: u1,
u2: u2,
u3: u3,
u4: u4
} do
params =
%{}
|> Map.put(:type, ["Create", "Announce"])
|> Map.put(:blocking_user, user)
|> Map.put(:muting_user, user)
|> Map.put(:user, user)
|> Map.put(:reply_visibility, "self")
|> Map.put(:reply_filtering_user, user)
activities_ids =
ActivityPub.fetch_activities([user.ap_id | User.following(user)], params)
|> Enum.map(& &1.id)
assert length(activities_ids) == 9
visible_ids =
Map.values(u1) ++
[
activities[:a1],
activities[:a2],
activities[:a4],
u2[:r1],
u3[:r1],
u4[:r1]
]
assert Enum.all?(visible_ids, &(&1 in activities_ids))
end
test "filtering out announces where the user is the actor of the announced message" do
user = insert(:user)
other_user = insert(:user)
third_user = insert(:user)
User.follow(user, other_user)
{:ok, post} = CommonAPI.post(user, %{status: "yo"})
{:ok, other_post} = CommonAPI.post(third_user, %{status: "yo"})
{:ok, _announce} = CommonAPI.repeat(post.id, other_user)
{:ok, _announce} = CommonAPI.repeat(post.id, third_user)
{:ok, announce} = CommonAPI.repeat(other_post.id, other_user)
params = %{
type: ["Announce"]
}
results =
[user.ap_id | User.following(user)]
|> ActivityPub.fetch_activities(params)
assert length(results) == 3
params = %{
type: ["Announce"],
announce_filtering_user: user
}
[result] =
[user.ap_id | User.following(user)]
|> ActivityPub.fetch_activities(params)
assert result.id == announce.id
end
end
describe "replies filtering with private messages" do
setup :private_messages
test "public timeline", %{users: %{u1: user}} do
activities_ids =
%{}
|> Map.put(:type, ["Create", "Announce"])
|> Map.put(:local_only, false)
|> Map.put(:blocking_user, user)
|> Map.put(:muting_user, user)
|> Map.put(:user, user)
|> ActivityPub.fetch_public_activities()
|> Enum.map(& &1.id)
assert activities_ids == []
end
test "public timeline with default reply_visibility `following`", %{users: %{u1: user}} do
activities_ids =
%{}
|> Map.put(:type, ["Create", "Announce"])
|> Map.put(:local_only, false)
|> Map.put(:blocking_user, user)
|> Map.put(:muting_user, user)
|> Map.put(:reply_visibility, "following")
|> Map.put(:reply_filtering_user, user)
|> Map.put(:user, user)
|> ActivityPub.fetch_public_activities()
|> Enum.map(& &1.id)
assert activities_ids == []
end
test "public timeline with default reply_visibility `self`", %{users: %{u1: user}} do
activities_ids =
%{}
|> Map.put(:type, ["Create", "Announce"])
|> Map.put(:local_only, false)
|> Map.put(:blocking_user, user)
|> Map.put(:muting_user, user)
|> Map.put(:reply_visibility, "self")
|> Map.put(:reply_filtering_user, user)
|> Map.put(:user, user)
|> ActivityPub.fetch_public_activities()
|> Enum.map(& &1.id)
assert activities_ids == []
activities_ids =
%{}
|> Map.put(:reply_visibility, "self")
|> Map.put(:reply_filtering_user, nil)
|> ActivityPub.fetch_public_activities()
assert activities_ids == []
end
test "home timeline", %{users: %{u1: user}} do
params =
%{}
|> Map.put(:type, ["Create", "Announce"])
|> Map.put(:blocking_user, user)
|> Map.put(:muting_user, user)
|> Map.put(:user, user)
activities_ids =
ActivityPub.fetch_activities([user.ap_id | User.following(user)], params)
|> Enum.map(& &1.id)
assert length(activities_ids) == 12
end
test "home timeline with default reply_visibility `following`", %{users: %{u1: user}} do
params =
%{}
|> Map.put(:type, ["Create", "Announce"])
|> Map.put(:blocking_user, user)
|> Map.put(:muting_user, user)
|> Map.put(:user, user)
|> Map.put(:reply_visibility, "following")
|> Map.put(:reply_filtering_user, user)
activities_ids =
ActivityPub.fetch_activities([user.ap_id | User.following(user)], params)
|> Enum.map(& &1.id)
assert length(activities_ids) == 12
end
test "home timeline with default reply_visibility `self`", %{
users: %{u1: user},
activities: activities,
u1: u1,
u2: u2,
u3: u3,
u4: u4
} do
params =
%{}
|> Map.put(:type, ["Create", "Announce"])
|> Map.put(:blocking_user, user)
|> Map.put(:muting_user, user)
|> Map.put(:user, user)
|> Map.put(:reply_visibility, "self")
|> Map.put(:reply_filtering_user, user)
activities_ids =
ActivityPub.fetch_activities([user.ap_id | User.following(user)], params)
|> Enum.map(& &1.id)
assert length(activities_ids) == 10
visible_ids =
Map.values(u1) ++ Map.values(u4) ++ [u2[:r1], u3[:r1]] ++ Map.values(activities)
assert Enum.all?(visible_ids, &(&1 in activities_ids))
end
end
defp public_messages(_) do
[u1, u2, u3, u4] = insert_list(4, :user)
{:ok, u1, u2} = User.follow(u1, u2)
{:ok, u2, u1} = User.follow(u2, u1)
{:ok, u1, u4} = User.follow(u1, u4)
{:ok, u4, u1} = User.follow(u4, u1)
{:ok, u2, u3} = User.follow(u2, u3)
{:ok, u3, u2} = User.follow(u3, u2)
{:ok, a1} = CommonAPI.post(u1, %{status: "Status"})
{:ok, r1_1} =
CommonAPI.post(u2, %{
status: "@#{u1.nickname} reply from u2 to u1",
in_reply_to_status_id: a1.id
})
{:ok, r1_2} =
CommonAPI.post(u3, %{
status: "@#{u1.nickname} reply from u3 to u1",
in_reply_to_status_id: a1.id
})
{:ok, r1_3} =
CommonAPI.post(u4, %{
status: "@#{u1.nickname} reply from u4 to u1",
in_reply_to_status_id: a1.id
})
{:ok, a2} = CommonAPI.post(u2, %{status: "Status"})
{:ok, r2_1} =
CommonAPI.post(u1, %{
status: "@#{u2.nickname} reply from u1 to u2",
in_reply_to_status_id: a2.id
})
{:ok, r2_2} =
CommonAPI.post(u3, %{
status: "@#{u2.nickname} reply from u3 to u2",
in_reply_to_status_id: a2.id
})
{:ok, r2_3} =
CommonAPI.post(u4, %{
status: "@#{u2.nickname} reply from u4 to u2",
in_reply_to_status_id: a2.id
})
{:ok, a3} = CommonAPI.post(u3, %{status: "Status"})
{:ok, r3_1} =
CommonAPI.post(u1, %{
status: "@#{u3.nickname} reply from u1 to u3",
in_reply_to_status_id: a3.id
})
{:ok, r3_2} =
CommonAPI.post(u2, %{
status: "@#{u3.nickname} reply from u2 to u3",
in_reply_to_status_id: a3.id
})
{:ok, r3_3} =
CommonAPI.post(u4, %{
status: "@#{u3.nickname} reply from u4 to u3",
in_reply_to_status_id: a3.id
})
{:ok, a4} = CommonAPI.post(u4, %{status: "Status"})
{:ok, r4_1} =
CommonAPI.post(u1, %{
status: "@#{u4.nickname} reply from u1 to u4",
in_reply_to_status_id: a4.id
})
{:ok, r4_2} =
CommonAPI.post(u2, %{
status: "@#{u4.nickname} reply from u2 to u4",
in_reply_to_status_id: a4.id
})
{:ok, r4_3} =
CommonAPI.post(u3, %{
status: "@#{u4.nickname} reply from u3 to u4",
in_reply_to_status_id: a4.id
})
{:ok,
users: %{u1: u1, u2: u2, u3: u3, u4: u4},
activities: %{a1: a1.id, a2: a2.id, a3: a3.id, a4: a4.id},
u1: %{r1: r1_1.id, r2: r1_2.id, r3: r1_3.id},
u2: %{r1: r2_1.id, r2: r2_2.id, r3: r2_3.id},
u3: %{r1: r3_1.id, r2: r3_2.id, r3: r3_3.id},
u4: %{r1: r4_1.id, r2: r4_2.id, r3: r4_3.id}}
end
defp private_messages(_) do
[u1, u2, u3, u4] = insert_list(4, :user)
{:ok, u1, u2} = User.follow(u1, u2)
{:ok, u2, u1} = User.follow(u2, u1)
{:ok, u1, u3} = User.follow(u1, u3)
{:ok, u3, u1} = User.follow(u3, u1)
{:ok, u1, u4} = User.follow(u1, u4)
{:ok, u4, u1} = User.follow(u4, u1)
{:ok, u2, u3} = User.follow(u2, u3)
{:ok, u3, u2} = User.follow(u3, u2)
{:ok, a1} = CommonAPI.post(u1, %{status: "Status", visibility: "private"})
{:ok, r1_1} =
CommonAPI.post(u2, %{
status: "@#{u1.nickname} reply from u2 to u1",
in_reply_to_status_id: a1.id,
visibility: "private"
})
{:ok, r1_2} =
CommonAPI.post(u3, %{
status: "@#{u1.nickname} reply from u3 to u1",
in_reply_to_status_id: a1.id,
visibility: "private"
})
{:ok, r1_3} =
CommonAPI.post(u4, %{
status: "@#{u1.nickname} reply from u4 to u1",
in_reply_to_status_id: a1.id,
visibility: "private"
})
{:ok, a2} = CommonAPI.post(u2, %{status: "Status", visibility: "private"})
{:ok, r2_1} =
CommonAPI.post(u1, %{
status: "@#{u2.nickname} reply from u1 to u2",
in_reply_to_status_id: a2.id,
visibility: "private"
})
{:ok, r2_2} =
CommonAPI.post(u3, %{
status: "@#{u2.nickname} reply from u3 to u2",
in_reply_to_status_id: a2.id,
visibility: "private"
})
{:ok, a3} = CommonAPI.post(u3, %{status: "Status", visibility: "private"})
{:ok, r3_1} =
CommonAPI.post(u1, %{
status: "@#{u3.nickname} reply from u1 to u3",
in_reply_to_status_id: a3.id,
visibility: "private"
})
{:ok, r3_2} =
CommonAPI.post(u2, %{
status: "@#{u3.nickname} reply from u2 to u3",
in_reply_to_status_id: a3.id,
visibility: "private"
})
{:ok, a4} = CommonAPI.post(u4, %{status: "Status", visibility: "private"})
{:ok, r4_1} =
CommonAPI.post(u1, %{
status: "@#{u4.nickname} reply from u1 to u4",
in_reply_to_status_id: a4.id,
visibility: "private"
})
{:ok,
users: %{u1: u1, u2: u2, u3: u3, u4: u4},
activities: %{a1: a1.id, a2: a2.id, a3: a3.id, a4: a4.id},
u1: %{r1: r1_1.id, r2: r1_2.id, r3: r1_3.id},
u2: %{r1: r2_1.id, r2: r2_2.id},
u3: %{r1: r3_1.id, r2: r3_2.id},
u4: %{r1: r4_1.id}}
end
describe "maybe_update_follow_information/1" do
setup do
clear_config([:instance, :external_user_synchronization], true)
user = %{
local: false,
ap_id: "https://gensokyo.2hu/users/raymoo",
following_address: "https://gensokyo.2hu/users/following",
follower_address: "https://gensokyo.2hu/users/followers",
type: "Person"
}
%{user: user}
end
test "logs an error when it can't fetch the info", %{user: user} do
assert capture_log(fn ->
ActivityPub.maybe_update_follow_information(user)
end) =~ "Follower/Following counter update for #{user.ap_id} failed"
end
test "just returns the input if the user type is Application", %{
user: user
} do
user =
user
|> Map.put(:type, "Application")
refute capture_log(fn ->
assert ^user = ActivityPub.maybe_update_follow_information(user)
end) =~ "Follower/Following counter update for #{user.ap_id} failed"
end
test "it just returns the input if the user has no following/follower addresses", %{
user: user
} do
user =
user
|> Map.put(:following_address, nil)
|> Map.put(:follower_address, nil)
refute capture_log(fn ->
assert ^user = ActivityPub.maybe_update_follow_information(user)
end) =~ "Follower/Following counter update for #{user.ap_id} failed"
end
end
describe "global activity expiration" do
test "creates an activity expiration for local Create activities" do
clear_config([:mrf, :policies], Pleroma.Web.ActivityPub.MRF.ActivityExpirationPolicy)
{:ok, activity} = ActivityBuilder.insert(%{"type" => "Create", "context" => "3hu"})
{:ok, follow} = ActivityBuilder.insert(%{"type" => "Follow", "context" => "3hu"})
assert_enqueued(
worker: Pleroma.Workers.PurgeExpiredActivity,
args: %{activity_id: activity.id},
scheduled_at:
activity.inserted_at
|> DateTime.from_naive!("Etc/UTC")
|> Timex.shift(days: 365)
)
refute_enqueued(
worker: Pleroma.Workers.PurgeExpiredActivity,
args: %{activity_id: follow.id}
)
end
end
describe "handling of clashing nicknames" do
test "renames an existing user with a clashing nickname and a different ap id" do
orig_user =
insert(
:user,
local: false,
nickname: "admin@mastodon.example.org",
ap_id: "http://mastodon.example.org/users/harinezumigari"
)
%{
nickname: orig_user.nickname,
ap_id: orig_user.ap_id <> "part_2"
}
|> ActivityPub.maybe_handle_clashing_nickname()
user = User.get_by_id(orig_user.id)
assert user.nickname == "#{orig_user.id}.admin@mastodon.example.org"
end
test "does nothing with a clashing nickname and the same ap id" do
orig_user =
insert(
:user,
local: false,
nickname: "admin@mastodon.example.org",
ap_id: "http://mastodon.example.org/users/harinezumigari"
)
%{
nickname: orig_user.nickname,
ap_id: orig_user.ap_id
}
|> ActivityPub.maybe_handle_clashing_nickname()
user = User.get_by_id(orig_user.id)
assert user.nickname == orig_user.nickname
end
end
describe "reply filtering" do
test "`following` still contains announcements by friends" do
user = insert(:user)
followed = insert(:user)
not_followed = insert(:user)
User.follow(user, followed)
{:ok, followed_post} = CommonAPI.post(followed, %{status: "Hello"})
{:ok, not_followed_to_followed} =
CommonAPI.post(not_followed, %{
status: "Also hello",
in_reply_to_status_id: followed_post.id
})
{:ok, retoot} = CommonAPI.repeat(not_followed_to_followed.id, followed)
params =
%{}
|> Map.put(:type, ["Create", "Announce"])
|> Map.put(:blocking_user, user)
|> Map.put(:muting_user, user)
|> Map.put(:reply_filtering_user, user)
|> Map.put(:reply_visibility, "following")
|> Map.put(:announce_filtering_user, user)
|> Map.put(:user, user)
activities =
[user.ap_id | User.following(user)]
|> ActivityPub.fetch_activities(params)
followed_post_id = followed_post.id
retoot_id = retoot.id
assert [%{id: ^followed_post_id}, %{id: ^retoot_id}] = activities
assert length(activities) == 2
end
# This test is skipped because, while this is the desired behavior,
# there seems to be no good way to achieve it with the method that
# we currently use for detecting to who a reply is directed.
# This is a TODO and should be fixed by a later rewrite of the code
# in question.
@tag skip: true
test "`following` still contains self-replies by friends" do
user = insert(:user)
followed = insert(:user)
not_followed = insert(:user)
User.follow(user, followed)
{:ok, followed_post} = CommonAPI.post(followed, %{status: "Hello"})
{:ok, not_followed_post} = CommonAPI.post(not_followed, %{status: "Also hello"})
{:ok, _followed_to_not_followed} =
CommonAPI.post(followed, %{status: "sup", in_reply_to_status_id: not_followed_post.id})
{:ok, _followed_self_reply} =
CommonAPI.post(followed, %{status: "Also cofe", in_reply_to_status_id: followed_post.id})
params =
%{}
|> Map.put(:type, ["Create", "Announce"])
|> Map.put(:blocking_user, user)
|> Map.put(:muting_user, user)
|> Map.put(:reply_filtering_user, user)
|> Map.put(:reply_visibility, "following")
|> Map.put(:announce_filtering_user, user)
|> Map.put(:user, user)
activities =
[user.ap_id | User.following(user)]
|> ActivityPub.fetch_activities(params)
assert length(activities) == 2
end
end
test "allow fetching of accounts with an empty string name field" do
Tesla.Mock.mock(fn
%{method: :get, url: "https://princess.cat/users/mewmew"} ->
file = File.read!("test/fixtures/mewmew_no_name.json")
%Tesla.Env{status: 200, body: file, headers: HttpRequestMock.activitypub_object_headers()}
end)
{:ok, user} = ActivityPub.make_user_from_ap_id("https://princess.cat/users/mewmew")
assert user.name == " "
end
test "pin_data_from_featured_collection will ignore unsupported values" do
assert %{} ==
ActivityPub.pin_data_from_featured_collection(%{
"type" => "OrderedCollection",
"first" => "https://social.example/users/alice/collections/featured?page=true"
})
end
end
diff --git a/test/pleroma/web/activity_pub/transmogrifier_test.exs b/test/pleroma/web/activity_pub/transmogrifier_test.exs
index e34a101b2..8fef013bb 100644
--- a/test/pleroma/web/activity_pub/transmogrifier_test.exs
+++ b/test/pleroma/web/activity_pub/transmogrifier_test.exs
@@ -1,955 +1,955 @@
# Pleroma: A lightweight social networking server
# Copyright © 2017-2022 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Web.ActivityPub.TransmogrifierTest do
use Oban.Testing, repo: Pleroma.Repo
use Pleroma.DataCase
alias Pleroma.Activity
alias Pleroma.Object
alias Pleroma.User
alias Pleroma.Web.ActivityPub.Transmogrifier
alias Pleroma.Web.ActivityPub.Utils
alias Pleroma.Web.AdminAPI.AccountView
alias Pleroma.Web.CommonAPI
import Mock
import Pleroma.Factory
import ExUnit.CaptureLog
setup_all do
Tesla.Mock.mock_global(fn env -> apply(HttpRequestMock, :request, [env]) end)
:ok
end
setup do: clear_config([:instance, :max_remote_account_fields])
describe "handle_incoming" do
test "it works for incoming unfollows with an existing follow" do
user = insert(:user)
follow_data =
File.read!("test/fixtures/mastodon-follow-activity.json")
|> Jason.decode!()
|> Map.put("object", user.ap_id)
{:ok, %Activity{data: _, local: false}} = Transmogrifier.handle_incoming(follow_data)
data =
File.read!("test/fixtures/mastodon-unfollow-activity.json")
|> Jason.decode!()
|> Map.put("object", follow_data)
{:ok, %Activity{data: data, local: false}} = Transmogrifier.handle_incoming(data)
assert data["type"] == "Undo"
assert data["object"]["type"] == "Follow"
assert data["object"]["object"] == user.ap_id
assert data["actor"] == "http://mastodon.example.org/users/admin"
refute User.following?(User.get_cached_by_ap_id(data["actor"]), user)
end
test "it accepts Flag activities" do
user = insert(:user)
other_user = insert(:user)
{:ok, activity} = CommonAPI.post(user, %{status: "test post"})
object = Object.normalize(activity, fetch: false)
note_obj = %{
"type" => "Note",
"id" => activity.object.data["id"],
"content" => "test post",
"published" => object.data["published"],
"actor" => AccountView.render("show.json", %{user: user, skip_visibility_check: true})
}
message = %{
"@context" => "https://www.w3.org/ns/activitystreams",
"cc" => [user.ap_id],
"object" => [user.ap_id, activity.data["id"]],
"type" => "Flag",
"content" => "blocked AND reported!!!",
"actor" => other_user.ap_id
}
assert {:ok, activity} = Transmogrifier.handle_incoming(message)
assert activity.data["object"] == [user.ap_id, note_obj]
assert activity.data["content"] == "blocked AND reported!!!"
assert activity.data["actor"] == other_user.ap_id
assert activity.data["cc"] == [user.ap_id]
end
test "it accepts Move activities" do
old_user = insert(:user)
new_user = insert(:user)
message = %{
"@context" => "https://www.w3.org/ns/activitystreams",
"type" => "Move",
"actor" => old_user.ap_id,
"object" => old_user.ap_id,
"target" => new_user.ap_id
}
assert :error = Transmogrifier.handle_incoming(message)
{:ok, _new_user} = User.update_and_set_cache(new_user, %{also_known_as: [old_user.ap_id]})
assert {:ok, %Activity{} = activity} = Transmogrifier.handle_incoming(message)
assert activity.actor == old_user.ap_id
assert activity.data["actor"] == old_user.ap_id
assert activity.data["object"] == old_user.ap_id
assert activity.data["target"] == new_user.ap_id
assert activity.data["type"] == "Move"
end
test "it fixes both the Create and object contexts in a reply" do
insert(:user, ap_id: "https://mk.absturztau.be/users/8ozbzjs3o8")
insert(:user, ap_id: "https://p.helene.moe/users/helene")
create_activity =
"test/fixtures/create-pleroma-reply-to-misskey-thread.json"
|> File.read!()
|> Jason.decode!()
assert {:ok, %Activity{} = activity} = Transmogrifier.handle_incoming(create_activity)
object = Object.normalize(activity, fetch: false)
assert activity.data["context"] == object.data["context"]
end
test "it keeps link tags" do
insert(:user, ap_id: "https://example.org/users/alice")
message = File.read!("test/fixtures/fep-e232.json") |> Jason.decode!()
assert capture_log(fn ->
assert {:ok, activity} = Transmogrifier.handle_incoming(message)
object = Object.normalize(activity)
assert [%{"type" => "Mention"}, %{"type" => "Link"}] = object.data["tag"]
end) =~ "Object rejected while fetching"
end
test "it accepts quote posts" do
insert(:user, ap_id: "https://misskey.io/users/7rkrarq81i")
object = File.read!("test/fixtures/quote_post/misskey_quote_post.json") |> Jason.decode!()
message = %{
"@context" => "https://www.w3.org/ns/activitystreams",
"type" => "Create",
"actor" => "https://misskey.io/users/7rkrarq81i",
"object" => object
}
assert {:ok, activity} = Transmogrifier.handle_incoming(message)
# Object was created in the database
object = Object.normalize(activity)
assert object.data["quoteUrl"] == "https://misskey.io/notes/8vs6wxufd0"
# It fetched the quoted post
assert Object.normalize("https://misskey.io/notes/8vs6wxufd0")
end
test "doesn't allow remote edits to fake local likes" do
# as a spot check for no internal fields getting injected
now = DateTime.utc_now()
pub_date = DateTime.to_iso8601(Timex.subtract(now, Timex.Duration.from_minutes(3)))
edit_date = DateTime.to_iso8601(now)
local_user = insert(:user)
create_data = %{
"type" => "Create",
"id" => "http://mastodon.example.org/users/admin/statuses/2619539638/activity",
"actor" => "http://mastodon.example.org/users/admin",
"to" => ["https://www.w3.org/ns/activitystreams#Public"],
"cc" => [],
"object" => %{
"type" => "Note",
"id" => "http://mastodon.example.org/users/admin/statuses/2619539638",
"attributedTo" => "http://mastodon.example.org/users/admin",
"to" => ["https://www.w3.org/ns/activitystreams#Public"],
"cc" => [],
"published" => pub_date,
"content" => "miaow",
"likes" => [local_user.ap_id]
}
}
update_data =
create_data
|> Map.put("type", "Update")
|> Map.put("id", create_data["object"]["id"] <> "/update/1")
|> put_in(["object", "content"], "miaow :3")
|> put_in(["object", "updated"], edit_date)
|> put_in(["object", "formerRepresentations"], %{
"type" => "OrderedCollection",
"totalItems" => 1,
"orderedItems" => [create_data["object"]]
})
{:ok, %Pleroma.Activity{} = activity} = Transmogrifier.handle_incoming(create_data)
%Pleroma.Object{} = object = Object.get_by_ap_id(activity.data["object"])
assert object.data["content"] == "miaow"
assert object.data["likes"] == []
assert object.data["like_count"] == 0
{:ok, %Pleroma.Activity{} = activity} = Transmogrifier.handle_incoming(update_data)
%Pleroma.Object{} = object = Object.get_by_ap_id(activity.data["object"]["id"])
assert object.data["content"] == "miaow :3"
assert object.data["likes"] == []
assert object.data["like_count"] == 0
end
test "strips internal fields from history items in edited notes" do
now = DateTime.utc_now()
pub_date = DateTime.to_iso8601(Timex.subtract(now, Timex.Duration.from_minutes(3)))
edit_date = DateTime.to_iso8601(now)
local_user = insert(:user)
create_data = %{
"type" => "Create",
"id" => "http://mastodon.example.org/users/admin/statuses/2619539638/activity",
"actor" => "http://mastodon.example.org/users/admin",
"to" => ["https://www.w3.org/ns/activitystreams#Public"],
"cc" => [],
"object" => %{
"type" => "Note",
"id" => "http://mastodon.example.org/users/admin/statuses/2619539638",
"attributedTo" => "http://mastodon.example.org/users/admin",
"to" => ["https://www.w3.org/ns/activitystreams#Public"],
"cc" => [],
"published" => pub_date,
"content" => "miaow",
"likes" => [],
"like_count" => 0
}
}
update_data =
create_data
|> Map.put("type", "Update")
|> Map.put("id", create_data["object"]["id"] <> "/update/1")
|> put_in(["object", "content"], "miaow :3")
|> put_in(["object", "updated"], edit_date)
|> put_in(["object", "formerRepresentations"], %{
"type" => "OrderedCollection",
"totalItems" => 1,
"orderedItems" => [
Map.merge(create_data["object"], %{
"likes" => [local_user.ap_id],
"like_count" => 1,
"pleroma" => %{"internal_field" => "should_be_stripped"}
})
]
})
{:ok, %Pleroma.Activity{} = activity} = Transmogrifier.handle_incoming(create_data)
%Pleroma.Object{} = object = Object.get_by_ap_id(activity.data["object"])
assert object.data["content"] == "miaow"
assert object.data["likes"] == []
assert object.data["like_count"] == 0
{:ok, %Pleroma.Activity{} = activity} = Transmogrifier.handle_incoming(update_data)
%Pleroma.Object{} = object = Object.get_by_ap_id(activity.data["object"]["id"])
assert object.data["content"] == "miaow :3"
assert object.data["likes"] == []
assert object.data["like_count"] == 0
# Check that internal fields are stripped from history items
history_item = List.first(object.data["formerRepresentations"]["orderedItems"])
assert history_item["likes"] == []
assert history_item["like_count"] == 0
refute Map.has_key?(history_item, "pleroma")
end
test "doesn't trip over remote likes in notes" do
now = DateTime.utc_now()
pub_date = DateTime.to_iso8601(Timex.subtract(now, Timex.Duration.from_minutes(3)))
edit_date = DateTime.to_iso8601(now)
create_data = %{
"type" => "Create",
"id" => "http://mastodon.example.org/users/admin/statuses/3409297097/activity",
"actor" => "http://mastodon.example.org/users/admin",
"to" => ["https://www.w3.org/ns/activitystreams#Public"],
"cc" => [],
"object" => %{
"type" => "Note",
"id" => "http://mastodon.example.org/users/admin/statuses/3409297097",
"attributedTo" => "http://mastodon.example.org/users/admin",
"to" => ["https://www.w3.org/ns/activitystreams#Public"],
"cc" => [],
"published" => pub_date,
"content" => "miaow",
"likes" => %{
"id" => "http://mastodon.example.org/users/admin/statuses/3409297097/likes",
"totalItems" => 0,
"type" => "Collection"
}
}
}
update_data =
create_data
|> Map.put("type", "Update")
|> Map.put("id", create_data["object"]["id"] <> "/update/1")
|> put_in(["object", "content"], "miaow :3")
|> put_in(["object", "updated"], edit_date)
|> put_in(["object", "likes", "totalItems"], 666)
|> put_in(["object", "formerRepresentations"], %{
"type" => "OrderedCollection",
"totalItems" => 1,
"orderedItems" => [create_data["object"]]
})
{:ok, %Pleroma.Activity{} = activity} = Transmogrifier.handle_incoming(create_data)
%Pleroma.Object{} = object = Object.get_by_ap_id(activity.data["object"])
assert object.data["content"] == "miaow"
assert object.data["likes"] == []
assert object.data["like_count"] == 0
{:ok, %Pleroma.Activity{} = activity} = Transmogrifier.handle_incoming(update_data)
%Pleroma.Object{} = object = Object.get_by_ap_id(activity.data["object"]["id"])
assert object.data["content"] == "miaow :3"
assert object.data["likes"] == []
# in the future this should retain remote likes, but for now:
assert object.data["like_count"] == 0
end
test "doesn't trip over remote likes in polls" do
now = DateTime.utc_now()
pub_date = DateTime.to_iso8601(Timex.subtract(now, Timex.Duration.from_minutes(3)))
edit_date = DateTime.to_iso8601(now)
create_data = %{
"type" => "Create",
"id" => "http://mastodon.example.org/users/admin/statuses/2471790073/activity",
"actor" => "http://mastodon.example.org/users/admin",
"to" => ["https://www.w3.org/ns/activitystreams#Public"],
"cc" => [],
"object" => %{
"type" => "Question",
"id" => "http://mastodon.example.org/users/admin/statuses/2471790073",
"attributedTo" => "http://mastodon.example.org/users/admin",
"to" => ["https://www.w3.org/ns/activitystreams#Public"],
"cc" => [],
"published" => pub_date,
"content" => "vote!",
"anyOf" => [
%{
"type" => "Note",
"name" => "a",
"replies" => %{
"type" => "Collection",
"totalItems" => 3
}
},
%{
"type" => "Note",
"name" => "b",
"replies" => %{
"type" => "Collection",
"totalItems" => 1
}
}
],
"likes" => %{
"id" => "http://mastodon.example.org/users/admin/statuses/2471790073/likes",
"totalItems" => 0,
"type" => "Collection"
}
}
}
update_data =
create_data
|> Map.put("type", "Update")
|> Map.put("id", create_data["object"]["id"] <> "/update/1")
|> put_in(["object", "content"], "vote now!")
|> put_in(["object", "updated"], edit_date)
|> put_in(["object", "likes", "totalItems"], 666)
|> put_in(["object", "formerRepresentations"], %{
"type" => "OrderedCollection",
"totalItems" => 1,
"orderedItems" => [create_data["object"]]
})
{:ok, %Pleroma.Activity{} = activity} = Transmogrifier.handle_incoming(create_data)
%Pleroma.Object{} = object = Object.get_by_ap_id(activity.data["object"])
assert object.data["content"] == "vote!"
assert object.data["likes"] == []
assert object.data["like_count"] == 0
{:ok, %Pleroma.Activity{} = activity} = Transmogrifier.handle_incoming(update_data)
%Pleroma.Object{} = object = Object.get_by_ap_id(activity.data["object"]["id"])
assert object.data["content"] == "vote now!"
assert object.data["likes"] == []
# in the future this should retain remote likes, but for now:
assert object.data["like_count"] == 0
end
end
describe "prepare outgoing" do
test "it inlines private announced objects" do
user = insert(:user)
{:ok, activity} = CommonAPI.post(user, %{status: "hey", visibility: "private"})
{:ok, announce_activity} = CommonAPI.repeat(activity.id, user)
{:ok, modified} = Transmogrifier.prepare_outgoing(announce_activity.data)
assert modified["object"]["content"] == "hey"
assert modified["object"]["actor"] == modified["object"]["attributedTo"]
end
test "it turns mentions into tags" do
user = insert(:user)
other_user = insert(:user)
{:ok, activity} =
CommonAPI.post(user, %{status: "hey, @#{other_user.nickname}, how are ya? #2hu"})
with_mock Pleroma.Notification,
get_notified_from_activity: fn _, _ -> [] end do
{:ok, modified} = Transmogrifier.prepare_outgoing(activity.data)
object = modified["object"]
expected_mention = %{
"href" => other_user.ap_id,
"name" => "@#{other_user.nickname}",
"type" => "Mention"
}
expected_tag = %{
"href" => Pleroma.Web.Endpoint.url() <> "/tags/2hu",
"type" => "Hashtag",
"name" => "#2hu"
}
refute called(Pleroma.Notification.get_notified_from_activity(:_, :_))
assert Enum.member?(object["tag"], expected_tag)
assert Enum.member?(object["tag"], expected_mention)
end
end
test "it adds the json-ld context and the conversation property" do
user = insert(:user)
{:ok, activity} = CommonAPI.post(user, %{status: "hey"})
{:ok, modified} = Transmogrifier.prepare_outgoing(activity.data)
assert modified["@context"] == Utils.make_json_ld_header()["@context"]
assert modified["object"]["conversation"] == modified["context"]
end
test "it sets the 'attributedTo' property to the actor of the object if it doesn't have one" do
user = insert(:user)
{:ok, activity} = CommonAPI.post(user, %{status: "hey"})
{:ok, modified} = Transmogrifier.prepare_outgoing(activity.data)
assert modified["object"]["actor"] == modified["object"]["attributedTo"]
end
test "it strips internal hashtag data" do
user = insert(:user)
{:ok, activity} = CommonAPI.post(user, %{status: "#2hu"})
expected_tag = %{
"href" => Pleroma.Web.Endpoint.url() <> "/tags/2hu",
"type" => "Hashtag",
"name" => "#2hu"
}
{:ok, modified} = Transmogrifier.prepare_outgoing(activity.data)
assert modified["object"]["tag"] == [expected_tag]
end
test "it strips internal fields" do
user = insert(:user)
{:ok, activity} =
CommonAPI.post(user, %{
status: "#2hu :firefox:",
generator: %{type: "Application", name: "TestClient", url: "https://pleroma.social"}
})
# Ensure injected application data made it into the activity
# as we don't have a Token to derive it from, otherwise it will
# be nil and the test will pass
assert %{
type: "Application",
name: "TestClient",
url: "https://pleroma.social"
} == activity.object.data["generator"]
{:ok, modified} = Transmogrifier.prepare_outgoing(activity.data)
assert length(modified["object"]["tag"]) == 2
assert is_nil(modified["object"]["emoji"])
assert is_nil(modified["object"]["like_count"])
assert is_nil(modified["object"]["announcements"])
assert is_nil(modified["object"]["announcement_count"])
assert is_nil(modified["object"]["generator"])
end
test "it strips internal fields of article" do
activity = insert(:article_activity)
{:ok, modified} = Transmogrifier.prepare_outgoing(activity.data)
assert length(modified["object"]["tag"]) == 2
assert is_nil(modified["object"]["emoji"])
assert is_nil(modified["object"]["like_count"])
assert is_nil(modified["object"]["announcements"])
assert is_nil(modified["object"]["announcement_count"])
assert is_nil(modified["object"]["likes"])
end
test "the directMessage flag is present" do
user = insert(:user)
other_user = insert(:user)
{:ok, activity} = CommonAPI.post(user, %{status: "2hu :moominmamma:"})
{:ok, modified} = Transmogrifier.prepare_outgoing(activity.data)
assert modified["directMessage"] == false
{:ok, activity} = CommonAPI.post(user, %{status: "@#{other_user.nickname} :moominmamma:"})
{:ok, modified} = Transmogrifier.prepare_outgoing(activity.data)
assert modified["directMessage"] == false
{:ok, activity} =
CommonAPI.post(user, %{
status: "@#{other_user.nickname} :moominmamma:",
visibility: "direct"
})
{:ok, modified} = Transmogrifier.prepare_outgoing(activity.data)
assert modified["directMessage"] == true
end
test "it strips BCC field" do
user = insert(:user)
{:ok, list} = Pleroma.List.create("foo", user)
{:ok, activity} = CommonAPI.post(user, %{status: "foobar", visibility: "list:#{list.id}"})
{:ok, modified} = Transmogrifier.prepare_outgoing(activity.data)
assert is_nil(modified["bcc"])
end
test "it can handle Listen activities" do
listen_activity = insert(:listen)
{:ok, modified} = Transmogrifier.prepare_outgoing(listen_activity.data)
assert modified["type"] == "Listen"
user = insert(:user)
{:ok, activity} = CommonAPI.listen(user, %{"title" => "lain radio episode 1"})
{:ok, _modified} = Transmogrifier.prepare_outgoing(activity.data)
end
test "custom emoji urls are URI encoded" do
# :dinosaur: filename has a space -> dino walking.gif
user = insert(:user)
{:ok, activity} = CommonAPI.post(user, %{status: "everybody do the dinosaur :dinosaur:"})
{:ok, prepared} = Transmogrifier.prepare_outgoing(activity.data)
assert length(prepared["object"]["tag"]) == 1
url = prepared["object"]["tag"] |> List.first() |> Map.get("icon") |> Map.get("url")
assert url == "http://localhost:4001/emoji/dino%20walking.gif"
end
test "Updates of Notes are handled" do
user = insert(:user)
{:ok, activity} = CommonAPI.post(user, %{status: "everybody do the dinosaur :dinosaur:"})
{:ok, update} = CommonAPI.update(activity, user, %{status: "mew mew :blank:"})
{:ok, prepared} = Transmogrifier.prepare_outgoing(update.data)
assert %{
"content" => "mew mew :blank:",
"tag" => [%{"name" => ":blank:", "type" => "Emoji"}],
"formerRepresentations" => %{
"orderedItems" => [
%{
"content" => "everybody do the dinosaur :dinosaur:",
"tag" => [%{"name" => ":dinosaur:", "type" => "Emoji"}]
}
]
}
} = prepared["object"]
end
test "it prepares a quote post" do
user = insert(:user)
{:ok, quoted_post} = CommonAPI.post(user, %{status: "hey"})
- {:ok, quote_post} = CommonAPI.post(user, %{status: "hey", quote_id: quoted_post.id})
+ {:ok, quote_post} = CommonAPI.post(user, %{status: "hey", quoted_status_id: quoted_post.id})
{:ok, modified} = Transmogrifier.prepare_outgoing(quote_post.data)
%{data: %{"id" => quote_id}} = Object.normalize(quoted_post)
assert modified["object"]["quoteUrl"] == quote_id
assert modified["object"]["quoteUri"] == quote_id
end
test "it adds language of the object to its json-ld context" do
user = insert(:user)
{:ok, activity} = CommonAPI.post(user, %{status: "Cześć", language: "pl"})
{:ok, modified} = Transmogrifier.prepare_outgoing(activity.object.data)
assert [_, _, %{"@language" => "pl"}] = modified["@context"]
end
test "it adds language of the object to Create activity json-ld context" do
user = insert(:user)
{:ok, activity} = CommonAPI.post(user, %{status: "Cześć", language: "pl"})
{:ok, modified} = Transmogrifier.prepare_outgoing(activity.data)
assert [_, _, %{"@language" => "pl"}] = modified["@context"]
end
test "it strips report data" do
reporter = insert(:user)
target_account = insert(:user)
content = "foobar"
{:ok, reported_activity} = CommonAPI.post(target_account, %{status: content})
context = Utils.generate_context_id()
object_ap_id = reported_activity.object.data["id"]
assert {:ok, activity} =
Pleroma.Web.ActivityPub.ActivityPub.flag(%{
actor: reporter,
context: context,
account: target_account,
statuses: [reported_activity],
content: content
})
{:ok, data} = Transmogrifier.prepare_outgoing(activity.data)
expected_data =
activity.data
|> put_in(["object"], [target_account.ap_id, object_ap_id])
|> Map.put("actor", reporter.ap_id)
|> Map.merge(Utils.make_json_ld_header())
assert data == expected_data
end
test "it strips report data and anonymize" do
placeholder = insert(:user)
reporter = insert(:user)
target_account = insert(:user)
content = "foobar"
{:ok, reported_activity} = CommonAPI.post(target_account, %{status: content})
context = Utils.generate_context_id()
object_ap_id = reported_activity.object.data["id"]
assert {:ok, activity} =
Pleroma.Web.ActivityPub.ActivityPub.flag(%{
actor: reporter,
context: context,
account: target_account,
statuses: [reported_activity],
content: content
})
clear_config([:activitypub, :anonymize_reporter], true)
clear_config([:activitypub, :anonymize_reporter_local_nickname], placeholder.nickname)
{:ok, data} = Transmogrifier.prepare_outgoing(activity.data)
expected_data =
activity.data
|> put_in(["object"], [target_account.ap_id, object_ap_id])
|> Map.put("actor", placeholder.ap_id)
|> Map.merge(Utils.make_json_ld_header())
assert data == expected_data
end
end
describe "actor rewriting" do
test "it fixes the actor URL property to be a proper URI" do
data = %{
"url" => %{"href" => "http://example.com"}
}
rewritten = Transmogrifier.maybe_fix_user_object(data)
assert rewritten["url"] == "http://example.com"
end
end
describe "actor origin containment" do
test "it rejects activities which reference objects with bogus origins" do
data = %{
"@context" => "https://www.w3.org/ns/activitystreams",
"id" => "http://mastodon.example.org/users/admin/activities/1234",
"actor" => "http://mastodon.example.org/users/admin",
"to" => ["https://www.w3.org/ns/activitystreams#Public"],
"object" => "https://info.pleroma.site/activity.json",
"type" => "Announce"
}
assert capture_log(fn ->
{:error, _} = Transmogrifier.handle_incoming(data)
end) =~ "Object rejected while fetching"
end
test "it rejects activities which reference objects that have an incorrect attribution (variant 1)" do
data = %{
"@context" => "https://www.w3.org/ns/activitystreams",
"id" => "http://mastodon.example.org/users/admin/activities/1234",
"actor" => "http://mastodon.example.org/users/admin",
"to" => ["https://www.w3.org/ns/activitystreams#Public"],
"object" => "https://info.pleroma.site/activity2.json",
"type" => "Announce"
}
assert capture_log(fn ->
{:error, _} = Transmogrifier.handle_incoming(data)
end) =~ "Object rejected while fetching"
end
test "it rejects activities which reference objects that have an incorrect attribution (variant 2)" do
data = %{
"@context" => "https://www.w3.org/ns/activitystreams",
"id" => "http://mastodon.example.org/users/admin/activities/1234",
"actor" => "http://mastodon.example.org/users/admin",
"to" => ["https://www.w3.org/ns/activitystreams#Public"],
"object" => "https://info.pleroma.site/activity3.json",
"type" => "Announce"
}
assert capture_log(fn ->
{:error, _} = Transmogrifier.handle_incoming(data)
end) =~ "Object rejected while fetching"
end
end
describe "fix_explicit_addressing" do
setup do
user = insert(:user)
[user: user]
end
test "moves non-explicitly mentioned actors to cc", %{user: user} do
explicitly_mentioned_actors = [
"https://pleroma.gold/users/user1",
"https://pleroma.gold/user2"
]
object = %{
"actor" => user.ap_id,
"to" => explicitly_mentioned_actors ++ ["https://social.beepboop.ga/users/dirb"],
"cc" => [],
"tag" =>
Enum.map(explicitly_mentioned_actors, fn href ->
%{"type" => "Mention", "href" => href}
end)
}
fixed_object = Transmogrifier.fix_explicit_addressing(object, user.follower_address)
assert Enum.all?(explicitly_mentioned_actors, &(&1 in fixed_object["to"]))
refute "https://social.beepboop.ga/users/dirb" in fixed_object["to"]
assert "https://social.beepboop.ga/users/dirb" in fixed_object["cc"]
end
test "does not move actor's follower collection to cc", %{user: user} do
object = %{
"actor" => user.ap_id,
"to" => [user.follower_address],
"cc" => []
}
fixed_object = Transmogrifier.fix_explicit_addressing(object, user.follower_address)
assert user.follower_address in fixed_object["to"]
refute user.follower_address in fixed_object["cc"]
end
test "removes recipient's follower collection from cc", %{user: user} do
recipient = insert(:user)
object = %{
"actor" => user.ap_id,
"to" => [recipient.ap_id, "https://www.w3.org/ns/activitystreams#Public"],
"cc" => [user.follower_address, recipient.follower_address]
}
fixed_object = Transmogrifier.fix_explicit_addressing(object, user.follower_address)
assert user.follower_address in fixed_object["cc"]
refute recipient.follower_address in fixed_object["cc"]
refute recipient.follower_address in fixed_object["to"]
end
end
describe "fix_summary/1" do
test "returns fixed object" do
assert Transmogrifier.fix_summary(%{"summary" => nil}) == %{"summary" => ""}
assert Transmogrifier.fix_summary(%{"summary" => "ok"}) == %{"summary" => "ok"}
assert Transmogrifier.fix_summary(%{}) == %{"summary" => ""}
end
end
describe "fix_url/1" do
test "fixes data for object when url is map" do
object = %{
"url" => %{
"type" => "Link",
"mimeType" => "video/mp4",
"href" => "https://peede8d-46fb-ad81-2d4c2d1630e3-480.mp4"
}
}
assert Transmogrifier.fix_url(object) == %{
"url" => "https://peede8d-46fb-ad81-2d4c2d1630e3-480.mp4"
}
end
test "returns non-modified object" do
assert Transmogrifier.fix_url(%{"type" => "Text"}) == %{"type" => "Text"}
end
end
describe "get_obj_helper/2" do
test "returns nil when cannot normalize object" do
assert capture_log(fn ->
refute Transmogrifier.get_obj_helper("test-obj-id")
end) =~ "Unsupported URI scheme"
end
test "returns {:ok, %Object{}} for success case" do
assert {:ok, %Object{}} =
Transmogrifier.get_obj_helper(
"https://mstdn.io/users/mayuutann/statuses/99568293732299394"
)
end
end
describe "fix_attachments/1" do
test "puts dimensions into attachment url field" do
object = %{
"attachment" => [
%{
"type" => "Document",
"name" => "Hello world",
"url" => "https://media.example.tld/1.jpg",
"width" => 880,
"height" => 960,
"mediaType" => "image/jpeg",
"blurhash" => "eTKL26+HDjcEIBVl;ds+K6t301W.t7nit7y1E,R:v}ai4nXSt7V@of"
}
]
}
expected = %{
"attachment" => [
%{
"type" => "Document",
"name" => "Hello world",
"url" => [
%{
"type" => "Link",
"mediaType" => "image/jpeg",
"href" => "https://media.example.tld/1.jpg",
"width" => 880,
"height" => 960
}
],
"mediaType" => "image/jpeg",
"blurhash" => "eTKL26+HDjcEIBVl;ds+K6t301W.t7nit7y1E,R:v}ai4nXSt7V@of"
}
]
}
assert Transmogrifier.fix_attachments(object) == expected
end
end
describe "prepare_object/1" do
test "it processes history" do
original = %{
"formerRepresentations" => %{
"orderedItems" => [
%{
"generator" => %{},
"emoji" => %{"blobcat" => "http://localhost:4001/emoji/blobcat.png"}
}
]
}
}
processed = Transmogrifier.prepare_object(original)
history_item = Enum.at(processed["formerRepresentations"]["orderedItems"], 0)
refute Map.has_key?(history_item, "generator")
assert [%{"name" => ":blobcat:"}] = history_item["tag"]
end
test "it works when there is no or bad history" do
original = %{
"formerRepresentations" => %{
"items" => [
%{
"generator" => %{},
"emoji" => %{"blobcat" => "http://localhost:4001/emoji/blobcat.png"}
}
]
}
}
processed = Transmogrifier.prepare_object(original)
assert processed["formerRepresentations"] == original["formerRepresentations"]
end
test "it uses contentMap to specify post language" do
user = insert(:user)
{:ok, activity} = CommonAPI.post(user, %{status: "Cześć", language: "pl"})
object = Transmogrifier.prepare_object(activity.object.data)
assert %{"contentMap" => %{"pl" => "Cześć"}} = object
end
end
end
diff --git a/test/pleroma/web/common_api/activity_draft_test.exs b/test/pleroma/web/common_api/activity_draft_test.exs
index 02bc6cf3b..2a7c2d399 100644
--- a/test/pleroma/web/common_api/activity_draft_test.exs
+++ b/test/pleroma/web/common_api/activity_draft_test.exs
@@ -1,33 +1,39 @@
# Pleroma: A lightweight social networking server
# Copyright © 2017-2021 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Web.CommonAPI.ActivityDraftTest do
use Pleroma.DataCase
alias Pleroma.Web.CommonAPI
alias Pleroma.Web.CommonAPI.ActivityDraft
import Pleroma.Factory
test "create/2 with a quote post" do
user = insert(:user)
another_user = insert(:user)
{:ok, direct} = CommonAPI.post(user, %{status: ".", visibility: "direct"})
{:ok, private} = CommonAPI.post(user, %{status: ".", visibility: "private"})
{:ok, unlisted} = CommonAPI.post(user, %{status: ".", visibility: "unlisted"})
{:ok, local} = CommonAPI.post(user, %{status: ".", visibility: "local"})
{:ok, public} = CommonAPI.post(user, %{status: ".", visibility: "public"})
- {:error, _} = ActivityDraft.create(user, %{status: "nice", quote_id: direct.id})
- {:ok, _} = ActivityDraft.create(user, %{status: "nice", quote_id: private.id})
- {:error, _} = ActivityDraft.create(another_user, %{status: "nice", quote_id: private.id})
- {:ok, _} = ActivityDraft.create(user, %{status: "nice", quote_id: unlisted.id})
- {:ok, _} = ActivityDraft.create(another_user, %{status: "nice", quote_id: unlisted.id})
- {:ok, _} = ActivityDraft.create(user, %{status: "nice", quote_id: local.id})
- {:ok, _} = ActivityDraft.create(another_user, %{status: "nice", quote_id: local.id})
- {:ok, _} = ActivityDraft.create(user, %{status: "nice", quote_id: public.id})
- {:ok, _} = ActivityDraft.create(another_user, %{status: "nice", quote_id: public.id})
+ {:error, _} = ActivityDraft.create(user, %{status: "nice", quoted_status_id: direct.id})
+ {:ok, _} = ActivityDraft.create(user, %{status: "nice", quoted_status_id: private.id})
+
+ {:error, _} =
+ ActivityDraft.create(another_user, %{status: "nice", quoted_status_id: private.id})
+
+ {:ok, _} = ActivityDraft.create(user, %{status: "nice", quoted_status_id: unlisted.id})
+
+ {:ok, _} =
+ ActivityDraft.create(another_user, %{status: "nice", quoted_status_id: unlisted.id})
+
+ {:ok, _} = ActivityDraft.create(user, %{status: "nice", quoted_status_id: local.id})
+ {:ok, _} = ActivityDraft.create(another_user, %{status: "nice", quoted_status_id: local.id})
+ {:ok, _} = ActivityDraft.create(user, %{status: "nice", quoted_status_id: public.id})
+ {:ok, _} = ActivityDraft.create(another_user, %{status: "nice", quoted_status_id: public.id})
end
end
diff --git a/test/pleroma/web/common_api_test.exs b/test/pleroma/web/common_api_test.exs
index 6b5d31537..9fc132610 100644
--- a/test/pleroma/web/common_api_test.exs
+++ b/test/pleroma/web/common_api_test.exs
@@ -1,2148 +1,2162 @@
# Pleroma: A lightweight social networking server
# Copyright © 2017-2022 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Web.CommonAPITest do
use Oban.Testing, repo: Pleroma.Repo
use Pleroma.DataCase, async: false
alias Pleroma.Activity
alias Pleroma.Chat
alias Pleroma.Conversation.Participation
alias Pleroma.Notification
alias Pleroma.Object
alias Pleroma.Repo
alias Pleroma.Rule
alias Pleroma.Tests.ObanHelpers
alias Pleroma.UnstubbedConfigMock, as: ConfigMock
alias Pleroma.User
alias Pleroma.Web.ActivityPub.ActivityPub
alias Pleroma.Web.ActivityPub.Transmogrifier
alias Pleroma.Web.ActivityPub.Visibility
alias Pleroma.Web.AdminAPI.AccountView
alias Pleroma.Web.CommonAPI
alias Pleroma.Workers.PollWorker
import Ecto.Query, only: [from: 2, where: 3]
import Mock
import Mox
import Pleroma.Factory
require Pleroma.Activity.Queries
require Pleroma.Constants
defp get_announces_of_object(%{data: %{"id" => id}} = _object) do
Pleroma.Activity.Queries.by_type("Announce")
|> Pleroma.Activity.Queries.by_object_id(id)
|> Pleroma.Repo.all()
end
setup_all do
Tesla.Mock.mock_global(fn env -> apply(HttpRequestMock, :request, [env]) end)
:ok
end
setup do
ConfigMock
|> stub_with(Pleroma.Test.StaticConfig)
:ok
end
setup do: clear_config([:instance, :safe_dm_mentions])
setup do: clear_config([:instance, :limit])
setup do: clear_config([:instance, :max_pinned_statuses])
describe "posting polls" do
test "it posts a poll" do
user = insert(:user)
{:ok, activity} =
CommonAPI.post(user, %{
status: "who is the best",
poll: %{expires_in: 600, options: ["reimu", "marisa"]}
})
object = Object.normalize(activity, fetch: false)
assert object.data["type"] == "Question"
assert object.data["oneOf"] |> length() == 2
assert_enqueued(
worker: PollWorker,
args: %{op: "poll_end", activity_id: activity.id},
scheduled_at: NaiveDateTime.from_iso8601!(object.data["closed"])
)
end
end
describe "blocking" do
setup do
blocker = insert(:user)
blocked = insert(:user, local: false)
CommonAPI.follow(blocked, blocker)
CommonAPI.follow(blocker, blocked)
CommonAPI.accept_follow_request(blocker, blocked)
CommonAPI.accept_follow_request(blocked, blocked)
%{blocker: blocker, blocked: blocked}
end
test "it blocks and federates", %{blocker: blocker, blocked: blocked} do
clear_config([:instance, :federating], true)
with_mock Pleroma.Web.Federator,
publish: fn _ -> nil end do
assert User.get_follow_state(blocker, blocked) == :follow_accept
refute is_nil(Pleroma.Web.ActivityPub.Utils.fetch_latest_follow(blocker, blocked))
assert {:ok, block} = CommonAPI.block(blocked, blocker)
assert block.local
assert User.blocks?(blocker, blocked)
refute User.following?(blocker, blocked)
refute User.following?(blocked, blocker)
refute User.get_follow_state(blocker, blocked)
assert %{data: %{"state" => "reject"}} =
Pleroma.Web.ActivityPub.Utils.fetch_latest_follow(blocker, blocked)
assert called(Pleroma.Web.Federator.publish(block))
end
end
test "add expiring block", %{blocker: blocker, blocked: blocked} do
{:ok, _} = CommonAPI.block(blocked, blocker, %{expires_in: 60})
assert User.blocks?(blocker, blocked)
worker = Pleroma.Workers.MuteExpireWorker
args = %{"op" => "unblock_user", "blocker_id" => blocker.id, "blocked_id" => blocked.id}
assert :ok = perform_job(worker, args)
refute User.blocks?(blocker, blocked)
end
test "it blocks and does not federate if outgoing blocks are disabled", %{
blocker: blocker,
blocked: blocked
} do
clear_config([:instance, :federating], true)
clear_config([:activitypub, :outgoing_blocks], false)
with_mock Pleroma.Web.Federator,
publish: fn _ -> nil end do
assert {:ok, block} = CommonAPI.block(blocked, blocker)
assert block.local
assert User.blocks?(blocker, blocked)
refute User.following?(blocker, blocked)
refute User.following?(blocked, blocker)
refute called(Pleroma.Web.Federator.publish(block))
end
end
end
describe "posting chat messages" do
setup do: clear_config([:instance, :chat_limit])
test "it posts a self-chat" do
author = insert(:user)
recipient = author
{:ok, activity} =
CommonAPI.post_chat_message(
author,
recipient,
"remember to buy milk when milk truk arive"
)
assert activity.data["type"] == "Create"
end
test "it posts a chat message without content but with an attachment" do
author = insert(:user)
recipient = insert(:user)
file = %Plug.Upload{
content_type: "image/jpeg",
path: Path.absname("test/fixtures/image.jpg"),
filename: "an_image.jpg"
}
{:ok, upload} = ActivityPub.upload(file, actor: author.ap_id)
with_mocks([
{
Pleroma.Web.Streamer,
[],
[
stream: fn _, _ ->
nil
end
]
},
{
Pleroma.Web.Push,
[],
[
send: fn _ -> nil end
]
}
]) do
{:ok, activity} =
CommonAPI.post_chat_message(
author,
recipient,
nil,
media_id: upload.id
)
notification =
Notification.for_user_and_activity(recipient, activity)
|> Repo.preload(:activity)
assert called(Pleroma.Web.Push.send(notification))
assert called(Pleroma.Web.Streamer.stream(["user", "user:notification"], notification))
assert called(Pleroma.Web.Streamer.stream(["user", "user:pleroma_chat"], :_))
assert activity
end
end
test "it adds html newlines" do
author = insert(:user)
recipient = insert(:user)
other_user = insert(:user)
{:ok, activity} =
CommonAPI.post_chat_message(
author,
recipient,
"uguu\nuguuu"
)
assert other_user.ap_id not in activity.recipients
object = Object.normalize(activity, fetch: false)
assert object.data["content"] == "uguu<br/>uguuu"
end
test "it linkifies" do
author = insert(:user)
recipient = insert(:user)
other_user = insert(:user)
{:ok, activity} =
CommonAPI.post_chat_message(
author,
recipient,
"https://example.org is the site of @#{other_user.nickname} #2hu"
)
assert other_user.ap_id not in activity.recipients
object = Object.normalize(activity, fetch: false)
assert object.data["content"] ==
"<a href=\"https://example.org\" rel=\"ugc\">https://example.org</a> is the site of <span class=\"h-card\"><a class=\"u-url mention\" data-user=\"#{other_user.id}\" href=\"#{other_user.ap_id}\" rel=\"ugc\">@<span>#{other_user.nickname}</span></a></span> <a class=\"hashtag\" data-tag=\"2hu\" href=\"http://localhost:4001/tag/2hu\">#2hu</a>"
end
test "it posts a chat message" do
author = insert(:user)
recipient = insert(:user)
{:ok, activity} =
CommonAPI.post_chat_message(
author,
recipient,
"a test message <script>alert('uuu')</script> :firefox:"
)
assert activity.data["type"] == "Create"
assert activity.local
object = Object.normalize(activity, fetch: false)
assert object.data["type"] == "ChatMessage"
assert object.data["to"] == [recipient.ap_id]
assert object.data["content"] ==
"a test message &lt;script&gt;alert(&#39;uuu&#39;)&lt;/script&gt; :firefox:"
assert object.data["emoji"] == %{
"firefox" => "http://localhost:4001/emoji/Firefox.gif"
}
assert Chat.get(author.id, recipient.ap_id)
assert Chat.get(recipient.id, author.ap_id)
assert :ok == Pleroma.Web.Federator.perform(:publish, activity)
end
test "it reject messages over the local limit" do
clear_config([:instance, :chat_limit], 2)
author = insert(:user)
recipient = insert(:user)
{:error, message} =
CommonAPI.post_chat_message(
author,
recipient,
"123"
)
assert message == :content_too_long
end
test "it reject messages via MRF" do
clear_config([:mrf_keyword, :reject], ["GNO"])
clear_config([:mrf, :policies], [Pleroma.Web.ActivityPub.MRF.KeywordPolicy])
author = insert(:user)
recipient = insert(:user)
assert {:reject, "[KeywordPolicy] Matches with rejected keyword"} ==
CommonAPI.post_chat_message(author, recipient, "GNO/Linux")
end
test "it reject messages with attachments not belonging to user" do
author = insert(:user)
not_author = insert(:user)
recipient = author
attachment = insert(:attachment, %{user: not_author})
{:error, message} =
CommonAPI.post_chat_message(
author,
recipient,
"123",
media_id: attachment.id
)
assert message == :forbidden
end
end
describe "unblocking" do
test "it works even without an existing block activity" do
blocked = insert(:user)
blocker = insert(:user)
User.block(blocker, blocked)
assert User.blocks?(blocker, blocked)
assert {:ok, :no_activity} == CommonAPI.unblock(blocked, blocker)
refute User.blocks?(blocker, blocked)
end
end
describe "deletion" do
test "it works with pruned objects" do
user = insert(:user)
{:ok, post} = CommonAPI.post(user, %{status: "namu amida butsu"})
clear_config([:instance, :federating], true)
Object.normalize(post, fetch: false)
|> Object.prune()
with_mock Pleroma.Web.Federator,
publish: fn _ -> nil end do
assert {:ok, delete} = CommonAPI.delete(post.id, user)
assert delete.local
assert called(Pleroma.Web.Federator.publish(delete))
end
refute Activity.get_by_id(post.id)
end
test "it allows users to delete their posts" do
user = insert(:user)
{:ok, post} = CommonAPI.post(user, %{status: "namu amida butsu"})
clear_config([:instance, :federating], true)
with_mock Pleroma.Web.Federator,
publish: fn _ -> nil end do
assert {:ok, delete} = CommonAPI.delete(post.id, user)
assert delete.local
assert called(Pleroma.Web.Federator.publish(delete))
end
refute Activity.get_by_id(post.id)
end
test "it does not allow a user to delete posts from another user" do
user = insert(:user)
other_user = insert(:user)
{:ok, post} = CommonAPI.post(user, %{status: "namu amida butsu"})
assert {:error, "Could not delete"} = CommonAPI.delete(post.id, other_user)
assert Activity.get_by_id(post.id)
end
test "it allows privileged users to delete other user's posts" do
clear_config([:instance, :moderator_privileges], [:messages_delete])
user = insert(:user)
moderator = insert(:user, is_moderator: true)
{:ok, post} = CommonAPI.post(user, %{status: "namu amida butsu"})
assert {:ok, delete} = CommonAPI.delete(post.id, moderator)
assert delete.local
refute Activity.get_by_id(post.id)
end
test "it doesn't allow unprivileged mods or admins to delete other user's posts" do
clear_config([:instance, :admin_privileges], [])
clear_config([:instance, :moderator_privileges], [])
user = insert(:user)
moderator = insert(:user, is_moderator: true, is_admin: true)
{:ok, post} = CommonAPI.post(user, %{status: "namu amida butsu"})
assert {:error, "Could not delete"} = CommonAPI.delete(post.id, moderator)
assert Activity.get_by_id(post.id)
end
test "privileged users deleting non-local posts won't federate the delete" do
clear_config([:instance, :admin_privileges], [:messages_delete])
# This is the user of the ingested activity
_user =
insert(:user,
local: false,
ap_id: "http://mastodon.example.org/users/admin",
last_refreshed_at: NaiveDateTime.utc_now()
)
admin = insert(:user, is_admin: true)
data =
File.read!("test/fixtures/mastodon-post-activity.json")
|> Jason.decode!()
{:ok, post} = Transmogrifier.handle_incoming(data)
with_mock Pleroma.Web.Federator,
publish: fn _ -> nil end do
assert {:ok, delete} = CommonAPI.delete(post.id, admin)
assert delete.local
refute called(Pleroma.Web.Federator.publish(:_))
end
refute Activity.get_by_id(post.id)
end
test "it allows privileged users to delete banned user's posts" do
clear_config([:instance, :moderator_privileges], [:messages_delete])
user = insert(:user)
moderator = insert(:user, is_moderator: true)
{:ok, post} = CommonAPI.post(user, %{status: "namu amida butsu"})
User.set_activation(user, false)
assert {:ok, delete} = CommonAPI.delete(post.id, moderator)
assert delete.local
refute Activity.get_by_id(post.id)
end
end
test "favoriting race condition" do
user = insert(:user)
users_serial = insert_list(10, :user)
users = insert_list(10, :user)
{:ok, activity} = CommonAPI.post(user, %{status: "."})
users_serial
|> Enum.map(fn user ->
CommonAPI.favorite(activity.id, user)
end)
object = Object.get_by_ap_id(activity.data["object"])
assert object.data["like_count"] == 10
users
|> Enum.map(fn user ->
Task.async(fn ->
CommonAPI.favorite(activity.id, user)
end)
end)
|> Enum.map(&Task.await/1)
object = Object.get_by_ap_id(activity.data["object"])
assert object.data["like_count"] == 20
end
test "repeating race condition" do
user = insert(:user)
users_serial = insert_list(10, :user)
users = insert_list(10, :user)
{:ok, activity} = CommonAPI.post(user, %{status: "."})
users_serial
|> Enum.map(fn user ->
CommonAPI.repeat(activity.id, user)
end)
object = Object.get_by_ap_id(activity.data["object"])
assert object.data["announcement_count"] == 10
users
|> Enum.map(fn user ->
Task.async(fn ->
CommonAPI.repeat(activity.id, user)
end)
end)
|> Enum.map(&Task.await/1)
object = Object.get_by_ap_id(activity.data["object"])
assert object.data["announcement_count"] == 20
end
test "when replying to a conversation / participation, it will set the correct context id even if no explicit reply_to is given" do
user = insert(:user)
{:ok, activity} = CommonAPI.post(user, %{status: ".", visibility: "direct"})
[participation] = Participation.for_user(user)
{:ok, convo_reply} =
CommonAPI.post(user, %{status: ".", in_reply_to_conversation_id: participation.id})
assert Visibility.direct?(convo_reply)
assert activity.data["context"] == convo_reply.data["context"]
end
test "when replying to a conversation / participation, it only mentions the recipients explicitly declared in the participation" do
har = insert(:user)
jafnhar = insert(:user)
tridi = insert(:user)
{:ok, activity} =
CommonAPI.post(har, %{
status: "@#{jafnhar.nickname} hey",
visibility: "direct"
})
assert har.ap_id in activity.recipients
assert jafnhar.ap_id in activity.recipients
[participation] = Participation.for_user(har)
{:ok, activity} =
CommonAPI.post(har, %{
status: "I don't really like @#{tridi.nickname}",
visibility: "direct",
in_reply_to_status_id: activity.id,
in_reply_to_conversation_id: participation.id
})
assert har.ap_id in activity.recipients
assert jafnhar.ap_id in activity.recipients
refute tridi.ap_id in activity.recipients
end
test "with the safe_dm_mention option set, it does not mention people beyond the initial tags" do
har = insert(:user)
jafnhar = insert(:user)
tridi = insert(:user)
clear_config([:instance, :safe_dm_mentions], true)
{:ok, activity} =
CommonAPI.post(har, %{
status: "@#{jafnhar.nickname} hey, i never want to see @#{tridi.nickname} again",
visibility: "direct"
})
refute tridi.ap_id in activity.recipients
assert jafnhar.ap_id in activity.recipients
end
test "it de-duplicates tags" do
user = insert(:user)
{:ok, activity} = CommonAPI.post(user, %{status: "#2hu #2HU"})
object = Object.normalize(activity, fetch: false)
assert Object.tags(object) == ["2hu"]
end
test "zwnj is treated as word character" do
user = insert(:user)
{:ok, activity} = CommonAPI.post(user, %{status: "#ساٴين‌س"})
object = Object.normalize(activity, fetch: false)
assert Object.tags(object) == ["ساٴين‌س"]
end
test "allows lang attribute" do
user = insert(:user)
text = ~s{<span lang="en">something</span><p lang="diaetuitech_rpyhpgc">random</p>}
{:ok, activity} = CommonAPI.post(user, %{status: text, content_type: "text/html"})
object = Object.normalize(activity, fetch: false)
assert object.data["content"] == text
end
test "double dot in link is allowed" do
user = insert(:user)
text = "https://example.to/something..mp3"
{:ok, activity} = CommonAPI.post(user, %{status: text})
object = Object.normalize(activity, fetch: false)
assert object.data["content"] == "<a href=\"#{text}\" rel=\"ugc\">#{text}</a>"
end
test "it adds emoji in the object" do
user = insert(:user)
{:ok, activity} = CommonAPI.post(user, %{status: ":firefox:"})
assert Object.normalize(activity, fetch: false).data["emoji"]["firefox"]
end
describe "posting" do
test "it adds an emoji on an external site" do
user = insert(:user)
{:ok, activity} = CommonAPI.post(user, %{status: "hey :external_emoji:"})
assert %{"external_emoji" => url} = Object.normalize(activity).data["emoji"]
assert url == "https://example.com/emoji.png"
{:ok, activity} = CommonAPI.post(user, %{status: "hey :blank:"})
assert %{"blank" => url} = Object.normalize(activity).data["emoji"]
assert url == "#{Pleroma.Web.Endpoint.url()}/emoji/blank.png"
end
test "it copies emoji from the subject of the parent post" do
%Object{} =
object =
Object.normalize("https://patch.cx/objects/a399c28e-c821-4820-bc3e-4afeb044c16f",
fetch: true
)
activity = Activity.get_create_by_object_ap_id(object.data["id"])
user = insert(:user)
{:ok, reply_activity} =
CommonAPI.post(user, %{
in_reply_to_id: activity.id,
status: ":joker_disapprove:",
spoiler_text: ":joker_smile:"
})
assert Object.normalize(reply_activity).data["emoji"]["joker_smile"]
refute Object.normalize(reply_activity).data["emoji"]["joker_disapprove"]
end
test "deactivated users can't post" do
user = insert(:user, is_active: false)
assert {:error, _} = CommonAPI.post(user, %{status: "ye"})
end
test "it supports explicit addressing" do
user = insert(:user)
user_two = insert(:user)
user_three = insert(:user)
user_four = insert(:user)
{:ok, activity} =
CommonAPI.post(user, %{
status:
"Hey, I think @#{user_three.nickname} is ugly. @#{user_four.nickname} is alright though.",
to: [user_two.nickname, user_four.nickname, "nonexistent"]
})
assert user.ap_id in activity.recipients
assert user_two.ap_id in activity.recipients
assert user_four.ap_id in activity.recipients
refute user_three.ap_id in activity.recipients
end
test "it filters out obviously bad tags when accepting a post as HTML" do
user = insert(:user)
post = "<p><b>2hu</b></p><script>alert('xss')</script>"
{:ok, activity} =
CommonAPI.post(user, %{
status: post,
content_type: "text/html"
})
object = Object.normalize(activity, fetch: false)
assert object.data["content"] == "<p><b>2hu</b></p>alert(&#39;xss&#39;)"
assert object.data["source"]["content"] == post
end
test "it filters out obviously bad tags when accepting a post as Markdown" do
user = insert(:user)
post = "<p><b>2hu</b></p><script>alert('xss')</script>"
{:ok, activity} =
CommonAPI.post(user, %{
status: post,
content_type: "text/markdown"
})
object = Object.normalize(activity, fetch: false)
assert object.data["content"] == "<p><b>2hu</b></p>"
assert object.data["source"]["content"] == post
end
test "it does not allow replies to direct messages that are not direct messages themselves" do
user = insert(:user)
{:ok, activity} = CommonAPI.post(user, %{status: "suya..", visibility: "direct"})
assert {:ok, _} =
CommonAPI.post(user, %{
status: "suya..",
visibility: "direct",
in_reply_to_status_id: activity.id
})
Enum.each(["public", "private", "unlisted"], fn visibility ->
assert {:error, "The message visibility must be direct"} =
CommonAPI.post(user, %{
status: "suya..",
visibility: visibility,
in_reply_to_status_id: activity.id
})
end)
end
test "replying with a direct message will NOT auto-add the author of the reply to the recipient list" do
user = insert(:user)
other_user = insert(:user)
third_user = insert(:user)
{:ok, post} = CommonAPI.post(user, %{status: "I'm stupid"})
{:ok, open_answer} =
CommonAPI.post(other_user, %{status: "No ur smart", in_reply_to_status_id: post.id})
# The OP is implicitly added
assert user.ap_id in open_answer.recipients
{:ok, secret_answer} =
CommonAPI.post(other_user, %{
status: "lol, that guy really is stupid, right, @#{third_user.nickname}?",
in_reply_to_status_id: post.id,
visibility: "direct"
})
assert third_user.ap_id in secret_answer.recipients
# The OP is not added
refute user.ap_id in secret_answer.recipients
end
test "it allows to address a list" do
user = insert(:user)
{:ok, list} = Pleroma.List.create("foo", user)
{:ok, activity} = CommonAPI.post(user, %{status: "foobar", visibility: "list:#{list.id}"})
assert activity.data["bcc"] == [list.ap_id]
assert activity.recipients == [list.ap_id, user.ap_id]
assert activity.data["listMessage"] == list.ap_id
end
test "it returns error when status is empty and no attachments" do
user = insert(:user)
assert {:error, "Cannot post an empty status without attachments"} =
CommonAPI.post(user, %{status: ""})
end
test "it validates character limits are correctly enforced" do
clear_config([:instance, :limit], 5)
user = insert(:user)
assert {:error, "The status is over the character limit"} =
CommonAPI.post(user, %{status: "foobar"})
assert {:ok, _activity} = CommonAPI.post(user, %{status: "12345"})
end
test "it validates media attachment limits are correctly enforced" do
clear_config([:instance, :max_media_attachments], 4)
user = insert(:user)
file = %Plug.Upload{
content_type: "image/jpeg",
path: Path.absname("test/fixtures/image.jpg"),
filename: "an_image.jpg"
}
{:ok, upload} = ActivityPub.upload(file, actor: user.ap_id)
assert {:error, "Too many attachments"} =
CommonAPI.post(user, %{
status: "",
media_ids: List.duplicate(upload.id, 5)
})
assert {:ok, _activity} =
CommonAPI.post(user, %{
status: "",
media_ids: [upload.id]
})
end
test "it can handle activities that expire" do
user = insert(:user)
expires_at = DateTime.add(DateTime.utc_now(), 1_000_000)
assert {:ok, activity} = CommonAPI.post(user, %{status: "chai", expires_in: 1_000_000})
assert_enqueued(
worker: Pleroma.Workers.PurgeExpiredActivity,
args: %{activity_id: activity.id},
scheduled_at: expires_at
)
end
test "it allows quote posting" do
user = insert(:user)
{:ok, quoted} = CommonAPI.post(user, %{status: "Hello world"})
- {:ok, quote_post} = CommonAPI.post(user, %{status: "nice post", quote_id: quoted.id})
+
+ {:ok, quote_post} =
+ CommonAPI.post(user, %{status: "nice post", quoted_status_id: quoted.id})
quoted = Object.normalize(quoted)
quote_post = Object.normalize(quote_post)
assert quote_post.data["quoteUrl"] == quoted.data["id"]
# The OP is not mentioned
refute quoted.data["actor"] in quote_post.data["to"]
end
+ test "it supports fallback from `quote_id`" do
+ user = insert(:user)
+
+ {:ok, quoted} = CommonAPI.post(user, %{status: "Hello world"})
+ {:ok, quote_post} = CommonAPI.post(user, %{status: "nice post", quote_id: quoted.id})
+
+ quoted = Object.normalize(quoted)
+ quote_post = Object.normalize(quote_post)
+
+ assert quote_post.data["quoteUrl"] == quoted.data["id"]
+ end
+
test "quote posting with explicit addressing doesn't mention the OP" do
user = insert(:user)
{:ok, quoted} = CommonAPI.post(user, %{status: "Hello world"})
{:ok, quote_post} =
- CommonAPI.post(user, %{status: "nice post", quote_id: quoted.id, to: []})
+ CommonAPI.post(user, %{status: "nice post", quoted_status_id: quoted.id, to: []})
assert Object.normalize(quote_post).data["to"] == [Pleroma.Constants.as_public()]
end
test "quote posting visibility" do
user = insert(:user)
another_user = insert(:user)
{:ok, direct} = CommonAPI.post(user, %{status: ".", visibility: "direct"})
{:ok, private} = CommonAPI.post(user, %{status: ".", visibility: "private"})
{:ok, unlisted} = CommonAPI.post(user, %{status: ".", visibility: "unlisted"})
{:ok, local} = CommonAPI.post(user, %{status: ".", visibility: "local"})
{:ok, public} = CommonAPI.post(user, %{status: ".", visibility: "public"})
- {:error, _} = CommonAPI.post(user, %{status: "nice", quote_id: direct.id})
- {:ok, _} = CommonAPI.post(user, %{status: "nice", quote_id: private.id})
- {:error, _} = CommonAPI.post(another_user, %{status: "nice", quote_id: private.id})
- {:ok, _} = CommonAPI.post(user, %{status: "nice", quote_id: unlisted.id})
- {:ok, _} = CommonAPI.post(another_user, %{status: "nice", quote_id: unlisted.id})
- {:ok, _} = CommonAPI.post(user, %{status: "nice", quote_id: local.id})
- {:ok, _} = CommonAPI.post(another_user, %{status: "nice", quote_id: local.id})
- {:ok, _} = CommonAPI.post(user, %{status: "nice", quote_id: public.id})
- {:ok, _} = CommonAPI.post(another_user, %{status: "nice", quote_id: public.id})
+ {:error, _} = CommonAPI.post(user, %{status: "nice", quoted_status_id: direct.id})
+ {:ok, _} = CommonAPI.post(user, %{status: "nice", quoted_status_id: private.id})
+ {:error, _} = CommonAPI.post(another_user, %{status: "nice", quoted_status_id: private.id})
+ {:ok, _} = CommonAPI.post(user, %{status: "nice", quoted_status_id: unlisted.id})
+ {:ok, _} = CommonAPI.post(another_user, %{status: "nice", quoted_status_id: unlisted.id})
+ {:ok, _} = CommonAPI.post(user, %{status: "nice", quoted_status_id: local.id})
+ {:ok, _} = CommonAPI.post(another_user, %{status: "nice", quoted_status_id: local.id})
+ {:ok, _} = CommonAPI.post(user, %{status: "nice", quoted_status_id: public.id})
+ {:ok, _} = CommonAPI.post(another_user, %{status: "nice", quoted_status_id: public.id})
end
test "it properly mentions punycode domain" do
user = insert(:user)
_mentioned_user =
insert(:user, ap_id: "https://xn--i2raa.com/users/yyy", nickname: "yyy@xn--i2raa.com")
{:ok, activity} =
CommonAPI.post(user, %{status: "hey @yyy@xn--i2raa.com", content_type: "text/markdown"})
assert "https://xn--i2raa.com/users/yyy" in Object.normalize(activity).data["to"]
end
end
describe "reactions" do
test "reacting to a status with an emoji" do
user = insert(:user)
other_user = insert(:user)
{:ok, activity} = CommonAPI.post(other_user, %{status: "cofe"})
{:ok, reaction} = CommonAPI.react_with_emoji(activity.id, user, "👍")
assert reaction.data["actor"] == user.ap_id
assert reaction.data["content"] == "👍"
{:ok, activity} = CommonAPI.post(other_user, %{status: "cofe"})
{:error, _} = CommonAPI.react_with_emoji(activity.id, user, ".")
end
test "unreacting to a status with an emoji" do
user = insert(:user)
other_user = insert(:user)
clear_config([:instance, :federating], true)
with_mock Pleroma.Web.Federator,
publish: fn _ -> nil end do
{:ok, activity} = CommonAPI.post(other_user, %{status: "cofe"})
{:ok, reaction} = CommonAPI.react_with_emoji(activity.id, user, "👍")
{:ok, unreaction} = CommonAPI.unreact_with_emoji(activity.id, user, "👍")
assert unreaction.data["type"] == "Undo"
assert unreaction.data["object"] == reaction.data["id"]
assert unreaction.local
# On federation, it contains the undone (and deleted) object
unreaction_with_object = %{
unreaction
| data: Map.put(unreaction.data, "object", reaction.data)
}
assert called(Pleroma.Web.Federator.publish(unreaction_with_object))
end
end
test "repeating a status" do
user = insert(:user)
other_user = insert(:user)
{:ok, activity} = CommonAPI.post(other_user, %{status: "cofe"})
{:ok, %Activity{} = announce_activity} = CommonAPI.repeat(activity.id, user)
assert Visibility.public?(announce_activity)
end
test "can't repeat a repeat" do
user = insert(:user)
other_user = insert(:user)
{:ok, activity} = CommonAPI.post(other_user, %{status: "cofe"})
{:ok, %Activity{} = announce} = CommonAPI.repeat(activity.id, other_user)
refute match?({:ok, %Activity{}}, CommonAPI.repeat(announce.id, user))
end
test "repeating a status privately" do
user = insert(:user)
other_user = insert(:user)
{:ok, activity} = CommonAPI.post(other_user, %{status: "cofe"})
{:ok, %Activity{} = announce_activity} =
CommonAPI.repeat(activity.id, user, %{visibility: "private"})
assert Visibility.private?(announce_activity)
refute Visibility.visible_for_user?(announce_activity, nil)
end
test "author can repeat own private statuses" do
author = insert(:user)
follower = insert(:user)
CommonAPI.follow(author, follower)
{:ok, activity} = CommonAPI.post(author, %{status: "cofe", visibility: "private"})
{:ok, %Activity{} = announce_activity} = CommonAPI.repeat(activity.id, author)
assert Visibility.private?(announce_activity)
refute Visibility.visible_for_user?(announce_activity, nil)
assert Visibility.visible_for_user?(activity, follower)
assert {:error, :not_found} = CommonAPI.repeat(activity.id, follower)
end
test "favoriting a status" do
user = insert(:user)
other_user = insert(:user)
{:ok, post_activity} = CommonAPI.post(other_user, %{status: "cofe"})
{:ok, %Activity{data: data}} = CommonAPI.favorite(post_activity.id, user)
assert data["type"] == "Like"
assert data["actor"] == user.ap_id
assert data["object"] == post_activity.data["object"]
end
test "retweeting a status twice returns the status" do
user = insert(:user)
other_user = insert(:user)
{:ok, activity} = CommonAPI.post(other_user, %{status: "cofe"})
{:ok, %Activity{} = announce} = CommonAPI.repeat(activity.id, user)
{:ok, ^announce} = CommonAPI.repeat(activity.id, user)
end
test "favoriting a status twice returns ok, but without the like activity" do
user = insert(:user)
other_user = insert(:user)
{:ok, activity} = CommonAPI.post(other_user, %{status: "cofe"})
{:ok, %Activity{}} = CommonAPI.favorite(activity.id, user)
assert {:ok, :already_liked} = CommonAPI.favorite(activity.id, user)
end
end
describe "pinned statuses" do
setup do
clear_config([:instance, :max_pinned_statuses], 1)
user = insert(:user)
{:ok, activity} = CommonAPI.post(user, %{status: "HI!!!"})
[user: user, activity: activity]
end
test "activity not found error", %{user: user} do
assert {:error, :not_found} = CommonAPI.pin("id", user)
end
test "pin status", %{user: user, activity: activity} do
assert {:ok, ^activity} = CommonAPI.pin(activity.id, user)
%{data: %{"id" => object_id}} = Object.normalize(activity)
user = refresh_record(user)
assert user.pinned_objects |> Map.keys() == [object_id]
end
test "pin poll", %{user: user} do
{:ok, activity} =
CommonAPI.post(user, %{
status: "How is fediverse today?",
poll: %{options: ["Absolutely outstanding", "Not good"], expires_in: 20}
})
assert {:ok, ^activity} = CommonAPI.pin(activity.id, user)
%{data: %{"id" => object_id}} = Object.normalize(activity)
user = refresh_record(user)
assert user.pinned_objects |> Map.keys() == [object_id]
end
test "unlisted statuses can be pinned", %{user: user} do
{:ok, activity} = CommonAPI.post(user, %{status: "HI!!!", visibility: "unlisted"})
assert {:ok, ^activity} = CommonAPI.pin(activity.id, user)
end
test "only self-authored can be pinned", %{activity: activity} do
user = insert(:user)
assert {:error, :ownership_error} = CommonAPI.pin(activity.id, user)
end
test "max pinned statuses", %{user: user, activity: activity_one} do
{:ok, activity_two} = CommonAPI.post(user, %{status: "HI!!!"})
assert {:ok, ^activity_one} = CommonAPI.pin(activity_one.id, user)
user = refresh_record(user)
assert {:error, :pinned_statuses_limit_reached} = CommonAPI.pin(activity_two.id, user)
end
test "only public can be pinned", %{user: user} do
{:ok, activity} = CommonAPI.post(user, %{status: "private status", visibility: "private"})
{:error, :visibility_error} = CommonAPI.pin(activity.id, user)
end
test "unpin status", %{user: user, activity: activity} do
{:ok, activity} = CommonAPI.pin(activity.id, user)
user = refresh_record(user)
id = activity.id
assert match?({:ok, %{id: ^id}}, CommonAPI.unpin(activity.id, user))
user = refresh_record(user)
assert user.pinned_objects == %{}
end
test "should unpin when deleting a status", %{user: user, activity: activity} do
{:ok, activity} = CommonAPI.pin(activity.id, user)
user = refresh_record(user)
assert {:ok, _} = CommonAPI.delete(activity.id, user)
user = refresh_record(user)
assert user.pinned_objects == %{}
end
test "ephemeral activity won't be deleted if was pinned", %{user: user} do
{:ok, activity} = CommonAPI.post(user, %{status: "Hello!", expires_in: 601})
assert Pleroma.Workers.PurgeExpiredActivity.get_expiration(activity.id)
{:ok, _activity} = CommonAPI.pin(activity.id, user)
refute Pleroma.Workers.PurgeExpiredActivity.get_expiration(activity.id)
user = refresh_record(user)
{:ok, _} = CommonAPI.unpin(activity.id, user)
# recreates expiration job on unpin
assert Pleroma.Workers.PurgeExpiredActivity.get_expiration(activity.id)
end
test "ephemeral activity deletion job won't be deleted on pinning error", %{
user: user,
activity: activity
} do
clear_config([:instance, :max_pinned_statuses], 1)
{:ok, _activity} = CommonAPI.pin(activity.id, user)
{:ok, activity2} = CommonAPI.post(user, %{status: "another status", expires_in: 601})
assert Pleroma.Workers.PurgeExpiredActivity.get_expiration(activity2.id)
user = refresh_record(user)
{:error, :pinned_statuses_limit_reached} = CommonAPI.pin(activity2.id, user)
assert Pleroma.Workers.PurgeExpiredActivity.get_expiration(activity2.id)
end
end
describe "mute tests" do
setup do
user = insert(:user)
activity = insert(:note_activity)
[user: user, activity: activity]
end
test "marks notifications as read after mute" do
author = insert(:user)
activity = insert(:note_activity, user: author)
friend1 = insert(:user)
friend2 = insert(:user)
{:ok, reply_activity} =
CommonAPI.post(
friend2,
%{
status: "@#{author.nickname} @#{friend1.nickname} test reply",
in_reply_to_status_id: activity.id
}
)
{:ok, favorite_activity} = CommonAPI.favorite(activity.id, friend2)
{:ok, repeat_activity} = CommonAPI.repeat(activity.id, friend1)
assert Repo.aggregate(
from(n in Notification, where: n.seen == false and n.user_id == ^friend1.id),
:count
) == 1
unread_notifications =
Repo.all(from(n in Notification, where: n.seen == false, where: n.user_id == ^author.id))
assert Enum.any?(unread_notifications, fn n ->
n.type == "favourite" && n.activity_id == favorite_activity.id
end)
assert Enum.any?(unread_notifications, fn n ->
n.type == "reblog" && n.activity_id == repeat_activity.id
end)
assert Enum.any?(unread_notifications, fn n ->
n.type == "mention" && n.activity_id == reply_activity.id
end)
{:ok, _} = CommonAPI.add_mute(activity, author)
assert CommonAPI.thread_muted?(activity, author)
assert Repo.aggregate(
from(n in Notification, where: n.seen == false and n.user_id == ^friend1.id),
:count
) == 1
read_notifications =
Repo.all(from(n in Notification, where: n.seen == true, where: n.user_id == ^author.id))
assert Enum.any?(read_notifications, fn n ->
n.type == "favourite" && n.activity_id == favorite_activity.id
end)
assert Enum.any?(read_notifications, fn n ->
n.type == "reblog" && n.activity_id == repeat_activity.id
end)
assert Enum.any?(read_notifications, fn n ->
n.type == "mention" && n.activity_id == reply_activity.id
end)
end
test "add mute", %{user: user, activity: activity} do
{:ok, _} = CommonAPI.add_mute(activity, user)
assert CommonAPI.thread_muted?(activity, user)
end
test "add expiring mute", %{user: user, activity: activity} do
{:ok, _} = CommonAPI.add_mute(activity, user, %{expires_in: 60})
assert CommonAPI.thread_muted?(activity, user)
worker = Pleroma.Workers.MuteExpireWorker
args = %{"op" => "unmute_conversation", "user_id" => user.id, "activity_id" => activity.id}
assert_enqueued(
worker: worker,
args: args
)
assert :ok = perform_job(worker, args)
refute CommonAPI.thread_muted?(activity, user)
end
test "remove mute", %{user: user, activity: activity} do
CommonAPI.add_mute(activity, user)
{:ok, _} = CommonAPI.remove_mute(activity, user)
refute CommonAPI.thread_muted?(activity, user)
end
test "remove mute by ids", %{user: user, activity: activity} do
CommonAPI.add_mute(activity, user)
{:ok, _} = CommonAPI.remove_mute(activity.id, user.id)
refute CommonAPI.thread_muted?(activity, user)
end
test "check that mutes can't be duplicate", %{user: user, activity: activity} do
CommonAPI.add_mute(activity, user)
{:error, _} = CommonAPI.add_mute(activity, user)
end
end
describe "reports" do
test "creates a report" do
reporter = insert(:user)
target_user = insert(:user)
{:ok, activity} = CommonAPI.post(target_user, %{status: "foobar"})
activity = Activity.normalize(activity)
reporter_ap_id = reporter.ap_id
target_ap_id = target_user.ap_id
reported_object_ap_id = activity.object.data["id"]
comment = "foobar"
report_data = %{
account_id: target_user.id,
comment: comment,
status_ids: [activity.id]
}
note_obj = %{
"type" => "Note",
"id" => reported_object_ap_id,
"content" => "foobar",
"published" => activity.object.data["published"],
"actor" => AccountView.render("show.json", %{user: target_user})
}
assert {:ok, flag_activity} = CommonAPI.report(reporter, report_data)
assert %Activity{
actor: ^reporter_ap_id,
data: %{
"type" => "Flag",
"content" => ^comment,
"object" => [^target_ap_id, ^note_obj],
"state" => "open"
}
} = flag_activity
end
test "updates report state" do
[reporter, target_user] = insert_pair(:user)
activity = insert(:note_activity, user: target_user)
object = Object.normalize(activity)
{:ok, %Activity{id: report_id}} =
CommonAPI.report(reporter, %{
account_id: target_user.id,
comment: "I feel offended",
status_ids: [activity.id]
})
{:ok, report} = CommonAPI.update_report_state(report_id, "resolved")
assert report.data["state"] == "resolved"
[reported_user, object_id] = report.data["object"]
assert reported_user == target_user.ap_id
assert object_id == object.data["id"]
end
test "updates report state, don't strip when report_strip_status is false" do
clear_config([:instance, :report_strip_status], false)
[reporter, target_user] = insert_pair(:user)
activity = insert(:note_activity, user: target_user)
{:ok, %Activity{id: report_id, data: report_data}} =
CommonAPI.report(reporter, %{
account_id: target_user.id,
comment: "I feel offended",
status_ids: [activity.id]
})
{:ok, report} = CommonAPI.update_report_state(report_id, "resolved")
assert report.data["state"] == "resolved"
[reported_user, reported_activity] = report.data["object"]
assert reported_user == target_user.ap_id
assert is_map(reported_activity)
assert reported_activity["content"] ==
report_data["object"] |> Enum.at(1) |> Map.get("content")
end
test "does not update report state when state is unsupported" do
[reporter, target_user] = insert_pair(:user)
activity = insert(:note_activity, user: target_user)
{:ok, %Activity{id: report_id}} =
CommonAPI.report(reporter, %{
account_id: target_user.id,
comment: "I feel offended",
status_ids: [activity.id]
})
assert CommonAPI.update_report_state(report_id, "test") == {:error, "Unsupported state"}
end
test "updates state of multiple reports" do
[reporter, target_user] = insert_pair(:user)
activity = insert(:note_activity, user: target_user)
{:ok, %Activity{id: first_report_id}} =
CommonAPI.report(reporter, %{
account_id: target_user.id,
comment: "I feel offended",
status_ids: [activity.id]
})
{:ok, %Activity{id: second_report_id}} =
CommonAPI.report(reporter, %{
account_id: target_user.id,
comment: "I feel very offended!",
status_ids: [activity.id]
})
{:ok, report_ids} =
CommonAPI.update_report_state([first_report_id, second_report_id], "resolved")
first_report = Activity.get_by_id(first_report_id)
second_report = Activity.get_by_id(second_report_id)
assert report_ids -- [first_report_id, second_report_id] == []
assert first_report.data["state"] == "resolved"
assert second_report.data["state"] == "resolved"
end
test "creates a report with provided rules" do
reporter = insert(:user)
target_user = insert(:user)
%{id: rule_id} = Rule.create(%{text: "There are no rules"})
reporter_ap_id = reporter.ap_id
target_ap_id = target_user.ap_id
report_data = %{
account_id: target_user.id,
rule_ids: [rule_id]
}
assert {:ok, flag_activity} = CommonAPI.report(reporter, report_data)
assert %Activity{
actor: ^reporter_ap_id,
data: %{
"type" => "Flag",
"object" => [^target_ap_id],
"state" => "open",
"rules" => [^rule_id]
}
} = flag_activity
end
end
describe "reblog muting" do
setup do
muter = insert(:user)
muted = insert(:user)
[muter: muter, muted: muted]
end
test "add a reblog mute", %{muter: muter, muted: muted} do
{:ok, _reblog_mute} = CommonAPI.hide_reblogs(muted, muter)
assert User.showing_reblogs?(muter, muted) == false
end
test "remove a reblog mute", %{muter: muter, muted: muted} do
{:ok, _reblog_mute} = CommonAPI.hide_reblogs(muted, muter)
{:ok, _reblog_mute} = CommonAPI.show_reblogs(muted, muter)
assert User.showing_reblogs?(muter, muted) == true
end
end
describe "follow/2" do
test "directly follows a non-locked local user" do
[follower, followed] = insert_pair(:user)
{:ok, followed, follower, _} = CommonAPI.follow(followed, follower)
assert User.following?(follower, followed)
end
end
describe "unfollow/2" do
test "also unsubscribes a user" do
[follower, followed] = insert_pair(:user)
{:ok, followed, follower, _} = CommonAPI.follow(followed, follower)
{:ok, _subscription} = User.subscribe(follower, followed)
assert User.subscribed_to?(follower, followed)
{:ok, follower} = CommonAPI.unfollow(followed, follower)
refute User.subscribed_to?(follower, followed)
end
test "also unpins a user" do
[follower, followed] = insert_pair(:user)
{:ok, followed, follower, _} = CommonAPI.follow(followed, follower)
{:ok, _endorsement} = User.endorse(follower, followed)
assert User.endorses?(follower, followed)
{:ok, follower} = CommonAPI.unfollow(followed, follower)
refute User.endorses?(follower, followed)
end
test "cancels a pending follow for a local user" do
follower = insert(:user)
followed = insert(:user, is_locked: true)
assert {:ok, followed, follower, %{id: activity_id, data: %{"state" => "pending"}}} =
CommonAPI.follow(followed, follower)
assert User.get_follow_state(follower, followed) == :follow_pending
assert {:ok, follower} = CommonAPI.unfollow(followed, follower)
assert User.get_follow_state(follower, followed) == nil
assert %{id: ^activity_id, data: %{"state" => "cancelled"}} =
Pleroma.Web.ActivityPub.Utils.fetch_latest_follow(follower, followed)
assert %{
data: %{
"type" => "Undo",
"object" => %{"type" => "Follow", "state" => "cancelled"}
}
} = Pleroma.Web.ActivityPub.Utils.fetch_latest_undo(follower)
end
test "cancels a pending follow for a remote user" do
follower = insert(:user)
followed = insert(:user, is_locked: true, local: false)
assert {:ok, followed, follower, %{id: activity_id, data: %{"state" => "pending"}}} =
CommonAPI.follow(followed, follower)
assert User.get_follow_state(follower, followed) == :follow_pending
assert {:ok, follower} = CommonAPI.unfollow(followed, follower)
assert User.get_follow_state(follower, followed) == nil
assert %{id: ^activity_id, data: %{"state" => "cancelled"}} =
Pleroma.Web.ActivityPub.Utils.fetch_latest_follow(follower, followed)
assert %{
data: %{
"type" => "Undo",
"object" => %{"type" => "Follow", "state" => "cancelled"}
}
} = Pleroma.Web.ActivityPub.Utils.fetch_latest_undo(follower)
end
end
describe "accept_follow_request/2" do
test "after acceptance, it sets all existing pending follow request states to 'accept'" do
user = insert(:user, is_locked: true)
follower = insert(:user)
follower_two = insert(:user)
{:ok, _, _, follow_activity} = CommonAPI.follow(user, follower)
{:ok, _, _, follow_activity_two} = CommonAPI.follow(user, follower)
{:ok, _, _, follow_activity_three} = CommonAPI.follow(user, follower_two)
assert follow_activity.data["state"] == "pending"
assert follow_activity_two.data["state"] == "pending"
assert follow_activity_three.data["state"] == "pending"
{:ok, _follower} = CommonAPI.accept_follow_request(follower, user)
assert Repo.get(Activity, follow_activity.id).data["state"] == "accept"
assert Repo.get(Activity, follow_activity_two.id).data["state"] == "accept"
assert Repo.get(Activity, follow_activity_three.id).data["state"] == "pending"
end
test "after rejection, it sets all existing pending follow request states to 'reject'" do
user = insert(:user, is_locked: true)
follower = insert(:user)
follower_two = insert(:user)
{:ok, _, _, follow_activity} = CommonAPI.follow(user, follower)
{:ok, _, _, follow_activity_two} = CommonAPI.follow(user, follower)
{:ok, _, _, follow_activity_three} = CommonAPI.follow(user, follower_two)
assert follow_activity.data["state"] == "pending"
assert follow_activity_two.data["state"] == "pending"
assert follow_activity_three.data["state"] == "pending"
{:ok, _follower} = CommonAPI.reject_follow_request(follower, user)
assert Repo.get(Activity, follow_activity.id).data["state"] == "reject"
assert Repo.get(Activity, follow_activity_two.id).data["state"] == "reject"
assert Repo.get(Activity, follow_activity_three.id).data["state"] == "pending"
end
test "doesn't create a following relationship if the corresponding follow request doesn't exist" do
user = insert(:user, is_locked: true)
not_follower = insert(:user)
CommonAPI.accept_follow_request(not_follower, user)
assert Pleroma.FollowingRelationship.following?(not_follower, user) == false
end
end
describe "vote/3" do
test "does not allow to vote twice" do
user = insert(:user)
other_user = insert(:user)
{:ok, activity} =
CommonAPI.post(user, %{
status: "Am I cute?",
poll: %{options: ["Yes", "No"], expires_in: 20}
})
object = Object.normalize(activity, fetch: false)
{:ok, _, object} = CommonAPI.vote(object, other_user, [0])
assert {:error, "Already voted"} == CommonAPI.vote(object, other_user, [1])
end
end
describe "listen/2" do
test "returns a valid activity" do
user = insert(:user)
{:ok, activity} =
CommonAPI.listen(user, %{
title: "lain radio episode 1",
album: "lain radio",
artist: "lain",
length: 180_000
})
object = Object.normalize(activity, fetch: false)
assert object.data["title"] == "lain radio episode 1"
assert Visibility.get_visibility(activity) == "public"
end
test "respects visibility=private" do
user = insert(:user)
{:ok, activity} =
CommonAPI.listen(user, %{
title: "lain radio episode 1",
album: "lain radio",
artist: "lain",
length: 180_000,
visibility: "private"
})
object = Object.normalize(activity, fetch: false)
assert object.data["title"] == "lain radio episode 1"
assert Visibility.get_visibility(activity) == "private"
end
end
describe "get_user/1" do
test "gets user by ap_id" do
user = insert(:user)
assert CommonAPI.get_user(user.ap_id) == user
end
test "gets user by guessed nickname" do
user = insert(:user, ap_id: "", nickname: "mario@mushroom.kingdom")
assert CommonAPI.get_user("https://mushroom.kingdom/users/mario") == user
end
test "fallback" do
assert %User{
name: "",
ap_id: "",
nickname: "erroruser@example.com"
} = CommonAPI.get_user("")
end
end
describe "with `local` visibility" do
setup do: clear_config([:instance, :federating], true)
test "post" do
user = insert(:user)
with_mock Pleroma.Web.Federator, publish: fn _ -> :ok end do
{:ok, activity} = CommonAPI.post(user, %{status: "#2hu #2HU", visibility: "local"})
assert Visibility.local_public?(activity)
assert_not_called(Pleroma.Web.Federator.publish(activity))
end
end
test "delete" do
user = insert(:user)
{:ok, %Activity{id: activity_id}} =
CommonAPI.post(user, %{status: "#2hu #2HU", visibility: "local"})
with_mock Pleroma.Web.Federator, publish: fn _ -> :ok end do
assert {:ok, %Activity{data: %{"deleted_activity_id" => ^activity_id}} = activity} =
CommonAPI.delete(activity_id, user)
assert Visibility.local_public?(activity)
assert_not_called(Pleroma.Web.Federator.publish(activity))
end
end
test "repeat" do
user = insert(:user)
other_user = insert(:user)
{:ok, %Activity{id: activity_id}} =
CommonAPI.post(other_user, %{status: "cofe", visibility: "local"})
with_mock Pleroma.Web.Federator, publish: fn _ -> :ok end do
assert {:ok, %Activity{data: %{"type" => "Announce"}} = activity} =
CommonAPI.repeat(activity_id, user)
assert Visibility.local_public?(activity)
refute called(Pleroma.Web.Federator.publish(activity))
end
end
test "unrepeat" do
user = insert(:user)
other_user = insert(:user)
{:ok, %Activity{id: activity_id}} =
CommonAPI.post(other_user, %{status: "cofe", visibility: "local"})
assert {:ok, _} = CommonAPI.repeat(activity_id, user)
with_mock Pleroma.Web.Federator, publish: fn _ -> :ok end do
assert {:ok, %Activity{data: %{"type" => "Undo"}} = activity} =
CommonAPI.unrepeat(activity_id, user)
assert Visibility.local_public?(activity)
refute called(Pleroma.Web.Federator.publish(activity))
end
end
test "favorite" do
user = insert(:user)
other_user = insert(:user)
{:ok, activity} = CommonAPI.post(other_user, %{status: "cofe", visibility: "local"})
with_mock Pleroma.Web.Federator, publish: fn _ -> :ok end do
assert {:ok, %Activity{data: %{"type" => "Like"}} = activity} =
CommonAPI.favorite(activity.id, user)
assert Visibility.local_public?(activity)
refute called(Pleroma.Web.Federator.publish(activity))
end
end
test "unfavorite" do
user = insert(:user)
other_user = insert(:user)
{:ok, activity} = CommonAPI.post(other_user, %{status: "cofe", visibility: "local"})
{:ok, %Activity{}} = CommonAPI.favorite(activity.id, user)
with_mock Pleroma.Web.Federator, publish: fn _ -> :ok end do
assert {:ok, activity} = CommonAPI.unfavorite(activity.id, user)
assert Visibility.local_public?(activity)
refute called(Pleroma.Web.Federator.publish(activity))
end
end
test "react_with_emoji" do
user = insert(:user)
other_user = insert(:user)
{:ok, activity} = CommonAPI.post(other_user, %{status: "cofe", visibility: "local"})
with_mock Pleroma.Web.Federator, publish: fn _ -> :ok end do
assert {:ok, %Activity{data: %{"type" => "EmojiReact"}} = activity} =
CommonAPI.react_with_emoji(activity.id, user, "👍")
assert Visibility.local_public?(activity)
refute called(Pleroma.Web.Federator.publish(activity))
end
end
test "unreact_with_emoji" do
user = insert(:user)
other_user = insert(:user)
{:ok, activity} = CommonAPI.post(other_user, %{status: "cofe", visibility: "local"})
{:ok, _reaction} = CommonAPI.react_with_emoji(activity.id, user, "👍")
with_mock Pleroma.Web.Federator, publish: fn _ -> :ok end do
assert {:ok, %Activity{data: %{"type" => "Undo"}} = activity} =
CommonAPI.unreact_with_emoji(activity.id, user, "👍")
assert Visibility.local_public?(activity)
refute called(Pleroma.Web.Federator.publish(activity))
end
end
end
describe "update/3" do
test "updates a post" do
user = insert(:user)
{:ok, activity} = CommonAPI.post(user, %{status: "foo1", spoiler_text: "title 1"})
{:ok, updated} = CommonAPI.update(activity, user, %{status: "updated 2"})
updated_object = Object.normalize(updated)
assert updated_object.data["content"] == "updated 2"
assert Map.get(updated_object.data, "summary", "") == ""
assert Map.has_key?(updated_object.data, "updated")
end
test "does not change visibility" do
user = insert(:user)
{:ok, activity} =
CommonAPI.post(user, %{status: "foo1", spoiler_text: "title 1", visibility: "private"})
{:ok, updated} = CommonAPI.update(activity, user, %{status: "updated 2"})
updated_object = Object.normalize(updated)
assert updated_object.data["content"] == "updated 2"
assert Map.get(updated_object.data, "summary", "") == ""
assert Visibility.get_visibility(updated_object) == "private"
assert Visibility.get_visibility(updated) == "private"
end
test "updates a post with emoji" do
[{emoji1, _}, {emoji2, _} | _] = Pleroma.Emoji.get_all()
user = insert(:user)
{:ok, activity} =
CommonAPI.post(user, %{status: "foo1", spoiler_text: "title 1 :#{emoji1}:"})
{:ok, updated} = CommonAPI.update(activity, user, %{status: "updated 2 :#{emoji2}:"})
updated_object = Object.normalize(updated)
assert updated_object.data["content"] == "updated 2 :#{emoji2}:"
assert %{^emoji2 => _} = updated_object.data["emoji"]
end
test "updates a post with emoji and federate properly" do
[{emoji1, _}, {emoji2, _} | _] = Pleroma.Emoji.get_all()
user = insert(:user)
{:ok, activity} =
CommonAPI.post(user, %{status: "foo1", spoiler_text: "title 1 :#{emoji1}:"})
clear_config([:instance, :federating], true)
with_mock Pleroma.Web.Federator,
publish: fn _p -> nil end do
{:ok, updated} = CommonAPI.update(activity, user, %{status: "updated 2 :#{emoji2}:"})
assert updated.data["object"]["content"] == "updated 2 :#{emoji2}:"
assert %{^emoji2 => _} = updated.data["object"]["emoji"]
assert called(Pleroma.Web.Federator.publish(updated))
end
end
test "editing a post that copied a remote title with remote emoji should keep that emoji" do
remote_emoji_uri = "https://remote.org/emoji.png"
note =
insert(
:note,
data: %{
"summary" => ":remoteemoji:",
"emoji" => %{
"remoteemoji" => remote_emoji_uri
},
"tag" => [
%{
"type" => "Emoji",
"name" => "remoteemoji",
"icon" => %{"url" => remote_emoji_uri}
}
]
}
)
note_activity = insert(:note_activity, note: note)
user = insert(:user)
{:ok, reply} =
CommonAPI.post(user, %{
status: "reply",
spoiler_text: ":remoteemoji:",
in_reply_to_id: note_activity.id
})
assert reply.object.data["emoji"]["remoteemoji"] == remote_emoji_uri
{:ok, edit} =
CommonAPI.update(reply, user, %{status: "reply mew mew", spoiler_text: ":remoteemoji:"})
edited_note = Pleroma.Object.normalize(edit)
assert edited_note.data["emoji"]["remoteemoji"] == remote_emoji_uri
end
test "respects MRF" do
user = insert(:user)
clear_config([:mrf, :policies], [Pleroma.Web.ActivityPub.MRF.KeywordPolicy])
clear_config([:mrf_keyword, :replace], [{"updated", "mewmew"}])
{:ok, activity} = CommonAPI.post(user, %{status: "foo1", spoiler_text: "updated 1"})
assert Object.normalize(activity).data["summary"] == "mewmew 1"
{:ok, updated} = CommonAPI.update(activity, user, %{status: "updated 2"})
updated_object = Object.normalize(updated)
assert updated_object.data["content"] == "mewmew 2"
assert Map.get(updated_object.data, "summary", "") == ""
assert Map.has_key?(updated_object.data, "updated")
end
end
describe "Group actors" do
setup do
poster = insert(:user)
group = insert(:user, actor_type: "Group")
other_group = insert(:user, actor_type: "Group")
%{poster: poster, group: group, other_group: other_group}
end
test "it boosts public posts", %{poster: poster, group: group} do
{:ok, post} = CommonAPI.post(poster, %{status: "hey @#{group.nickname}"})
announces = get_announces_of_object(post.object)
assert [_] = announces
end
test "it does not boost private posts", %{poster: poster, group: group} do
{:ok, private_post} =
CommonAPI.post(poster, %{status: "hey @#{group.nickname}", visibility: "private"})
assert [] = get_announces_of_object(private_post.object)
end
test "remote groups do not boost any posts", %{poster: poster} do
remote_group =
insert(:user, actor_type: "Group", local: false, nickname: "remote@example.com")
{:ok, post} = CommonAPI.post(poster, %{status: "hey @#{User.full_nickname(remote_group)}"})
assert remote_group.ap_id in post.data["to"]
announces = get_announces_of_object(post.object)
assert [] = announces
end
test "multiple groups mentioned", %{poster: poster, group: group, other_group: other_group} do
{:ok, post} =
CommonAPI.post(poster, %{status: "hey @#{group.nickname} @#{other_group.nickname}"})
announces = get_announces_of_object(post.object)
assert [_, _] = announces
end
test "it does not boost if group is blocking poster", %{poster: poster, group: group} do
{:ok, _} = CommonAPI.block(poster, group)
{:ok, post} = CommonAPI.post(poster, %{status: "hey @#{group.nickname}"})
announces = get_announces_of_object(post.object)
assert [] = announces
end
end
describe "Oban jobs are cancelled" do
setup do
clear_config([:instance, :federating], true)
local_user = insert(:user)
remote_one =
insert(:user, %{
local: false,
nickname: "nick1@domain.com",
ap_id: "https://domain.com/users/nick1",
inbox: "https://domain.com/users/nick1/inbox",
shared_inbox: "https://domain.com/inbox"
})
remote_two =
insert(:user, %{
local: false,
nickname: "nick2@example.com",
ap_id: "https://example.com/users/nick2",
inbox: "https://example.com/users/nick2/inbox",
shared_inbox: "https://example.com/inbox"
})
%{local_user: local_user, remote_one: remote_one, remote_two: remote_two}
end
test "when deleting posts", %{
local_user: local_user,
remote_one: remote_one,
remote_two: remote_two
} do
{:ok, _, _} = Pleroma.User.follow(remote_one, local_user)
{:ok, _, _} = Pleroma.User.follow(remote_two, local_user)
{:ok, %{id: activity_id} = _activity} =
CommonAPI.post(local_user, %{status: "Happy Friday everyone!"})
# Generate the publish_one jobs
ObanHelpers.perform_all()
publish_one_jobs =
all_enqueued()
|> Enum.filter(fn job ->
match?(
%{
state: "available",
queue: "federator_outgoing",
worker: "Pleroma.Workers.PublisherWorker",
args: %{"op" => "publish_one", "params" => %{"activity_id" => ^activity_id}}
},
job
)
end)
assert length(publish_one_jobs) == 2
# The delete should have triggered cancelling the publish_one jobs
assert {:ok, _delete} = CommonAPI.delete(activity_id, local_user)
# all_enqueued/1 will not return cancelled jobs
cancelled_jobs =
Oban.Job
|> where([j], j.worker == "Pleroma.Workers.PublisherWorker")
|> where([j], j.state == "cancelled")
|> where([j], j.args["op"] == "publish_one")
|> where([j], j.args["params"]["activity_id"] == ^activity_id)
|> Pleroma.Repo.all()
assert length(cancelled_jobs) == 2
end
test "when unfavoriting posts", %{
local_user: local_user,
remote_one: remote_user
} do
{:ok, activity} =
CommonAPI.post(remote_user, %{status: "I like turtles!"})
{:ok, %{id: favorite_id} = _favorite} =
CommonAPI.favorite(activity.id, local_user)
# Generate the publish_one jobs
ObanHelpers.perform_all()
publish_one_jobs =
all_enqueued()
|> Enum.filter(fn job ->
match?(
%{
state: "available",
queue: "federator_outgoing",
worker: "Pleroma.Workers.PublisherWorker",
args: %{"op" => "publish_one", "params" => %{"activity_id" => ^favorite_id}}
},
job
)
end)
assert length(publish_one_jobs) == 1
# The unfavorite should have triggered cancelling the publish_one jobs
assert {:ok, _unfavorite} = CommonAPI.unfavorite(activity.id, local_user)
# all_enqueued/1 will not return cancelled jobs
cancelled_jobs =
Oban.Job
|> where([j], j.worker == "Pleroma.Workers.PublisherWorker")
|> where([j], j.state == "cancelled")
|> where([j], j.args["op"] == "publish_one")
|> where([j], j.args["params"]["activity_id"] == ^favorite_id)
|> Pleroma.Repo.all()
assert length(cancelled_jobs) == 1
end
test "when unboosting posts", %{
local_user: local_user,
remote_one: remote_one,
remote_two: remote_two
} do
{:ok, _, _} = Pleroma.User.follow(remote_one, local_user)
{:ok, _, _} = Pleroma.User.follow(remote_two, local_user)
{:ok, activity} =
CommonAPI.post(remote_one, %{status: "This is an unpleasant post"})
{:ok, %{id: repeat_id} = _repeat} =
CommonAPI.repeat(activity.id, local_user)
# Generate the publish_one jobs
ObanHelpers.perform_all()
publish_one_jobs =
all_enqueued()
|> Enum.filter(fn job ->
match?(
%{
state: "available",
queue: "federator_outgoing",
worker: "Pleroma.Workers.PublisherWorker",
args: %{"op" => "publish_one", "params" => %{"activity_id" => ^repeat_id}}
},
job
)
end)
assert length(publish_one_jobs) == 2
# The unrepeat should have triggered cancelling the publish_one jobs
assert {:ok, _unfavorite} = CommonAPI.unrepeat(activity.id, local_user)
# all_enqueued/1 will not return cancelled jobs
cancelled_jobs =
Oban.Job
|> where([j], j.worker == "Pleroma.Workers.PublisherWorker")
|> where([j], j.state == "cancelled")
|> where([j], j.args["op"] == "publish_one")
|> where([j], j.args["params"]["activity_id"] == ^repeat_id)
|> Pleroma.Repo.all()
assert length(cancelled_jobs) == 2
end
test "when unreacting to posts", %{
local_user: local_user,
remote_one: remote_one,
remote_two: remote_two
} do
{:ok, _, _} = Pleroma.User.follow(remote_one, local_user)
{:ok, _, _} = Pleroma.User.follow(remote_two, local_user)
{:ok, %{id: activity_id}} =
CommonAPI.post(remote_one, %{status: "Gang gang!!!!"})
{:ok, %{id: react_id} = _react} =
CommonAPI.react_with_emoji(activity_id, local_user, "👍")
# Generate the publish_one jobs
ObanHelpers.perform_all()
publish_one_jobs =
all_enqueued()
|> Enum.filter(fn job ->
match?(
%{
state: "available",
queue: "federator_outgoing",
worker: "Pleroma.Workers.PublisherWorker",
args: %{"op" => "publish_one", "params" => %{"activity_id" => ^react_id}}
},
job
)
end)
assert length(publish_one_jobs) == 2
# The unreact should have triggered cancelling the publish_one jobs
assert {:ok, _unreact} = CommonAPI.unreact_with_emoji(activity_id, local_user, "👍")
# all_enqueued/1 will not return cancelled jobs
cancelled_jobs =
Oban.Job
|> where([j], j.worker == "Pleroma.Workers.PublisherWorker")
|> where([j], j.state == "cancelled")
|> where([j], j.args["op"] == "publish_one")
|> where([j], j.args["params"]["activity_id"] == ^react_id)
|> Pleroma.Repo.all()
assert length(cancelled_jobs) == 2
end
end
end
diff --git a/test/pleroma/web/mastodon_api/views/status_view_test.exs b/test/pleroma/web/mastodon_api/views/status_view_test.exs
index 8c44d17a2..d7908886b 100644
--- a/test/pleroma/web/mastodon_api/views/status_view_test.exs
+++ b/test/pleroma/web/mastodon_api/views/status_view_test.exs
@@ -1,1010 +1,1012 @@
# Pleroma: A lightweight social networking server
# Copyright © 2017-2022 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Web.MastodonAPI.StatusViewTest do
use Pleroma.DataCase
alias Pleroma.Activity
alias Pleroma.Bookmark
alias Pleroma.Conversation.Participation
alias Pleroma.HTML
alias Pleroma.Object
alias Pleroma.Repo
alias Pleroma.UnstubbedConfigMock, as: ConfigMock
alias Pleroma.User
alias Pleroma.UserRelationship
alias Pleroma.Web.CommonAPI
alias Pleroma.Web.MastodonAPI.AccountView
alias Pleroma.Web.MastodonAPI.StatusView
alias Pleroma.Web.RichMedia.Card
require Bitwise
import Mox
import OpenApiSpex.TestAssertions
import Pleroma.Factory
import Tesla.Mock
setup do
mock(fn env -> apply(HttpRequestMock, :request, [env]) end)
:ok
end
test "has an emoji reaction list" do
user = insert(:user)
other_user = insert(:user)
third_user = insert(:user)
{:ok, activity} = CommonAPI.post(user, %{status: "dae cofe??"})
{:ok, _} = CommonAPI.react_with_emoji(activity.id, user, "☕")
{:ok, _} = CommonAPI.react_with_emoji(activity.id, user, ":dinosaur:")
{:ok, _} = CommonAPI.react_with_emoji(activity.id, third_user, "🍵")
{:ok, _} = CommonAPI.react_with_emoji(activity.id, other_user, "☕")
{:ok, _} = CommonAPI.react_with_emoji(activity.id, other_user, ":dinosaur:")
activity = Repo.get(Activity, activity.id)
status = StatusView.render("show.json", activity: activity)
assert_schema(status, "Status", Pleroma.Web.ApiSpec.spec())
assert status[:pleroma][:emoji_reactions] == [
%{name: "☕", count: 2, me: false, url: nil, account_ids: [other_user.id, user.id]},
%{
count: 2,
me: false,
name: "dinosaur",
url: "http://localhost:4001/emoji/dino walking.gif",
account_ids: [other_user.id, user.id]
},
%{name: "🍵", count: 1, me: false, url: nil, account_ids: [third_user.id]}
]
status = StatusView.render("show.json", activity: activity, for: user)
assert_schema(status, "Status", Pleroma.Web.ApiSpec.spec())
assert status[:pleroma][:emoji_reactions] == [
%{name: "☕", count: 2, me: true, url: nil, account_ids: [other_user.id, user.id]},
%{
count: 2,
me: true,
name: "dinosaur",
url: "http://localhost:4001/emoji/dino walking.gif",
account_ids: [other_user.id, user.id]
},
%{name: "🍵", count: 1, me: false, url: nil, account_ids: [third_user.id]}
]
end
test "works with legacy-formatted reactions" do
user = insert(:user)
other_user = insert(:user)
note =
insert(:note,
user: user,
data: %{
"reactions" => [["😿", [other_user.ap_id]]]
}
)
activity = insert(:note_activity, user: user, note: note)
status = StatusView.render("show.json", activity: activity, for: user)
assert status[:pleroma][:emoji_reactions] == [
%{name: "😿", count: 1, me: false, url: nil, account_ids: [other_user.id]}
]
end
test "works correctly with badly formatted emojis" do
user = insert(:user)
{:ok, activity} = CommonAPI.post(user, %{status: "yo"})
activity
|> Object.normalize(fetch: false)
|> Object.update_data(%{"reactions" => %{"☕" => [user.ap_id], "x" => 1}})
activity = Activity.get_by_id(activity.id)
status = StatusView.render("show.json", activity: activity, for: user)
assert status[:pleroma][:emoji_reactions] == [
%{name: "☕", count: 1, me: true, url: nil, account_ids: [user.id]}
]
end
test "doesn't show reactions from muted and blocked users" do
user = insert(:user)
other_user = insert(:user)
third_user = insert(:user)
{:ok, activity} = CommonAPI.post(user, %{status: "dae cofe??"})
{:ok, _} = User.mute(user, other_user)
{:ok, _} = User.block(other_user, third_user)
{:ok, _} = CommonAPI.react_with_emoji(activity.id, other_user, "☕")
activity = Repo.get(Activity, activity.id)
status = StatusView.render("show.json", activity: activity)
assert status[:pleroma][:emoji_reactions] == [
%{name: "☕", count: 1, me: false, url: nil, account_ids: [other_user.id]}
]
status = StatusView.render("show.json", activity: activity, for: user)
assert status[:pleroma][:emoji_reactions] == []
{:ok, _} = CommonAPI.react_with_emoji(activity.id, third_user, "☕")
status = StatusView.render("show.json", activity: activity)
assert status[:pleroma][:emoji_reactions] == [
%{
name: "☕",
count: 2,
me: false,
url: nil,
account_ids: [third_user.id, other_user.id]
}
]
status = StatusView.render("show.json", activity: activity, for: user)
assert status[:pleroma][:emoji_reactions] == [
%{name: "☕", count: 1, me: false, url: nil, account_ids: [third_user.id]}
]
status = StatusView.render("show.json", activity: activity, for: other_user)
assert status[:pleroma][:emoji_reactions] == [
%{name: "☕", count: 1, me: true, url: nil, account_ids: [other_user.id]}
]
end
test "loads and returns the direct conversation id when given the `with_direct_conversation_id` option" do
user = insert(:user)
{:ok, activity} = CommonAPI.post(user, %{status: "Hey @shp!", visibility: "direct"})
[participation] = Participation.for_user(user)
status =
StatusView.render("show.json",
activity: activity,
with_direct_conversation_id: true,
for: user
)
assert status[:pleroma][:direct_conversation_id] == participation.id
status = StatusView.render("show.json", activity: activity, for: user)
assert status[:pleroma][:direct_conversation_id] == nil
assert_schema(status, "Status", Pleroma.Web.ApiSpec.spec())
end
test "returns the direct conversation id when given the `direct_conversation_id` option" do
user = insert(:user)
{:ok, activity} = CommonAPI.post(user, %{status: "Hey @shp!", visibility: "direct"})
[participation] = Participation.for_user(user)
status =
StatusView.render("show.json",
activity: activity,
direct_conversation_id: participation.id,
for: user
)
assert status[:pleroma][:direct_conversation_id] == participation.id
assert_schema(status, "Status", Pleroma.Web.ApiSpec.spec())
end
test "returns a temporary ap_id based user for activities missing db users" do
user = insert(:user)
{:ok, activity} = CommonAPI.post(user, %{status: "Hey @shp!", visibility: "direct"})
Repo.delete(user)
User.invalidate_cache(user)
finger_url =
"https://localhost/.well-known/webfinger?resource=acct:#{user.nickname}@localhost"
Tesla.Mock.mock_global(fn
%{method: :get, url: "http://localhost/.well-known/host-meta"} ->
%Tesla.Env{status: 404, body: ""}
%{method: :get, url: "https://localhost/.well-known/host-meta"} ->
%Tesla.Env{status: 404, body: ""}
%{
method: :get,
url: ^finger_url
} ->
%Tesla.Env{status: 404, body: ""}
end)
%{account: ms_user} = StatusView.render("show.json", activity: activity)
assert ms_user.acct == "erroruser@example.com"
end
test "tries to get a user by nickname if fetching by ap_id doesn't work" do
user = insert(:user)
{:ok, activity} = CommonAPI.post(user, %{status: "Hey @shp!", visibility: "direct"})
{:ok, user} =
user
|> Ecto.Changeset.change(%{ap_id: "#{user.ap_id}/extension/#{user.nickname}"})
|> Repo.update()
User.invalidate_cache(user)
result = StatusView.render("show.json", activity: activity)
assert result[:account][:id] == to_string(user.id)
assert_schema(result, "Status", Pleroma.Web.ApiSpec.spec())
end
test "a note with null content" do
note = insert(:note_activity)
note_object = Object.normalize(note, fetch: false)
data =
note_object.data
|> Map.put("content", nil)
Object.change(note_object, %{data: data})
|> Object.update_and_set_cache()
User.get_cached_by_ap_id(note.data["actor"])
status = StatusView.render("show.json", %{activity: note})
assert status.content == ""
assert_schema(status, "Status", Pleroma.Web.ApiSpec.spec())
end
test "a note activity" do
note = insert(:note_activity)
object_data = Object.normalize(note, fetch: false).data
user = User.get_cached_by_ap_id(note.data["actor"])
convo_id = :erlang.crc32(object_data["context"]) |> Bitwise.band(Bitwise.bnot(0x8000_0000))
status = StatusView.render("show.json", %{activity: note})
created_at =
(object_data["published"] || "")
|> String.replace(~r/\.\d+Z/, ".000Z")
expected = %{
id: to_string(note.id),
uri: object_data["id"],
url: Pleroma.Web.Router.Helpers.o_status_url(Pleroma.Web.Endpoint, :notice, note),
account: AccountView.render("show.json", %{user: user, skip_visibility_check: true}),
in_reply_to_id: nil,
in_reply_to_account_id: nil,
card: nil,
reblog: nil,
content: HTML.filter_tags(object_data["content"]),
text: nil,
created_at: created_at,
edited_at: nil,
reblogs_count: 0,
replies_count: 0,
favourites_count: 0,
reblogged: false,
bookmarked: false,
favourited: false,
muted: false,
pinned: false,
sensitive: false,
poll: nil,
spoiler_text: HTML.filter_tags(object_data["summary"]),
visibility: "public",
media_attachments: [],
mentions: [],
tags: [
%{
name: "#{hd(object_data["tag"])}",
url: "http://localhost:4001/tag/#{hd(object_data["tag"])}"
}
],
application: nil,
language: nil,
emojis: [
%{
shortcode: "2hu",
url: "corndog.png",
static_url: "corndog.png",
visible_in_picker: false
}
],
pleroma: %{
local: true,
conversation_id: convo_id,
context: object_data["context"],
in_reply_to_account_acct: nil,
quote: nil,
quote_id: nil,
quote_url: nil,
quote_visible: false,
content: %{"text/plain" => HTML.strip_tags(object_data["content"])},
spoiler_text: %{"text/plain" => HTML.strip_tags(object_data["summary"])},
expires_at: nil,
direct_conversation_id: nil,
thread_muted: false,
emoji_reactions: [],
parent_visible: false,
pinned_at: nil,
quotes_count: 0,
bookmark_folder: nil,
list_id: nil
},
quotes_count: 0
}
assert status == expected
assert_schema(status, "Status", Pleroma.Web.ApiSpec.spec())
end
test "tells if the message is muted for some reason" do
user = insert(:user)
other_user = insert(:user)
{:ok, _user_relationships} = User.mute(user, other_user)
{:ok, activity} = CommonAPI.post(other_user, %{status: "test"})
relationships_opt = UserRelationship.view_relationships_option(user, [other_user])
opts = %{activity: activity}
status = StatusView.render("show.json", opts)
assert status.muted == false
assert_schema(status, "Status", Pleroma.Web.ApiSpec.spec())
status = StatusView.render("show.json", Map.put(opts, :relationships, relationships_opt))
assert status.muted == false
for_opts = %{activity: activity, for: user}
status = StatusView.render("show.json", for_opts)
assert status.muted == true
status = StatusView.render("show.json", Map.put(for_opts, :relationships, relationships_opt))
assert status.muted == true
assert_schema(status, "Status", Pleroma.Web.ApiSpec.spec())
end
test "tells if the message is thread muted" do
user = insert(:user)
other_user = insert(:user)
{:ok, _user_relationships} = User.mute(user, other_user)
{:ok, activity} = CommonAPI.post(other_user, %{status: "test"})
status = StatusView.render("show.json", %{activity: activity, for: user})
assert status.pleroma.thread_muted == false
{:ok, activity} = CommonAPI.add_mute(activity, user)
status = StatusView.render("show.json", %{activity: activity, for: user})
assert status.pleroma.thread_muted == true
end
test "tells if the status is bookmarked" do
user = insert(:user)
{:ok, activity} = CommonAPI.post(user, %{status: "Cute girls doing cute things"})
status = StatusView.render("show.json", %{activity: activity})
assert status.bookmarked == false
status = StatusView.render("show.json", %{activity: activity, for: user})
assert status.bookmarked == false
{:ok, _bookmark} = Bookmark.create(user.id, activity.id)
activity = Activity.get_by_id_with_object(activity.id)
status = StatusView.render("show.json", %{activity: activity, for: user})
assert status.bookmarked == true
end
test "a reply" do
note = insert(:note_activity)
user = insert(:user)
{:ok, activity} = CommonAPI.post(user, %{status: "he", in_reply_to_status_id: note.id})
status = StatusView.render("show.json", %{activity: activity})
assert status.in_reply_to_id == to_string(note.id)
[status] = StatusView.render("index.json", %{activities: [activity], as: :activity})
assert status.in_reply_to_id == to_string(note.id)
end
test "a quote post" do
post = insert(:note_activity)
user = insert(:user)
- {:ok, quote_post} = CommonAPI.post(user, %{status: "he", quote_id: post.id})
- {:ok, quoted_quote_post} = CommonAPI.post(user, %{status: "yo", quote_id: quote_post.id})
+ {:ok, quote_post} = CommonAPI.post(user, %{status: "he", quoted_status_id: post.id})
+
+ {:ok, quoted_quote_post} =
+ CommonAPI.post(user, %{status: "yo", quoted_status_id: quote_post.id})
status = StatusView.render("show.json", %{activity: quoted_quote_post})
assert status.pleroma.quote.id == to_string(quote_post.id)
assert status.pleroma.quote_id == to_string(quote_post.id)
assert status.pleroma.quote_url == Object.normalize(quote_post).data["id"]
assert status.pleroma.quote_visible
# Quotes don't go more than one level deep
refute status.pleroma.quote.pleroma.quote
assert status.pleroma.quote.pleroma.quote_id == to_string(post.id)
assert status.pleroma.quote.pleroma.quote_url == Object.normalize(post).data["id"]
assert status.pleroma.quote.pleroma.quote_visible
# In an index
[status] = StatusView.render("index.json", %{activities: [quoted_quote_post], as: :activity})
assert status.pleroma.quote.id == to_string(quote_post.id)
end
test "quoted private post" do
user = insert(:user)
# Insert a private post
private = insert(:followers_only_note_activity, user: user)
private_object = Object.normalize(private)
# Create a public post quoting the private post
quote_private =
insert(:note_activity,
note: insert(:note, data: %{"quoteUrl" => private_object.data["id"]})
)
status = StatusView.render("show.json", %{activity: quote_private})
# The quote isn't rendered
refute status.pleroma.quote
assert status.pleroma.quote_url == private_object.data["id"]
refute status.pleroma.quote_visible
# After following the user, the quote is rendered
follower = insert(:user)
CommonAPI.follow(user, follower)
status = StatusView.render("show.json", %{activity: quote_private, for: follower})
assert status.pleroma.quote.id == to_string(private.id)
assert status.pleroma.quote_visible
end
test "quoted direct message" do
# Insert a direct message
direct = insert(:direct_note_activity)
direct_object = Object.normalize(direct)
# Create a public post quoting the direct message
quote_direct =
insert(:note_activity, note: insert(:note, data: %{"quoteUrl" => direct_object.data["id"]}))
status = StatusView.render("show.json", %{activity: quote_direct})
# The quote isn't rendered
refute status.pleroma.quote
assert status.pleroma.quote_url == direct_object.data["id"]
refute status.pleroma.quote_visible
end
test "repost of quote post" do
post = insert(:note_activity)
user = insert(:user)
- {:ok, quote_post} = CommonAPI.post(user, %{status: "he", quote_id: post.id})
+ {:ok, quote_post} = CommonAPI.post(user, %{status: "he", quoted_status_id: post.id})
{:ok, repost} = CommonAPI.repeat(quote_post.id, user)
[status] = StatusView.render("index.json", %{activities: [repost], as: :activity})
assert status.reblog.pleroma.quote.id == to_string(post.id)
end
test "contains mentions" do
user = insert(:user)
mentioned = insert(:user)
{:ok, activity} = CommonAPI.post(user, %{status: "hi @#{mentioned.nickname}"})
status = StatusView.render("show.json", %{activity: activity})
assert status.mentions ==
Enum.map([mentioned], fn u -> AccountView.render("mention.json", %{user: u}) end)
assert_schema(status, "Status", Pleroma.Web.ApiSpec.spec())
end
test "create mentions from the 'to' field" do
%User{ap_id: recipient_ap_id} = insert(:user)
cc = insert_pair(:user) |> Enum.map(& &1.ap_id)
object =
insert(:note, %{
data: %{
"to" => [recipient_ap_id],
"cc" => cc
}
})
activity =
insert(:note_activity, %{
note: object,
recipients: [recipient_ap_id | cc]
})
assert length(activity.recipients) == 3
%{mentions: [mention] = mentions} = StatusView.render("show.json", %{activity: activity})
assert length(mentions) == 1
assert mention.url == recipient_ap_id
end
test "create mentions from the 'tag' field" do
recipient = insert(:user)
cc = insert_pair(:user) |> Enum.map(& &1.ap_id)
object =
insert(:note, %{
data: %{
"cc" => cc,
"tag" => [
%{
"href" => recipient.ap_id,
"name" => recipient.nickname,
"type" => "Mention"
},
%{
"href" => "https://example.com/search?tag=test",
"name" => "#test",
"type" => "Hashtag"
}
]
}
})
activity =
insert(:note_activity, %{
note: object,
recipients: [recipient.ap_id | cc]
})
assert length(activity.recipients) == 3
%{mentions: [mention] = mentions} = StatusView.render("show.json", %{activity: activity})
assert length(mentions) == 1
assert mention.url == recipient.ap_id
end
describe "attachments" do
test "Complete Mastodon style" do
object = %{
"type" => "Image",
"url" => [
%{
"mediaType" => "image/png",
"href" => "someurl",
"width" => 200,
"height" => 100
}
],
"blurhash" => "UJJ8X[xYW,%Jtq%NNFbXB5j]IVM|9GV=WHRn",
"uuid" => 6
}
expected = %{
id: "1638338801",
type: "image",
url: "someurl",
remote_url: "someurl",
preview_url: "someurl",
text_url: "someurl",
description: nil,
pleroma: %{mime_type: "image/png"},
meta: %{original: %{width: 200, height: 100, aspect: 2}},
blurhash: "UJJ8X[xYW,%Jtq%NNFbXB5j]IVM|9GV=WHRn"
}
api_spec = Pleroma.Web.ApiSpec.spec()
assert expected == StatusView.render("attachment.json", %{attachment: object})
assert_schema(expected, "Attachment", api_spec)
# If theres a "id", use that instead of the generated one
object = Map.put(object, "id", 2)
result = StatusView.render("attachment.json", %{attachment: object})
assert %{id: "2"} = result
assert_schema(result, "Attachment", api_spec)
end
test "Honkerific" do
object = %{
"type" => "Image",
"url" => [
%{
"mediaType" => "image/png",
"href" => "someurl"
}
],
"name" => "fool.jpeg",
"summary" => "they have played us for absolute fools."
}
expected = %{
blurhash: nil,
description: "they have played us for absolute fools.",
id: "1638338801",
pleroma: %{mime_type: "image/png", name: "fool.jpeg"},
preview_url: "someurl",
remote_url: "someurl",
text_url: "someurl",
type: "image",
url: "someurl"
}
api_spec = Pleroma.Web.ApiSpec.spec()
assert expected == StatusView.render("attachment.json", %{attachment: object})
assert_schema(expected, "Attachment", api_spec)
end
end
test "put the url advertised in the Activity in to the url attribute" do
id = "https://wedistribute.org/wp-json/pterotype/v1/object/85810"
[activity] = Activity.search(nil, id)
status = StatusView.render("show.json", %{activity: activity})
assert status.uri == id
assert status.url == "https://wedistribute.org/2019/07/mastodon-drops-ostatus/"
end
test "a reblog" do
user = insert(:user)
activity = insert(:note_activity)
{:ok, reblog} = CommonAPI.repeat(activity.id, user)
represented = StatusView.render("show.json", %{for: user, activity: reblog})
assert represented[:id] == to_string(reblog.id)
assert represented[:reblog][:id] == to_string(activity.id)
assert represented[:emojis] == []
assert_schema(represented, "Status", Pleroma.Web.ApiSpec.spec())
end
test "a peertube video" do
user = insert(:user)
{:ok, object} =
Pleroma.Object.Fetcher.fetch_object_from_id(
"https://peertube.moe/videos/watch/df5f464b-be8d-46fb-ad81-2d4c2d1630e3"
)
%Activity{} = activity = Activity.get_create_by_object_ap_id(object.data["id"])
represented = StatusView.render("show.json", %{for: user, activity: activity})
assert represented[:id] == to_string(activity.id)
assert length(represented[:media_attachments]) == 1
assert_schema(represented, "Status", Pleroma.Web.ApiSpec.spec())
end
test "funkwhale audio" do
user = insert(:user)
{:ok, object} =
Pleroma.Object.Fetcher.fetch_object_from_id(
"https://channels.tests.funkwhale.audio/federation/music/uploads/42342395-0208-4fee-a38d-259a6dae0871"
)
%Activity{} = activity = Activity.get_create_by_object_ap_id(object.data["id"])
represented = StatusView.render("show.json", %{for: user, activity: activity})
assert represented[:id] == to_string(activity.id)
assert length(represented[:media_attachments]) == 1
end
test "a Mobilizon event" do
user = insert(:user)
{:ok, object} =
Pleroma.Object.Fetcher.fetch_object_from_id(
"https://mobilizon.org/events/252d5816-00a3-4a89-a66f-15bf65c33e39"
)
%Activity{} = activity = Activity.get_create_by_object_ap_id(object.data["id"])
represented = StatusView.render("show.json", %{for: user, activity: activity})
assert represented[:id] == to_string(activity.id)
assert represented[:url] ==
"https://mobilizon.org/events/252d5816-00a3-4a89-a66f-15bf65c33e39"
assert represented[:content] ==
"<p><a href=\"https://mobilizon.org/events/252d5816-00a3-4a89-a66f-15bf65c33e39\">Mobilizon Launching Party</a></p><p>Mobilizon is now federated! 🎉</p><p></p><p>You can view this event from other instances if they are subscribed to mobilizon.org, and soon directly from Mastodon and Pleroma. It is possible that you may see some comments from other instances, including Mastodon ones, just below.</p><p></p><p>With a Mobilizon account on an instance, you may <strong>participate</strong> at events from other instances and <strong>add comments</strong> on events.</p><p></p><p>Of course, it&#39;s still <u>a work in progress</u>: if reports made from an instance on events and comments can be federated, you can&#39;t block people right now, and moderators actions are rather limited, but this <strong>will definitely get fixed over time</strong> until first stable version next year.</p><p></p><p>Anyway, if you want to come up with some feedback, head over to our forum or - if you feel you have technical skills and are familiar with it - on our Gitlab repository.</p><p></p><p>Also, to people that want to set Mobilizon themselves even though we really don&#39;t advise to do that for now, we have a little documentation but it&#39;s quite the early days and you&#39;ll probably need some help. No worries, you can chat with us on our Forum or though our Matrix channel.</p><p></p><p>Check our website for more informations and follow us on Twitter or Mastodon.</p>"
end
describe "build_tags/1" do
test "it returns a a dictionary tags" do
object_tags = [
"fediverse",
"mastodon",
"nextcloud",
%{
"href" => "https://kawen.space/users/lain",
"name" => "@lain@kawen.space",
"type" => "Mention"
}
]
assert StatusView.build_tags(object_tags) == [
%{name: "fediverse", url: "http://localhost:4001/tag/fediverse"},
%{name: "mastodon", url: "http://localhost:4001/tag/mastodon"},
%{name: "nextcloud", url: "http://localhost:4001/tag/nextcloud"}
]
end
end
describe "rich media cards" do
test "a rich media card without a site name renders correctly" do
page_url = "https://example.com"
{:ok, card} =
Card.create(page_url, %{image: page_url <> "/example.jpg", title: "Example website"})
assert match?(%{provider_name: "example.com"}, StatusView.render("card.json", card))
end
test "a rich media card without a site name or image renders correctly" do
page_url = "https://example.com"
fields = %{
"url" => page_url,
"title" => "Example website"
}
{:ok, card} = Card.create(page_url, fields)
assert match?(%{provider_name: "example.com"}, StatusView.render("card.json", card))
end
test "a rich media card without an image renders correctly" do
page_url = "https://example.com"
fields = %{
"url" => page_url,
"site_name" => "Example site name",
"title" => "Example website"
}
{:ok, card} = Card.create(page_url, fields)
assert match?(%{provider_name: "example.com"}, StatusView.render("card.json", card))
end
test "a rich media card without descriptions returns the fields with empty strings" do
page_url = "https://example.com"
fields = %{
"url" => page_url,
"site_name" => "Example site name",
"title" => "Example website"
}
{:ok, card} = Card.create(page_url, fields)
assert match?(
%{description: "", image_description: ""},
StatusView.render("card.json", card)
)
end
test "a rich media card with all relevant data renders correctly" do
page_url = "https://example.com"
fields = %{
"url" => page_url,
"site_name" => "Example site name",
"title" => "Example website",
"image" => page_url <> "/example.jpg",
"description" => "Example description"
}
{:ok, card} = Card.create(page_url, fields)
assert match?(%{provider_name: "example.com"}, StatusView.render("card.json", card))
end
test "a rich media card has all media proxied" do
clear_config([:media_proxy, :enabled], true)
clear_config([:media_preview_proxy, :enabled])
ConfigMock
|> stub_with(Pleroma.Test.StaticConfig)
page_url = "https://example.com"
fields = %{
"url" => page_url,
"site_name" => "Example site name",
"title" => "Example website",
"image" => page_url <> "/example.jpg",
"audio" => page_url <> "/example.ogg",
"video" => page_url <> "/example.mp4",
"description" => "Example description"
}
{:ok, card} = Card.create(page_url, fields)
%{
provider_name: "example.com",
image: image,
pleroma: %{opengraph: og}
} = StatusView.render("card.json", card)
assert String.match?(image, ~r/\/proxy\//)
assert String.match?(og["image"], ~r/\/proxy\//)
assert String.match?(og["audio"], ~r/\/proxy\//)
assert String.match?(og["video"], ~r/\/proxy\//)
end
end
test "does not embed a relationship in the account" do
user = insert(:user)
other_user = insert(:user)
{:ok, activity} =
CommonAPI.post(user, %{
status: "drink more water"
})
result = StatusView.render("show.json", %{activity: activity, for: other_user})
assert result[:account][:pleroma][:relationship] == %{}
assert_schema(result, "Status", Pleroma.Web.ApiSpec.spec())
end
test "does not embed a relationship in the account in reposts" do
user = insert(:user)
other_user = insert(:user)
{:ok, activity} =
CommonAPI.post(user, %{
status: "˙˙ɐʎns"
})
{:ok, activity} = CommonAPI.repeat(activity.id, other_user)
result = StatusView.render("show.json", %{activity: activity, for: user})
assert result[:account][:pleroma][:relationship] == %{}
assert result[:reblog][:account][:pleroma][:relationship] == %{}
assert_schema(result, "Status", Pleroma.Web.ApiSpec.spec())
end
test "visibility/list" do
user = insert(:user)
{:ok, list} = Pleroma.List.create("foo", user)
{:ok, activity} = CommonAPI.post(user, %{status: "foobar", visibility: "list:#{list.id}"})
status = StatusView.render("show.json", activity: activity)
assert status.visibility == "list"
assert status.pleroma.list_id == nil
status = StatusView.render("show.json", activity: activity, for: user)
assert status.pleroma.list_id == list.id
end
test "has a field for parent visibility" do
user = insert(:user)
poster = insert(:user)
{:ok, invisible} = CommonAPI.post(poster, %{status: "hey", visibility: "private"})
{:ok, visible} =
CommonAPI.post(poster, %{status: "hey", visibility: "private", in_reply_to_id: invisible.id})
status = StatusView.render("show.json", activity: visible, for: user)
refute status.pleroma.parent_visible
status = StatusView.render("show.json", activity: visible, for: poster)
assert status.pleroma.parent_visible
end
test "it shows edited_at" do
poster = insert(:user)
{:ok, post} = CommonAPI.post(poster, %{status: "hey"})
status = StatusView.render("show.json", activity: post)
refute status.edited_at
{:ok, _} = CommonAPI.update(post, poster, %{status: "mew mew"})
edited = Pleroma.Activity.normalize(post)
status = StatusView.render("show.json", activity: edited)
assert status.edited_at
end
test "it shows post language" do
user = insert(:user)
{:ok, post} = CommonAPI.post(user, %{status: "Szczęść Boże", language: "pl"})
status = StatusView.render("show.json", activity: post)
assert status.language == "pl"
end
test "doesn't show post language if it's 'und'" do
user = insert(:user)
{:ok, post} = CommonAPI.post(user, %{status: "sdifjogijodfg", language: "und"})
status = StatusView.render("show.json", activity: post)
assert status.language == nil
end
test "with a source object" do
note =
insert(:note,
data: %{"source" => %{"content" => "object source", "mediaType" => "text/markdown"}}
)
activity = insert(:note_activity, note: note)
status = StatusView.render("show.json", activity: activity, with_source: true)
assert status.text == "object source"
end
describe "source.json" do
test "with a source object, renders both source and content type" do
note =
insert(:note,
data: %{"source" => %{"content" => "object source", "mediaType" => "text/markdown"}}
)
activity = insert(:note_activity, note: note)
status = StatusView.render("source.json", activity: activity)
assert status.text == "object source"
assert status.content_type == "text/markdown"
end
test "with a source string, renders source and put text/plain as the content type" do
note = insert(:note, data: %{"source" => "string source"})
activity = insert(:note_activity, note: note)
status = StatusView.render("source.json", activity: activity)
assert status.text == "string source"
assert status.content_type == "text/plain"
end
end
end
diff --git a/test/pleroma/web/pleroma_api/controllers/status_controller_test.exs b/test/pleroma/web/pleroma_api/controllers/status_controller_test.exs
index 73d497d92..4b4418384 100644
--- a/test/pleroma/web/pleroma_api/controllers/status_controller_test.exs
+++ b/test/pleroma/web/pleroma_api/controllers/status_controller_test.exs
@@ -1,30 +1,30 @@
# Pleroma: A lightweight social networking server
# Copyright © 2017-2022 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Web.PleromaAPI.StatusControllerTest do
use Pleroma.Web.ConnCase
alias Pleroma.Web.CommonAPI
import Pleroma.Factory
test "/quotes fallback works" do
[current_user, user] = insert_pair(:user)
%{conn: conn} = oauth_access(["read:statuses"], user: current_user)
activity = insert(:note_activity)
- {:ok, quote_post} = CommonAPI.post(user, %{status: "quoat", quote_id: activity.id})
+ {:ok, quote_post} = CommonAPI.post(user, %{status: "quoat", quoted_status_id: activity.id})
response =
conn
|> get("/api/v1/pleroma/statuses/#{activity.id}/quotes")
|> json_response_and_validate_schema(:ok)
[status] = response
assert length(response) == 1
assert status["id"] == quote_post.id
end
end

File Metadata

Mime Type
text/x-diff
Expires
Thu, Oct 29, 11:59 PM (1 d, 16 h)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
1798927
Default Alt Text
(274 KB)

Event Timeline