Merge branch 'develop' into gitlab-mr-iid-4315

This commit is contained in:
Lain Soykaf 2026-07-27 11:34:31 +04:00
commit b4c624911c
No known key found for this signature in database
1163 changed files with 40459 additions and 6082 deletions

View file

@ -0,0 +1,34 @@
{
"@context": [
"https://www.w3.org/ns/activitystreams",
"https://w3id.org/security/v1",
"https://www.w3.org/ns/did/v1",
"https://w3id.org/security/multikey/v1",
{
"Hashtag": "as:Hashtag"
}
],
"attributedTo": "https://mymath.rocks/endpoints/SYn3cl_N4HAPfPHgo2x37XunLEmhV9LnxCggcYwyec0",
"cc": [
"https://mymath.rocks/endpoints/30zoCe7haKBEFolH4rbAmKj-t9_bG0c2X2kMQkJk5qY",
"https://mastodon.social/users/nikclayton"
],
"content": "<blockquote class=\"h-quote\">\n<p>I note that mymath.rocks does not provide this information.</p>\n</blockquote>\n<p>I&#39;m a big believer in &quot;Do as I say, not as I <strong>did</strong>&quot;.</p>\n<p>I could give a long list of technical reasons, which boil down to: nodeinfo is pretty nonsensical with the way I write stuff.</p>\n<p>I think the above statement also addresses a main failure of the Fediverse. People, e.g. me, would love to fix stuff. Unfortunately, we lack the focus to address a lot of issues, e.g. nodeinfo sucks. So stuff gets done in a broken way.</p>\n<p>I think the main challenge the Fediverse has faced, and failed at, is avoiding the above situation. To continue the example, there is no way for somebody to say: Let&#39;s fix nodeinfo and people will follow their ideas.</p>\n",
"id": "https://mymath.rocks/objects/2b89e564-30cf-4eeb-97ca-7e638a154026",
"inReplyTo": "https://mastodon.social/users/nikclayton/statuses/115496665258618127",
"likes": "https://mymath.rocks/objects/2b89e564-30cf-4eeb-97ca-7e638a154026/likes",
"published": "2025-11-06T08:21:17.790Z",
"replies": "https://mymath.rocks/objects/2b89e564-30cf-4eeb-97ca-7e638a154026/replies",
"shares": "https://mymath.rocks/objects/2b89e564-30cf-4eeb-97ca-7e638a154026/shares",
"source": {
"content": "> I note that mymath.rocks does not provide this information.\n\nI'm a big believer in \"Do as I say, not as I __did__\".\n\nI could give a long list of technical reasons, which boil down to: nodeinfo is pretty nonsensical with the way I write stuff.\n\nI think the above statement also addresses a main failure of the Fediverse. People, e.g. me, would love to fix stuff. Unfortunately, we lack the focus to address a lot of issues, e.g. nodeinfo sucks. So stuff gets done in a broken way.\n\nI think the main challenge the Fediverse has faced, and failed at, is avoiding the above situation. To continue the example, there is no way for somebody to say: Let's fix nodeinfo and people will follow their ideas.",
"mediaType": "text/markdown"
},
"tag": {
"href": "https://mastodon.social/users/nikclayton",
"name": "https://mastodon.social/users/nikclayton",
"type": "Mention"
},
"to": "as:Public",
"type": "Note"
}

BIN
test/fixtures/break_analyze.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 360 KiB

View file

@ -0,0 +1,5 @@
import Config
config :pleroma, Pleroma.Search, module: Pleroma.Search.ParadeDB
config :pleroma, Pleroma.Search.ParadeDB, url: "postgres://localhost/paradedb"
config :pleroma, Pleroma.Search.ParadeDB.Repo, pool_size: 2

View file

@ -0,0 +1,32 @@
{
"@context": [
"https://www.w3.org/ns/activitystreams"
],
"type": "Create",
"id": "https://fedidev.fun/activity/attachment-embedded-note",
"actor": "https://fedidev.fun/users/alice",
"to": [
"https://www.w3.org/ns/activitystreams#Public"
],
"cc": [],
"published": "2025-11-21T14:43:01Z",
"object": {
"type": "Note",
"id": "https://fedidev.fun/objects/note-attachment-embedded-note",
"attributedTo": "https://fedidev.fun/users/alice",
"to": [
"https://www.w3.org/ns/activitystreams#Public"
],
"cc": [],
"published": "2025-11-21T14:43:01Z",
"content": "Note with embedded Note attachment",
"attachment": {
"type": "Note",
"attributedTo": "https://fedidev.fun/users/alice",
"name": "attached note",
"content": "This is just a note",
"published": "2024-03-06T07:23:56Z"
}
}
}

View file

@ -0,0 +1,31 @@
{
"@context": [
"https://www.w3.org/ns/activitystreams"
],
"type": "Create",
"id": "https://fedidev.fun/activity/attachment-payto-link",
"actor": "https://fedidev.fun/users/alice",
"to": [
"https://www.w3.org/ns/activitystreams#Public"
],
"cc": [],
"published": "2025-11-21T14:43:00Z",
"object": {
"type": "Note",
"id": "https://fedidev.fun/objects/note-attachment-payto-link",
"attributedTo": "https://fedidev.fun/users/alice",
"to": [
"https://www.w3.org/ns/activitystreams#Public"
],
"cc": [],
"published": "2025-11-21T14:43:00Z",
"content": "Payment Link",
"attachment": {
"type": "Link",
"name": "Donate",
"href": "payto://iban/DE75512108001245126199",
"rel": "payment"
}
}
}

View file

@ -0,0 +1,40 @@
{
"@context": [
"https://www.w3.org/ns/activitystreams"
],
"type": "Create",
"id": "https://fedidev.fun/activity/attachment-url-list",
"actor": "https://fedidev.fun/users/alice",
"to": [
"https://www.w3.org/ns/activitystreams#Public"
],
"cc": [],
"published": "2025-11-21T14:43:02Z",
"object": {
"type": "Note",
"id": "https://fedidev.fun/objects/note-attachment-url-list",
"attributedTo": "https://fedidev.fun/users/alice",
"to": [
"https://www.w3.org/ns/activitystreams#Public"
],
"cc": [],
"published": "2025-11-21T14:43:02Z",
"content": "url is array of two Link objects",
"attachment": {
"type": "Image",
"url": [
{
"type": "Link",
"href": "https://fedidev.fun/images/007.png",
"mediaType": "image/png"
},
{
"type": "Link",
"href": "https://fedidev.fun/images/008.jpg",
"mediaType": "image/jpeg"
}
]
}
}
}

View file

@ -0,0 +1,32 @@
{
"@context": [
"https://www.w3.org/ns/activitystreams"
],
"type": "Create",
"id": "https://fedidev.fun/activity/emoji-icon-string",
"actor": "https://fedidev.fun/users/alice",
"to": [
"https://www.w3.org/ns/activitystreams#Public"
],
"cc": [],
"published": "2025-11-21T14:40:00Z",
"object": {
"type": "Note",
"id": "https://fedidev.fun/objects/note-emoji-icon-string",
"attributedTo": "https://fedidev.fun/users/alice",
"to": [
"https://www.w3.org/ns/activitystreams#Public"
],
"cc": [],
"published": "2025-11-21T14:40:00Z",
"content": "emoji icon as string :cow:",
"tag": [
{
"type": "Emoji",
"name": ":cow:",
"icon": "https://fedidev.fun/static/cow.png"
}
]
}
}

View file

@ -0,0 +1,34 @@
{
"@context": [
"https://www.w3.org/ns/activitystreams"
],
"type": "Create",
"id": "https://fedidev.fun/activity/emoji-icon-url-missing-type",
"actor": "https://fedidev.fun/users/alice",
"to": [
"https://www.w3.org/ns/activitystreams#Public"
],
"cc": [],
"published": "2025-11-21T14:40:01Z",
"object": {
"type": "Note",
"id": "https://fedidev.fun/objects/note-emoji-icon-url-missing-type",
"attributedTo": "https://fedidev.fun/users/alice",
"to": [
"https://www.w3.org/ns/activitystreams#Public"
],
"cc": [],
"published": "2025-11-21T14:40:01Z",
"content": "emoji icon url missing type :cow2:",
"tag": [
{
"type": "Emoji",
"name": ":cow2:",
"icon": {
"url": "https://fedidev.fun/static/cow.png"
}
}
]
}
}

View file

@ -0,0 +1,31 @@
{
"@context": [
"https://www.w3.org/ns/activitystreams"
],
"type": "Create",
"id": "https://fedidev.fun/activity/emoji-missing-icon",
"actor": "https://fedidev.fun/users/alice",
"to": [
"https://www.w3.org/ns/activitystreams#Public"
],
"cc": [],
"published": "2025-11-21T14:40:02Z",
"object": {
"type": "Note",
"id": "https://fedidev.fun/objects/note-emoji-missing-icon",
"attributedTo": "https://fedidev.fun/users/alice",
"to": [
"https://www.w3.org/ns/activitystreams#Public"
],
"cc": [],
"published": "2025-11-21T14:40:02Z",
"content": "emoji missing icon :cow3:",
"tag": [
{
"type": "Emoji",
"name": ":cow3:"
}
]
}
}

View file

@ -0,0 +1,30 @@
{
"@context": [
"https://www.w3.org/ns/activitystreams"
],
"type": "Create",
"id": "https://fedidev.fun/activity/in-reply-to-collection",
"actor": "https://fedidev.fun/users/alice",
"to": [
"https://www.w3.org/ns/activitystreams#Public"
],
"cc": [],
"published": "2025-11-21T14:40:08Z",
"object": {
"type": "Note",
"id": "https://fedidev.fun/objects/note-in-reply-to-collection",
"attributedTo": "https://fedidev.fun/users/alice",
"to": [
"https://www.w3.org/ns/activitystreams#Public"
],
"cc": [],
"published": "2025-11-21T14:40:08Z",
"content": "collection",
"inReplyTo": {
"type": "Collection",
"items": [
"https://fedidev.fun/users/alice"
]
}
}
}

View file

@ -0,0 +1,35 @@
{
"@context": [
"https://www.w3.org/ns/activitystreams"
],
"type": "Create",
"id": "https://fedidev.fun/activity/in-reply-to-embedded-note",
"actor": "https://fedidev.fun/users/alice",
"to": [
"https://www.w3.org/ns/activitystreams#Public"
],
"cc": [],
"published": "2025-11-21T14:40:21Z",
"object": {
"type": "Note",
"id": "https://fedidev.fun/objects/note-in-reply-to-embedded-note",
"attributedTo": "https://fedidev.fun/users/alice",
"to": [
"https://www.w3.org/ns/activitystreams#Public"
],
"cc": [],
"published": "2025-11-21T14:40:21Z",
"content": "embedded note",
"inReplyTo": {
"@context": "https://www.w3.org/ns/activitystreams",
"type": "Note",
"id": "https://fedidev.fun/assets/note1.jsonap",
"to": [
"https://www.w3.org/ns/activitystreams#Public"
],
"attributedTo": "https://fedidev.fun/users/alice",
"content": "One",
"published": "2024-01-06T13:11:45Z"
}
}
}

View file

@ -0,0 +1,28 @@
{
"@context": [
"https://www.w3.org/ns/activitystreams"
],
"type": "Create",
"id": "https://fedidev.fun/activity/in-reply-to-link",
"actor": "https://fedidev.fun/users/alice",
"to": [
"https://www.w3.org/ns/activitystreams#Public"
],
"cc": [],
"published": "2025-11-21T14:39:56Z",
"object": {
"type": "Note",
"id": "https://fedidev.fun/objects/note-in-reply-to-link",
"attributedTo": "https://fedidev.fun/users/alice",
"to": [
"https://www.w3.org/ns/activitystreams#Public"
],
"cc": [],
"published": "2025-11-21T14:39:56Z",
"content": "Link in inReplyTo",
"inReplyTo": {
"type": "Link",
"href": "https://fedidev.fun/assets/note1.jsonap"
}
}
}

View file

@ -0,0 +1,28 @@
{
"@context": [
"https://www.w3.org/ns/activitystreams"
],
"type": "Create",
"id": "https://fedidev.fun/activity/in-reply-to-list",
"actor": "https://fedidev.fun/users/alice",
"to": [
"https://www.w3.org/ns/activitystreams#Public"
],
"cc": [],
"published": "2025-11-21T14:40:08Z",
"object": {
"type": "Note",
"id": "https://fedidev.fun/objects/note-in-reply-to-list",
"attributedTo": "https://fedidev.fun/users/alice",
"to": [
"https://www.w3.org/ns/activitystreams#Public"
],
"cc": [],
"published": "2025-11-21T14:40:08Z",
"content": "two elements in inReplyTo",
"inReplyTo": [
"https://fedidev.fun/assets/note1.jsonap",
"https://fedidev.fun/assets/note2.jsonap"
]
}
}

View file

@ -0,0 +1,25 @@
{
"@context": [
"https://www.w3.org/ns/activitystreams"
],
"type": "Create",
"id": "https://fedidev.fun/activity/missing-attributedto",
"actor": "https://fedidev.fun/users/alice",
"to": [
"https://www.w3.org/ns/activitystreams#Public"
],
"cc": [],
"published": "2025-11-21T14:42:00Z",
"object": {
"type": "Note",
"id": "https://fedidev.fun/objects/note-missing-attributedto",
"attributedTo": null,
"to": [
"https://www.w3.org/ns/activitystreams#Public"
],
"cc": [],
"published": "2025-11-21T14:42:00Z",
"content": "object without attributedTo"
}
}

View file

@ -0,0 +1,28 @@
{
"@context": [
"https://www.w3.org/ns/activitystreams"
],
"type": "Create",
"id": "https://fedidev.fun/activity/misskey-quote-list",
"actor": "https://fedidev.fun/users/alice",
"to": [
"https://www.w3.org/ns/activitystreams#Public"
],
"cc": [],
"published": "2025-11-21T14:43:53Z",
"object": {
"type": "Note",
"id": "https://fedidev.fun/objects/note-misskey-quote-list",
"attributedTo": "https://fedidev.fun/users/alice",
"to": [
"https://www.w3.org/ns/activitystreams#Public"
],
"cc": [],
"published": "2025-11-21T14:43:53Z",
"content": "_misskey_quote attribute as list",
"_misskey_quote": [
"https://fedidev.fun/assets/note1.jsonap",
"https://fedidev.fun/assets/note2.jsonap"
]
}
}

View file

@ -0,0 +1,28 @@
{
"@context": [
"https://www.w3.org/ns/activitystreams"
],
"type": "Create",
"id": "https://fedidev.fun/activity/object-type-list",
"actor": "https://fedidev.fun/users/alice",
"to": [
"https://www.w3.org/ns/activitystreams#Public"
],
"cc": [],
"published": "2025-11-21T14:43:32Z",
"object": {
"type": [
"Note"
],
"id": "https://fedidev.fun/objects/note-object-type-list",
"attributedTo": "https://fedidev.fun/users/alice",
"to": [
"https://www.w3.org/ns/activitystreams#Public"
],
"cc": [],
"published": "2025-11-21T14:43:32Z",
"name": "name",
"summary": "summary",
"content": "content"
}
}

View file

@ -0,0 +1,28 @@
{
"@context": [
"https://www.w3.org/ns/activitystreams"
],
"type": "Create",
"id": "https://fedidev.fun/activity/quote-uri-list",
"actor": "https://fedidev.fun/users/alice",
"to": [
"https://www.w3.org/ns/activitystreams#Public"
],
"cc": [],
"published": "2025-11-21T14:43:52Z",
"object": {
"type": "Note",
"id": "https://fedidev.fun/objects/note-quote-uri-list",
"attributedTo": "https://fedidev.fun/users/alice",
"to": [
"https://www.w3.org/ns/activitystreams#Public"
],
"cc": [],
"published": "2025-11-21T14:43:52Z",
"content": "quoteUri attribute as list",
"quoteUri": [
"https://fedidev.fun/assets/note1.jsonap",
"https://fedidev.fun/assets/note2.jsonap"
]
}
}

View file

@ -0,0 +1,28 @@
{
"@context": [
"https://www.w3.org/ns/activitystreams"
],
"type": "Create",
"id": "https://fedidev.fun/activity/quote-url-list",
"actor": "https://fedidev.fun/users/alice",
"to": [
"https://www.w3.org/ns/activitystreams#Public"
],
"cc": [],
"published": "2025-11-21T14:43:52Z",
"object": {
"type": "Note",
"id": "https://fedidev.fun/objects/note-quote-url-list",
"attributedTo": "https://fedidev.fun/users/alice",
"to": [
"https://www.w3.org/ns/activitystreams#Public"
],
"cc": [],
"published": "2025-11-21T14:43:52Z",
"content": "quoteUrl attribute as list",
"quoteUrl": [
"https://fedidev.fun/assets/note1.jsonap",
"https://fedidev.fun/assets/note2.jsonap"
]
}
}

View file

@ -0,0 +1,30 @@
{
"@context": [
"https://www.w3.org/ns/activitystreams"
],
"type": "Create",
"id": "https://fedidev.fun/activity/tag-hashtag-missing-type",
"actor": "https://fedidev.fun/users/alice",
"to": [
"https://www.w3.org/ns/activitystreams#Public"
],
"cc": [],
"published": "2025-11-21T14:41:00Z",
"object": {
"type": "Note",
"id": "https://fedidev.fun/objects/note-tag-hashtag-missing-type",
"attributedTo": "https://fedidev.fun/users/alice",
"to": [
"https://www.w3.org/ns/activitystreams#Public"
],
"cc": [],
"published": "2025-11-21T14:41:00Z",
"content": "hashtag without type",
"tag": [
{
"name": "#Test"
}
]
}
}

View file

@ -0,0 +1,31 @@
{
"@context": [
"https://www.w3.org/ns/activitystreams"
],
"type": "Create",
"id": "https://fedidev.fun/activity/tag-mention-missing-type",
"actor": "https://fedidev.fun/users/alice",
"to": [
"https://www.w3.org/ns/activitystreams#Public"
],
"cc": [],
"published": "2025-11-21T14:41:01Z",
"object": {
"type": "Note",
"id": "https://fedidev.fun/objects/note-tag-mention-missing-type",
"attributedTo": "https://fedidev.fun/users/alice",
"to": [
"https://www.w3.org/ns/activitystreams#Public"
],
"cc": [],
"published": "2025-11-21T14:41:01Z",
"content": "mention without type",
"tag": [
{
"name": "@bob",
"href": "https://remote.example/users/bob"
}
]
}
}

27
test/fixtures/fedidev.fun/url_list.json vendored Normal file
View file

@ -0,0 +1,27 @@
{
"@context": [
"https://www.w3.org/ns/activitystreams"
],
"type": "Create",
"id": "https://fedidev.fun/activity/url-list",
"actor": "https://fedidev.fun/users/alice",
"to": [
"https://www.w3.org/ns/activitystreams#Public"
],
"cc": [],
"published": "2025-11-21T14:39:06Z",
"object": {
"type": "Note",
"id": "https://fedidev.fun/objects/note-url-list",
"attributedTo": "https://fedidev.fun/users/alice",
"to": [
"https://www.w3.org/ns/activitystreams#Public"
],
"cc": [],
"published": "2025-11-21T14:39:06Z",
"content": "text",
"url": [
"https://fedidev.fun/objects/123"
]
}
}

View file

@ -0,0 +1,76 @@
{
"@context": [
"https://www.w3.org/ns/activitystreams",
"https://w3id.org/security/v1",
{
"Hashtag": "as:Hashtag",
"PropertyValue": "schema:PropertyValue",
"conversation": "ostatus:conversation",
"dfrn": "http://purl.org/macgirvin/dfrn/1.0/",
"diaspora": "https://diasporafoundation.org/ns/",
"directMessage": "litepub:directMessage",
"discoverable": "toot:discoverable",
"featured": {
"@id": "toot:featured",
"@type": "@id"
},
"litepub": "http://litepub.social/ns#",
"manuallyApprovesFollowers": "as:manuallyApprovesFollowers",
"ostatus": "http://ostatus.org#",
"quoteUrl": "as:quoteUrl",
"schema": "http://schema.org#",
"sensitive": "as:sensitive",
"toot": "http://joinmastodon.org/ns#",
"value": "schema:value",
"vcard": "http://www.w3.org/2006/vcard/ns#"
}
],
"actor": "https://my-place.social/profile/vaartis",
"cc": [
"https://my-place.social/followers/vaartis"
],
"id": "https://my-place.social/objects/e599373b-1368-4b20-cd24-837166957182/Undo",
"instrument": {
"id": "https://my-place.social/friendica",
"name": "Friendica 'Interrupted Fern' 2024.12-1576",
"type": "Application",
"url": "https://my-place.social"
},
"object": {
"actor": "https://my-place.social/profile/vaartis",
"cc": [
"https://my-place.social/followers/vaartis"
],
"diaspora:guid": "e599373b-1968-4b20-cd24-80d340160302",
"diaspora:like": "{\"author\":\"vaartis@my-place.social\",\"guid\":\"e599373b-1968-4b20-cd24-80d340160302\",\"parent_guid\":\"cd36feba-c31f3ed3fd5c064a-17c31593\",\"parent_type\":\"Post\",\"positive\":\"false\",\"author_signature\":\"xR2zLJNfc9Nhx1n8LLMWM1kde12my4cqamIsrH\\/UntKzuDwO4DuHBL0fkFhgC\\/dylxm4HqsHD45MQbtwQCVGq6WhC96TrbMuYEK61HIO23dTr3m+qJVtfdH4wyhUNHgiiYPhZpkLDfnR1JiRWmFTlmZC8q8JEkOB5IQsrWia2eOR6IsqDcdKO\\/Urgns9\\/BdQi8KnchBKSEFc1iUtcOEruvhunKGyW5zI\\/Rltfdz3xGH8tlw+YlMXeWXPnqgOJ9GzNA0lwG4U421L6yylYagW7oxIznnBLB4bO46vYZbgXZV1hiI9ZyveHOinLMY1QkmTj5CNvnx3\\/VJwLovd0v+0Nr2vu\\/3ftbpBXc6L1bsNjlRqtsfwJlcgl+tH1DC4W8tKf+Y3tdtzVw0CHXCuacxHLyq5wZd\\/5YfYR9SJQ\\/jInU4PHA5+hIE3PGqNUp5QfFE0umq56H7MQKsIPgM5mMV4fPAA8OpltuMVDvQYUxalrnvoTf00k90x1wCTK71\\/jQGh7r7PmGvSdfPr+65eVTjITD8\\/lTGIb8850v1fl3\\/i2R8Dv17jQIRyX5o9MXPSO6jHo4Swma5RzPA\\/0bRj6qRTyPkM1L9qEIr+2H2I7KKhT2ZE5GhAU7yI9A3VLBWzpTrUPMGbfpd1OjVTEqXAdMjpLDYI3Mh5zQ58p8xCzt+W+t0=\"}",
"id": "https://my-place.social/objects/e599373b-1368-4b20-cd24-837166957182",
"instrument": {
"id": "https://my-place.social/friendica",
"name": "Friendica 'Interrupted Fern' 2024.12-1576",
"type": "Application",
"url": "https://my-place.social"
},
"object": "https://pl.kotobank.ch/objects/301bce65-8a1b-4c49-a65c-fe2ce861a213",
"published": "2025-06-12T18:47:41Z",
"to": [
"https://pl.kotobank.ch/users/vaartis",
"https://mitra.social/users/silverpill",
"https://www.w3.org/ns/activitystreams#Public"
],
"type": "Dislike"
},
"published": "2025-06-12T18:41:25Z",
"signature": {
"created": "2025-06-12T18:44:16Z",
"creator": "https://my-place.social/profile/vaartis#main-key",
"nonce": "2d67847d4bd4b1b83a30d61eac6cdc7ad6b980df06a8b9b97217e1d8f7b6cf20",
"signatureValue": "LnoRMZuQGDvTICkShGBq28ynaj2lF1bViJFGS6n4gKn3IbxPWATHxao43gxWRc+HCTrHNg7quzgaW4+PYM7UVUz3jO+bjNKsN845nijOVdyFrPOXbuaij3KQh2OoHhFJWoV/ZQQTFF0kRK1qT4BwG+P8NqOOKAMv+Cw7ruQH+f2w7uDgcNIbCD1gLcwb6cw7WVe5qu8yMkKqp2kBdqW3RCsI85RmmFgwehDgH5nrX7ER1qbeLWrqy7echwD9/fO3rqAu13xDNyiGZHDT7JB3RUt0AyMm0XCfjbwSQ0n+MkYXgE4asvFz81+iiPCLt+6gePWAFc5odF1FxdySBpSuUOs4p92NzP9OhQ0c0qrqrzYI7aYklY7oMfxjkva+X+0bm3up+2IRJdnZa/pXlmwdcqTpyMr1sgzaexMUNBp3dq7zA51eEaakLDX3i2onXJowfmze3+6XgPAFHYamR+pRNtuEoY4uyYEK3fj5GgwJ4RtFJMYVoEs/Q8h3OgYRcK1FE9UlDjSqbQ7QIRn2Ib4wjgmkeM0vrHIwh/1CtqA/M/6WuYFzCaJBc8O9ykpK9ZMbw64ToQXKf2SqhZsDoyTWRWTO1PXOk1XCAAElUh8/WCyeghvgqLXn0LHov4lmBsHA5iMUcLqBKD3GJIHd+ExrOFxMZs4mBLLGyz0p5joJ3NY=",
"type": "RsaSignature2017"
},
"to": [
"https://pl.kotobank.ch/users/vaartis",
"https://mitra.social/users/silverpill",
"https://www.w3.org/ns/activitystreams#Public"
],
"type": "Undo"
}

56
test/fixtures/friendica-dislike.json vendored Normal file
View file

@ -0,0 +1,56 @@
{
"@context": [
"https://www.w3.org/ns/activitystreams",
"https://w3id.org/security/v1",
{
"Hashtag": "as:Hashtag",
"PropertyValue": "schema:PropertyValue",
"conversation": "ostatus:conversation",
"dfrn": "http://purl.org/macgirvin/dfrn/1.0/",
"diaspora": "https://diasporafoundation.org/ns/",
"directMessage": "litepub:directMessage",
"discoverable": "toot:discoverable",
"featured": {
"@id": "toot:featured",
"@type": "@id"
},
"litepub": "http://litepub.social/ns#",
"manuallyApprovesFollowers": "as:manuallyApprovesFollowers",
"ostatus": "http://ostatus.org#",
"quoteUrl": "as:quoteUrl",
"schema": "http://schema.org#",
"sensitive": "as:sensitive",
"toot": "http://joinmastodon.org/ns#",
"value": "schema:value",
"vcard": "http://www.w3.org/2006/vcard/ns#"
}
],
"actor": "https://my-place.social/profile/vaartis",
"cc": [
"https://my-place.social/followers/vaartis"
],
"diaspora:guid": "e599373b-1968-4b20-cd24-80d340160302",
"diaspora:like": "{\"author\":\"vaartis@my-place.social\",\"guid\":\"e599373b-1968-4b20-cd24-80d340160302\",\"parent_guid\":\"cd36feba-c31f3ed3fd5c064a-17c31593\",\"parent_type\":\"Post\",\"positive\":\"false\",\"author_signature\":\"xR2zLJNfc9Nhx1n8LLMWM1kde12my4cqamIsrH\\/UntKzuDwO4DuHBL0fkFhgC\\/dylxm4HqsHD45MQbtwQCVGq6WhC96TrbMuYEK61HIO23dTr3m+qJVtfdH4wyhUNHgiiYPhZpkLDfnR1JiRWmFTlmZC8q8JEkOB5IQsrWia2eOR6IsqDcdKO\\/Urgns9\\/BdQi8KnchBKSEFc1iUtcOEruvhunKGyW5zI\\/Rltfdz3xGH8tlw+YlMXeWXPnqgOJ9GzNA0lwG4U421L6yylYagW7oxIznnBLB4bO46vYZbgXZV1hiI9ZyveHOinLMY1QkmTj5CNvnx3\\/VJwLovd0v+0Nr2vu\\/3ftbpBXc6L1bsNjlRqtsfwJlcgl+tH1DC4W8tKf+Y3tdtzVw0CHXCuacxHLyq5wZd\\/5YfYR9SJQ\\/jInU4PHA5+hIE3PGqNUp5QfFE0umq56H7MQKsIPgM5mMV4fPAA8OpltuMVDvQYUxalrnvoTf00k90x1wCTK71\\/jQGh7r7PmGvSdfPr+65eVTjITD8\\/lTGIb8850v1fl3\\/i2R8Dv17jQIRyX5o9MXPSO6jHo4Swma5RzPA\\/0bRj6qRTyPkM1L9qEIr+2H2I7KKhT2ZE5GhAU7yI9A3VLBWzpTrUPMGbfpd1OjVTEqXAdMjpLDYI3Mh5zQ58p8xCzt+W+t0=\"}",
"id": "https://my-place.social/objects/e599373b-1368-4b20-cd24-837166957182",
"instrument": {
"id": "https://my-place.social/friendica",
"name": "Friendica 'Interrupted Fern' 2024.12-1576",
"type": "Application",
"url": "https://my-place.social"
},
"object": "https://pl.kotobank.ch/objects/301bce65-8a1b-4c49-a65c-fe2ce861a213",
"published": "2025-06-12T18:47:41Z",
"signature": {
"created": "2025-06-12T18:47:42Z",
"creator": "https://my-place.social/profile/vaartis#main-key",
"nonce": "84e496f80b09d7a299c5cc89e8cadd13abf621b3a0a321684fa74278b68a6dd8",
"signatureValue": "qe2WxY+j7daIYLRadCctgal6A1s9XgoiMfM/8KjJm15w0sSizYYqruyQ5gS44e+cj5GHc9v5gP2ieod5v7eHAPzlcDI4bfkcyHVapAXTqU67ZebW+v6Q+21IMDgqrkYCv5TbV7LTxltW59dlqovpHE4TEe/M7xLKWJ3vVchRUcWqH9kDmak0nacoqYVAb5E9jYnQhUWPTCfPV82qQpeWQPOZ4iIvPw6rDkSSY5jL6bCogBZblHGpUjXfe/FPlacaCWiTQdoga3yOBXB1RYPw9nh5FI5Xkv/oi+52WmJrECinlD6AL8/BpiYvKz236zy7p/TR4BXlCx9RR/msjOnSabkQ4kmYFrRr80UDCGF+CdkdzLl8K9rSE3PbF1+nEqD7X0GOWn/DdtixqXJw6IR4bh32YW2SlcrSRBvI1p82Mv68BeqRaYqL6FAhKFwLhX5JpXngZ3k0g7rWWxc498voPWnFZDyCTRNxO9VIIUavDDEQ0BdFk6WDb8zx9tsAg8JoK57eVDcFly7tfVQffYiHpve06d8ag1DtzipqguRsURmuqpGNMq28XBTxwtrP2LnXXHKxoYN/YQ9cDnCKclbx7/uKmOVMLkLZlM0wAVoZpm5z2fG4voKqFiGZ1PoiFY2sN4URMArJtygV3PlTX4ASAQrak0ksvEo9msrBUD0Su9c=",
"type": "RsaSignature2017"
},
"to": [
"https://pl.kotobank.ch/users/vaartis",
"https://mitra.social/users/silverpill",
"https://www.w3.org/ns/activitystreams#Public"
],
"type": "Dislike"
}

151
test/fixtures/fulmo.html vendored Normal file
View file

@ -0,0 +1,151 @@
<!DOCTYPE html>
<html lang='eo'>
<head>
<meta charset='utf-8'/>
<meta name='author' content='Tirifto'/>
<meta name='generator' content='Pageling'/>
<meta name='viewport' content='width=device-width,
height=device-height,
initial-scale=1.0'/>
<link rel='stylesheet' type='text/css' href='/r/stiloj/tiriftejo.css'/>
<link rel='alternate' type='application/atom+xml' href='/eo/novajhoj.atom'/>
<link rel='icon' size='16x16' type='image/vnd.microsoft.icon' href='/favicon.ico'/>
<link rel='icon' size='128x128' type='image/png' href='/icon.png'/>
<link rel='alternate' hreflang='eo' href='https://tirifto.xwx.moe/eo/rakontoj/fulmo.html'/>
<title>Fulmo</title>
<meta property='og:title' content='Fulmo'/>
<meta property='og:type' content='website'/>
<meta property='og:url' content='https://tirifto.xwx.moe/eo/rakontoj/fulmo.html'/>
<meta property='og:site_name' content='Tiriftejo'/>
<meta property='og:locale' content='eo'/>
<meta property='og:description' content='Pri feoj, kiuj devis ordigi falintan arbon.'/>
<meta property='og:image' content='https://tirifto.xwx.moe/r/ilustrajhoj/pinglordigado.png'/>
<meta property='og:image:alt' content='Meze de arbaro kuŝas falinta trunko, sen pingloj kaj kun branĉoj derompitaj. Post ĝi videblas du feoj: florofeo maldekstre kaj nubofeo dekstre. La florofeo iom kaŝas sin post la trunko. La nubofeo staras kaj tenas amason da pigloj. Ili iom rigardas al si.'/>
<meta property='og:image:height' content='630'/>
<meta property='og:image:width' content='1200'/>
<meta property='og:image' content='https://tirifto.xwx.moe/r/opengraph/eo.png'/>
<meta property='og:image:alt' content='La tirifta okulo ĉirkaŭita de ornamaj steloj kaj la teksto: »Tiriftejo. Esperanto.«'/>
<meta property='og:image:height' content='630'/>
<meta property='og:image:width' content='1200'/>
</head>
<body>
<header id='website-header'>
<nav id='website-navigation'>
<input type='checkbox' id='website-navigation-toggle'
aria-description='Montri ligilojn al ĉefaj paĝoj de la retejo.'/>
<label for='website-navigation-toggle'>Paĝoj</label>
<a href='/eo/verkoj.html'>Verkoj</a>
<a href='/eo/novajhoj.html'>Novaĵoj</a>
<a href='/eo/donacoj.html'>Donacoj</a>
<a href='/eo/prio.html'>Prio</a>
<a href='/eo/amikoj.html'>Amikoj</a>
<a href='/eo/kontakto.html'>Kontakto</a>
</nav>
<span id='eye' role='img' aria-label=''></span>
<nav id='language-switcher'
aria-roledescription='lingvo-ŝanĝilo'>
<input type='checkbox' id='language-switcher-toggle'
aria-description='Montri ligilojn al tradukoj de tiu ĉi paĝo.'/>
<label for='language-switcher-toggle'>Lingvoj</label>
<a href='fulmo.html' lang='eo' hreflang='eo'><img aria-hidden='true' alt='' src='/r/flagoj/eo.png'/>Esperanto</a>
</nav>
</header>
<div class='bodier'>
<nav id='work-links'>
<a href='.'>Ceteraj rakontoj</a>
<a href='../bildosignoj'>Bildosignoj</a>
<a href='../eseoj'>Eseoj</a>
<a href='../ludoj'>Ludoj</a>
<a href='../poemoj'>Poemoj</a>
<a href='../vortaroj'>Vortaroj</a>
</nav>
<main>
<article>
<header>
<h1>Fulmo</h1>
<p>Skribis Tirifto</p>
<time datetime='2025-01-31'>2025-01-31</time>
</header>
<p>»Kial ĉiam mi? Tio ne justas! Oni kulpigas min, sed ja ne mi kulpas!« La nubofeo lamentis, dum ĝi ordigis restaĵojn de falinta arbo. Plejparto el la pingloj estis brulintaj, kaj el la trunko ankoraŭ leviĝis fumo.</p>
<p>Subite aŭdeblis ekstraj kraketoj deapude. Ĝi rigardis flanken, kaj vidis iun kaŭri apud la arbo, derompi branĉetojn, kaj orde ilin amasigi. Ŝajnis, ke ekde sia rimarkiĝo, la nekonatulo laŭeble kuntiriĝis, kaj strebis labori kiel eble plej silente.</p>
<p>»Saluton…?« La nubofeo stariĝis, alporolante la eston. Tiu kvazaŭ frostiĝis, sed timeme ankaŭ stariĝis.</p>
<p>»S- Saluton…« Ĝi respondis sen kuraĝo rigardi ĝiadirekten. Nun stare, videblis ke ĝi estas verdanta florofeo.</p>
<p>»… kion vi faras tie ĉi?« La nubofeo demandis.</p>
<p>»Nu… tiel kaj tiel… mi ordigas.«</p>
<p>»Ho. Mi ricevis taskon ordigi ĉi tie… se vi povas atendi, vi ne bezonas peni!«</p>
<p>»N- Nu… mi tamen volus…« Parolis la florofeo, plu deturnante la kapon.</p>
<p>»Nu… bone, se vi tion deziras… dankon!« La nubofeo dankis, kaj returniĝis al sia laboro.</p>
<p>Fojfoje ĝi scivole rigardis al sia nova kunlaboranto, kaj fojfoje renkontis similan rigardon de ĝia flanko, kiuokaze ambaŭ rigardoj rapide revenis al la ordigataj pingloj kaj branĉetoj. »(Kial tiom volonte helpi min?)« Pensis al si la nubofeo. »(Ĉu ĝi simple tiom bonkoras? Ĝi ja tre bele floras; eble ankaŭ ĝia koro tiel same belas…)« Kaj vere, ĝiaj surfloroj grandanime malfermis siajn belkolorajn folietojn, kaj bonodoris al mondo.</p>
<figure>
<picture>
<source srcset='/r/ilustrajhoj/pinglordigado.jxl' type='image/jxl'/>
<img src='/r/ilustrajhoj/pinglordigado.png' alt='Meze de arbaro kuŝas falinta trunko, sen pingloj kaj kun branĉoj derompitaj. Post ĝi videblas du feoj: florofeo maldekstre kaj nubofeo dekstre. La florofeo iom kaŝas sin post la trunko. La nubofeo staras kaj tenas amason da pigloj. Ili iom rigardas al si.'/>
</picture>
<figcaption>
Pinglordigado
<details>
<summary>© <time datetime='2025'>2025</time> Tirifto</summary>
<a href='https://artlibre.org/'><img src='/r/permesiloj/lal.svg' class='stamp licence' alt='Emblemo: Permesilo de arto libera'/></a>
</details>
</figcaption>
</figure>
<p>Post iom da tempo, ĉiu feo tralaboris ĝis la trunkomezo, kaj proksimiĝis al la alia feo. Kaj tiam ekpezis sur ili devosento rompi la silenton.</p>
<p>»… kia bela vetero, ĉu ne?« Diris la nubofeo, tuj rimarkonte, ke mallumiĝas, kaj la ĉielo restas kovrita de nuboj.</p>
<p>»Jes ja! Tre nube. Mi ŝatas nubojn!« Respondis la alia entuziasme, sed tuj haltetis kaj deturnis la kapon. Ambaŭ feoj daŭrigis laboron silente, kaj plu proksimiĝis, ĝis tiu preskaŭ estis finita.</p>
<p>»H… H… Ho ne…!« Subite ekdiris la nubofeo urĝe.</p>
<p>»Kio okazas?!«</p>
<p>»T… Tern…!«</p>
<p>»Jen! Tenu!« La florofeo etendis manon kun granda folio. La nubofeo ĝin prenis, kaj tien ternis. Aperis ekfulmo, kaj la cindriĝinta folio disfalis.</p>
<p>»Pardonu… mi ne volis…« Bedaŭris la nubofeo. »Mi ne scias, kial tio ĉiam okazas! Tiom plaĉas al mi promeni tere, sed ĉiuj diras, ke mi maldevus, ĉar ial ĝi ĉiam finiĝas tiel ĉi.« Ĝi montris al la arbo. »Eble ili pravas…«</p>
<p>»Nu…« diris la florofeo bedaŭre, kaj etendis la manon.</p>
<p>»H… H… Ne ree…!«</p>
<p>Ekfulmis. Alia ĵus metita folio cindriĝis en la manoj de la florofeo, time ferminta la okulojn.</p>
<p>»Dankegon… mi tre ŝatas vian helpon! Kaj mi ne… ne…«</p>
<p>Metiĝis. Ekfulmis. Cindriĝis.</p>
<p>»Io tre iritas mian nazon!« Plendis la nubofeo. Poste ĝi rimarkis la florpolvon, kiu disŝutiĝis el la florofeo en la tutan ĉirkaŭaĵon, kaj eĉ tuj antaŭ la nubofeon.</p>
<p>»N- Nu…« Diris la florofeo, honte rigardanta la teron. »… pardonu.«</p>
<footer>
<noscript><p>Ĉu vi ŝatas la verkon? <a href='/eo/donacoj.html'>Subtenu min</a> aŭ kopiu adreson de la verko por diskonigi ĝin!</p></noscript>
<script id='underbuttons'>
/* @license magnet:?xt=urn:btih:90dc5c0be029de84e523b9b3922520e79e0e6f08&dn=cc0.txt CC0-1.0 */
document.getElementById('underbuttons').outerHTML = "<p><a href='/eo/donacoj.html' class='button' target='_blank'>Subtenu min</a> <button onclick='navigator.clipboard.writeText(window.location.href.split(\"\#\")[0]).then(() => window.alert(\"Ligilo al ĉi tiu verko estas kopiita. Sendu ĝin al iu por diskonigi la verkon! 🐱\"))'>Diskonigu la verkon</button></p>"
/* @license-end */
</script>
<details class='history'>
<summary>Historio</summary>
<dl>
<dt><time datetime='2025-01-31'>2025-01-31</time></dt>
<dd>Unua publikigo.</dd>
</dl>
</details>
<details class='licence' open='details'>
<summary>Permesilo</summary>
<p>Ĉi tiun verkon vi rajtas libere kopii, disdoni, kaj ŝanĝi, laŭ kondiĉoj de la <a href='https://artlibre.org/'>Permesilo de arto libera</a>. (Resume: Vi devas mencii la aŭtoron kaj doni ligilon al la verko. Se vi ŝanĝas la verkon, vi devas laŭeble noti la faritajn ŝanĝojn, ilian daton, kaj eldoni ilin sub la sama permesilo.)</p>
<a href='https://artlibre.org/'><img src='/r/permesiloj/lal.svg' class='stamp licence' alt='Emblemo: Permesilo de arto libera'/></a>
</details>
</footer>
</article>
</main>
</div>
<footer id='website-footer'>
<div class='stamps'>
<a href='https://gnu.org/'>
<img class='stamp' src='/r/retetikedoj/gnu.png' lang='en' alt='GNU'/></a>
<img class='stamp' src='/r/retetikedoj/ihhtus.png' lang='el' alt='ΙΧΘΥΣ'/>
<img class='stamp' src='/r/retetikedoj/be-kind.apng' lang='en' alt='Be kind.'/>
<img class='stamp' src='/r/retetikedoj/kulturo-libera.png' lang='eo' alt='Kulturo libera.'/>
<img class='stamp' src='/r/retetikedoj/discord.png' lang='en' alt='Say ‘no’ to Discord.'/>
<a href='https://xwx.moe/'>
<img class='stamp' src='/r/retetikedoj/xwx-moe.png' alt='xwx.moe'/></a>
<a href='https://mojeek.co.uk' hreflang='en'>
<img class='stamp' src='/r/retetikedoj/mojeek.png' lang='en' alt='Mojeek'/></a>
<a href='https://raku.org/' hreflang='en'>
<img class='stamp' src='/r/retetikedoj/raku.png' alt='Raku'/></a>
<picture>
<source srcset='/r/retetikedoj/jxl.jxl' type='image/jxl'/>
<img class='stamp' src='/r/retetikedoj/jxl.png' alt='JPEG XL'/></picture>
</div>
</footer>
</body>
</html>

View file

@ -0,0 +1,90 @@
{
"@context": [
"https://www.w3.org/ns/activitystreams",
{
"atomUri": "ostatus:atomUri",
"conversation": "ostatus:conversation",
"inReplyToAtomUri": "ostatus:inReplyToAtomUri",
"ostatus": "http://ostatus.org#",
"sensitive": "as:sensitive",
"toot": "http://joinmastodon.org/ns#",
"votersCount": "toot:votersCount"
},
"https://w3id.org/security/v1"
],
"actor": "https://pol.social/users/mkljczk",
"cc": ["https://www.w3.org/ns/activitystreams#Public",
"https://pol.social/users/aemstuz", "https://gts.mkljczk.pl/users/mkljczk",
"https://pl.fediverse.pl/users/mkljczk",
"https://fedi.kutno.pl/users/mkljczk"],
"id": "https://pol.social/users/mkljczk/statuses/113907871635572263#updates/1738096776",
"object": {
"atomUri": "https://pol.social/users/mkljczk/statuses/113907871635572263",
"attachment": [],
"attributedTo": "https://pol.social/users/mkljczk",
"cc": ["https://www.w3.org/ns/activitystreams#Public",
"https://pol.social/users/aemstuz", "https://gts.mkljczk.pl/users/mkljczk",
"https://pl.fediverse.pl/users/mkljczk",
"https://fedi.kutno.pl/users/mkljczk"],
"content": "<p>test</p>",
"contentMap": {
"pl": "<p>test</p>"
},
"conversation": "https://fedi.kutno.pl/contexts/43c14c70-d3fb-42b4-a36d-4eacfab9695a",
"id": "https://pol.social/users/mkljczk/statuses/113907871635572263",
"inReplyTo": "https://pol.social/users/aemstuz/statuses/113907854282654767",
"inReplyToAtomUri": "https://pol.social/users/aemstuz/statuses/113907854282654767",
"likes": {
"id": "https://pol.social/users/mkljczk/statuses/113907871635572263/likes",
"totalItems": 1,
"type": "Collection"
},
"published": "2025-01-28T20:29:45Z",
"replies": {
"first": {
"items": [],
"next": "https://pol.social/users/mkljczk/statuses/113907871635572263/replies?only_other_accounts=true&page=true",
"partOf": "https://pol.social/users/mkljczk/statuses/113907871635572263/replies",
"type": "CollectionPage"
},
"id": "https://pol.social/users/mkljczk/statuses/113907871635572263/replies",
"type": "Collection"
},
"sensitive": false,
"shares": {
"id": "https://pol.social/users/mkljczk/statuses/113907871635572263/shares",
"totalItems": 0,
"type": "Collection"
},
"summary": null,
"tag": [
{
"href": "https://pol.social/users/aemstuz",
"name": "@aemstuz",
"type": "Mention"
},
{
"href": "https://gts.mkljczk.pl/users/mkljczk",
"name": "@mkljczk@gts.mkljczk.pl",
"type": "Mention"
},
{
"href": "https://pl.fediverse.pl/users/mkljczk",
"name": "@mkljczk@fediverse.pl",
"type": "Mention"
},
{
"href": "https://fedi.kutno.pl/users/mkljczk",
"name": "@mkljczk@fedi.kutno.pl",
"type": "Mention"
}
],
"to": ["https://pol.social/users/mkljczk/followers"],
"type": "Note",
"updated": "2025-01-28T20:39:36Z",
"url": "https://pol.social/@mkljczk/113907871635572263"
},
"published": "2025-01-28T20:39:36Z",
"to": ["https://pol.social/users/mkljczk/followers"],
"type": "Update"
}

View file

@ -0,0 +1,54 @@
{
"@context": [
"https://www.w3.org/ns/activitystreams",
"https://w3id.org/security/v1",
{
"Emoji": "toot:Emoji",
"Hashtag": "as:Hashtag",
"PropertyValue": "schema:PropertyValue",
"_misskey_content": "misskey:_misskey_content",
"_misskey_quote": "misskey:_misskey_quote",
"_misskey_reaction": "misskey:_misskey_reaction",
"_misskey_summary": "misskey:_misskey_summary",
"_misskey_votes": "misskey:_misskey_votes",
"backgroundUrl": "sharkey:backgroundUrl",
"discoverable": "toot:discoverable",
"featured": "toot:featured",
"fedibird": "http://fedibird.com/ns#",
"firefish": "https://joinfirefish.org/ns#",
"isCat": "misskey:isCat",
"listenbrainz": "sharkey:listenbrainz",
"manuallyApprovesFollowers": "as:manuallyApprovesFollowers",
"misskey": "https://misskey-hub.net/ns#",
"quoteUri": "fedibird:quoteUri",
"quoteUrl": "as:quoteUrl",
"schema": "http://schema.org#",
"sensitive": "as:sensitive",
"sharkey": "https://joinsharkey.org/ns#",
"speakAsCat": "firefish:speakAsCat",
"toot": "http://joinmastodon.org/ns#",
"value": "schema:value",
"vcard": "http://www.w3.org/2006/vcard/ns#"
}
],
"_misskey_reaction": ":blobwtfnotlikethis:",
"actor": "https://mai.waifuism.life/users/9otxaeemjqy70001",
"content": ":blobwtfnotlikethis:",
"id": "https://mai.waifuism.life/likes/9q2xifhrdnb0001b",
"object": "https://bungle.online/notes/9q2xi2sy4k",
"tag": [
{
"icon": {
"mediaType": "image/png",
"type": "Image",
"url": "https://mai.waifuism.life/files/1b0510f2-1fb4-43f5-a399-10053bbd8f0f"
},
"id": "https://mai.waifuism.life/emojis/blobwtfnotlikethis",
"name": ":blobwtfnotlikethis:",
"type": "Emoji",
"updated": "2024-02-07T02:21:46.497Z"
}
],
"type": "Like"
}

View file

@ -0,0 +1,46 @@
{
"@context": [
"https://www.w3.org/ns/activitystreams",
"https://w3id.org/security/v1",
"https://w3id.org/security/data-integrity/v1",
{
"Emoji": "toot:Emoji",
"Hashtag": "as:Hashtag",
"sensitive": "as:sensitive",
"toot": "http://joinmastodon.org/ns#"
}
],
"actor": "https://mitra.social/users/silverpill",
"cc": [],
"content": ":ablobcatheartsqueeze:",
"id": "https://mitra.social/activities/like/0195a89a-a3a0-ead4-3a1c-aa6311397cfd",
"object": "https://framapiaf.org/users/peertube/statuses/114182703352270287",
"proof": {
"created": "2025-03-18T09:34:21.610678375Z",
"cryptosuite": "eddsa-jcs-2022",
"proofPurpose": "assertionMethod",
"proofValue": "z5AvpwkXQGFpTneRVDNeF48Jo9qYG6PgrE5HaPPpQNdNyc31ULMN4Vxd4aFXELo4Rk5Y9hd9nDy254xP8v5uGGWp1",
"type": "DataIntegrityProof",
"verificationMethod": "https://mitra.social/users/silverpill#ed25519-key"
},
"tag": [
{
"attributedTo": "https://mitra.social/actor",
"icon": {
"mediaType": "image/png",
"type": "Image",
"url": "https://mitra.social/media/a08e153441b25e512ab1b2e8922f5d8cd928322c8b79958cd48297ac722a4117.png"
},
"id": "https://mitra.social/objects/emojis/ablobcatheartsqueeze",
"name": ":ablobcatheartsqueeze:",
"type": "Emoji",
"updated": "1970-01-01T00:00:00Z"
}
],
"to": [
"https://framapiaf.org/users/peertube",
"https://www.w3.org/ns/activitystreams#Public"
],
"type": "Like"
}

View file

@ -0,0 +1,93 @@
{
"@context": [
"https://www.w3.org/ns/activitystreams",
{
"ostatus": "http://ostatus.org#",
"atomUri": "ostatus:atomUri",
"inReplyToAtomUri": "ostatus:inReplyToAtomUri",
"conversation": "ostatus:conversation",
"sensitive": "as:sensitive",
"toot": "http://joinmastodon.org/ns#",
"votersCount": "toot:votersCount",
"quote": "https://w3id.org/fep/044f#quote",
"quoteUri": "http://fedibird.com/ns#quoteUri",
"_misskey_quote": "https://misskey-hub.net/ns#_misskey_quote",
"quoteAuthorization": {
"@id": "https://w3id.org/fep/044f#quoteAuthorization",
"@type": "@id"
},
"gts": "https://gotosocial.org/ns#",
"interactionPolicy": {
"@id": "gts:interactionPolicy",
"@type": "@id"
},
"canQuote": {
"@id": "gts:canQuote",
"@type": "@id"
},
"automaticApproval": {
"@id": "gts:automaticApproval",
"@type": "@id"
},
"manualApproval": {
"@id": "gts:manualApproval",
"@type": "@id"
}
}
],
"id": "https://mastodon.social/users/gwynnion/statuses/115345489087257171",
"type": "Note",
"summary": null,
"inReplyTo": null,
"published": "2025-10-09T17:54:47Z",
"url": "https://mastodon.social/@gwynnion/115345489087257171",
"attributedTo": "https://mastodon.social/users/gwynnion",
"to": [
"https://www.w3.org/ns/activitystreams#Public"
],
"cc": [
"https://mastodon.social/users/gwynnion/followers"
],
"sensitive": false,
"atomUri": "https://mastodon.social/users/gwynnion/statuses/115345489087257171",
"inReplyToAtomUri": null,
"conversation": "https://mastodon.social/contexts/109836797527169643-115345489087257171",
"context": "https://mastodon.social/contexts/109836797527169643-115345489087257171",
"content": "<p class=\"quote-inline\">RE: <a href=\"https://mastodon.social/@404mediaco/115344945575874225\" target=\"_blank\" rel=\"nofollow noopener\" translate=\"no\"><span class=\"invisible\">https://</span><span class=\"ellipsis\">mastodon.social/@404mediaco/11</span><span class=\"invisible\">5344945575874225</span></a></p><p>Every age verification system is just a scheme for companies and hackers to steal your identity.</p>",
"contentMap": {
"en": "<p class=\"quote-inline\">RE: <a href=\"https://mastodon.social/@404mediaco/115344945575874225\" target=\"_blank\" rel=\"nofollow noopener\" translate=\"no\"><span class=\"invisible\">https://</span><span class=\"ellipsis\">mastodon.social/@404mediaco/11</span><span class=\"invisible\">5344945575874225</span></a></p><p>Every age verification system is just a scheme for companies and hackers to steal your identity.</p>"
},
"quote": "https://mastodon.social/users/404mediaco/statuses/115344945575874225",
"_misskey_quote": "https://mastodon.social/users/404mediaco/statuses/115344945575874225",
"quoteUri": "https://mastodon.social/users/404mediaco/statuses/115344945575874225",
"quoteAuthorization": "https://mastodon.social/users/404mediaco/quote_authorizations/115345489087269783",
"interactionPolicy": {
"canQuote": {
"automaticApproval": [
"https://www.w3.org/ns/activitystreams#Public"
]
}
},
"attachment": [],
"tag": [],
"replies": {
"id": "https://mastodon.social/users/gwynnion/statuses/115345489087257171/replies",
"type": "Collection",
"first": {
"type": "CollectionPage",
"next": "https://mastodon.social/users/gwynnion/statuses/115345489087257171/replies?only_other_accounts=true&page=true",
"partOf": "https://mastodon.social/users/gwynnion/statuses/115345489087257171/replies",
"items": []
}
},
"likes": {
"id": "https://mastodon.social/users/gwynnion/statuses/115345489087257171/likes",
"type": "Collection",
"totalItems": 26
},
"shares": {
"id": "https://mastodon.social/users/gwynnion/statuses/115345489087257171/shares",
"type": "Collection",
"totalItems": 28
}
}

File diff suppressed because one or more lines are too long

18
test/fixtures/server.pem vendored Normal file
View file

@ -0,0 +1,18 @@
-----BEGIN CERTIFICATE-----
MIICpDCCAYwCCQC0vCQAnSoGdzANBgkqhkiG9w0BAQsFADAUMRIwEAYDVQQDDAls
b2NhbGhvc3QwHhcNMjYwMTE2MTY1ODE5WhcNMzYwMTE0MTY1ODE5WjAUMRIwEAYD
VQQDDAlsb2NhbGhvc3QwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQCq
dZ4O2upZqwIo1eK5KrW1IIsjkfsFK8hE7Llh+4axcesiUKot0ib1CUhRSYiL1DLO
CIYQOw8IKQDVSC4JWAX9SsnX4W8dwexMQuSQG7/IKX2auC1bNNySFvoqM6Gq3GL9
MqBFonZGXDPZu8fmxsI/2p9+2GK13F+HXgoLlXSCoO3XELJaBmjv29tgxxWRxCiH
m4u0briSxgUEx+CctpKPvGDmLaoIOIhjtuoG6OjkeWUOp6jDcteazO23VxPyF5cS
NbRJgm8AckrTQ6wbWSnhyqF8rPEsIc0ZAlUdDEs5fL3sjugc566FvE+GOkZIEyDD
tgWbc4Ne+Kp/nnt6oVxpAgMBAAEwDQYJKoZIhvcNAQELBQADggEBADv+J1DTok8V
MKVKo0hsRnHTeJQ2+EIgOspuYlEzez3PysOZH6diAQxO2lzuo9LKxP3hnmw17XO/
P2oCzYyb9/P58VY/gr4UDIfuhgcE0cVfdsRhVId/I2FW6VP2f5q1TGbDUxSsVIlG
6hufn1aLBu90LtEbDkHqbnD05yYPwdqzWg4TrOXbX+jBhQrXJJdB3W7KTgozjRQw
F7+/2IyXoxXuxcwQBQlYhUbvGlsFqFpP/6cz2al5i5pNUkiNaSYwlRmuwa7zoTft
tHf57dhfXIpXET2BaJM6DSjDOOG/QleRXkvkTI5J21q+Bo+XnOzo19p4cZKJpTFC
SNgrftyNh3k=
-----END CERTIFICATE-----

View file

@ -0,0 +1 @@
[{"language":"BG","name":"Bulgarian","supports_formality":false},{"language":"CS","name":"Czech","supports_formality":false},{"language":"DA","name":"Danish","supports_formality":false},{"language":"DE","name":"German","supports_formality":true},{"language":"EL","name":"Greek","supports_formality":false},{"language":"EN-GB","name":"English (British)","supports_formality":false},{"language":"EN-US","name":"English (American)","supports_formality":false},{"language":"ES","name":"Spanish","supports_formality":true},{"language":"ET","name":"Estonian","supports_formality":false},{"language":"FI","name":"Finnish","supports_formality":false},{"language":"FR","name":"French","supports_formality":true},{"language":"HU","name":"Hungarian","supports_formality":false},{"language":"ID","name":"Indonesian","supports_formality":false},{"language":"IT","name":"Italian","supports_formality":true},{"language":"JA","name":"Japanese","supports_formality":false},{"language":"LT","name":"Lithuanian","supports_formality":false},{"language":"LV","name":"Latvian","supports_formality":false},{"language":"NL","name":"Dutch","supports_formality":true},{"language":"PL","name":"Polish","supports_formality":true},{"language":"PT-BR","name":"Portuguese (Brazilian)","supports_formality":true},{"language":"PT-PT","name":"Portuguese (European)","supports_formality":true},{"language":"RO","name":"Romanian","supports_formality":false},{"language":"RU","name":"Russian","supports_formality":true},{"language":"SK","name":"Slovak","supports_formality":false},{"language":"SL","name":"Slovenian","supports_formality":false},{"language":"SV","name":"Swedish","supports_formality":false},{"language":"TR","name":"Turkish","supports_formality":false},{"language":"UK","name":"Ukrainian","supports_formality":false},{"language":"ZH","name":"Chinese (simplified)","supports_formality":false}]

View file

@ -0,0 +1 @@
{"translations":[{"detected_source_language":"PL","text":"REMOVE THE FOLLOWER!Paste this on your follower. If we get 70% of nk users...they will remove the follower!!!"}]}

View file

@ -13,7 +13,7 @@
"directMessage": "litepub:directMessage"
}
],
"id": "http://localhost:8080/followers/fuser3",
"id": "https://remote.org/followers/fuser3",
"type": "OrderedCollection",
"totalItems": 296
}

View file

@ -13,7 +13,7 @@
"directMessage": "litepub:directMessage"
}
],
"id": "http://localhost:8080/following/fuser3",
"id": "https://remote.org/following/fuser3",
"type": "OrderedCollection",
"totalItems": 32
}

View file

@ -0,0 +1,41 @@
{
"alsoKnownAs": [],
"attachment": [],
"capabilities": {},
"discoverable": true,
"endpoints": {},
"featured": "https://queef.in/cute_cat/collections/featured",
"followers": "https://queef.in/cute_cat/followers",
"following": "https://queef.in/cute_cat/following",
"icon": {
"type": "Image",
"url": [
"https://queef.in/storage/profile.webp",
"https://example.com/image"
]
},
"id": "https://queef.in/cute_cat",
"image": {
"type": "Image",
"url": [
"https://queef.in/storage/banner.gif",
"https://example.com/image"
]
},
"inbox": "https://queef.in/cute_cat/inbox",
"manuallyApprovesFollowers": false,
"name": "cute_cat",
"outbox": "https://queef.in/cute_cat/outbox",
"preferredUsername": "cute_cat",
"publicKey": {
"id": "https://queef.in/cute_cat#main-key",
"owner": "https://queef.in/cute_cat"
},
"published": "2025-08-18T01:16:10.000Z",
"summary": "A cute cat",
"tag": [],
"type": "Person",
"url": "https://queef.in/cute_cat",
"vcard:bday": null,
"webfinger": "acct:cute_cat@queef.in"
}

File diff suppressed because one or more lines are too long

View file

@ -1,7 +1,7 @@
{
"@context": "https://www.w3.org/ns/activitystreams",
"id": "http://localhost:4001/users/masto_closed/followers",
"id": "https://remote.org/users/masto_closed/followers",
"type": "OrderedCollection",
"totalItems": 437,
"first": "http://localhost:4001/users/masto_closed/followers?page=1"
"first": "https://remote.org/users/masto_closed/followers?page=1"
}

View file

@ -1 +1 @@
{"@context":"https://www.w3.org/ns/activitystreams","id":"http://localhost:4001/users/masto_closed/followers?page=1","type":"OrderedCollectionPage","totalItems":437,"next":"http://localhost:4001/users/masto_closed/followers?page=2","partOf":"http://localhost:4001/users/masto_closed/followers","orderedItems":["https://testing.uguu.ltd/users/rin","https://patch.cx/users/rin","https://letsalllovela.in/users/xoxo","https://pleroma.site/users/crushv","https://aria.company/users/boris","https://kawen.space/users/crushv","https://freespeech.host/users/cvcvcv","https://pleroma.site/users/picpub","https://pixelfed.social/users/nosleep","https://boopsnoot.gq/users/5c1896d162f7d337f90492a3","https://pikachu.rocks/users/waifu","https://royal.crablettesare.life/users/crablettes"]}
{"@context":"https://www.w3.org/ns/activitystreams","id":"https://remote.org/users/masto_closed/followers?page=1","type":"OrderedCollectionPage","totalItems":437,"next":"https://remote.org/users/masto_closed/followers?page=2","partOf":"https://remote.org/users/masto_closed/followers","orderedItems":["https://testing.uguu.ltd/users/rin","https://patch.cx/users/rin","https://letsalllovela.in/users/xoxo","https://pleroma.site/users/crushv","https://aria.company/users/boris","https://kawen.space/users/crushv","https://freespeech.host/users/cvcvcv","https://pleroma.site/users/picpub","https://pixelfed.social/users/nosleep","https://boopsnoot.gq/users/5c1896d162f7d337f90492a3","https://pikachu.rocks/users/waifu","https://royal.crablettesare.life/users/crablettes"]}

View file

@ -1,7 +1,7 @@
{
"@context": "https://www.w3.org/ns/activitystreams",
"id": "http://localhost:4001/users/masto_closed/following",
"id": "https://remote.org/users/masto_closed/following",
"type": "OrderedCollection",
"totalItems": 152,
"first": "http://localhost:4001/users/masto_closed/following?page=1"
"first": "https://remote.org/users/masto_closed/following?page=1"
}

View file

@ -1 +1 @@
{"@context":"https://www.w3.org/ns/activitystreams","id":"http://localhost:4001/users/masto_closed/following?page=1","type":"OrderedCollectionPage","totalItems":152,"next":"http://localhost:4001/users/masto_closed/following?page=2","partOf":"http://localhost:4001/users/masto_closed/following","orderedItems":["https://testing.uguu.ltd/users/rin","https://patch.cx/users/rin","https://letsalllovela.in/users/xoxo","https://pleroma.site/users/crushv","https://aria.company/users/boris","https://kawen.space/users/crushv","https://freespeech.host/users/cvcvcv","https://pleroma.site/users/picpub","https://pixelfed.social/users/nosleep","https://boopsnoot.gq/users/5c1896d162f7d337f90492a3","https://pikachu.rocks/users/waifu","https://royal.crablettesare.life/users/crablettes"]}
{"@context":"https://www.w3.org/ns/activitystreams","id":"https://remote.org/users/masto_closed/following?page=1","type":"OrderedCollectionPage","totalItems":152,"next":"https://remote.org/users/masto_closed/following?page=2","partOf":"https://remote.org/users/masto_closed/following","orderedItems":["https://testing.uguu.ltd/users/rin","https://patch.cx/users/rin","https://letsalllovela.in/users/xoxo","https://pleroma.site/users/crushv","https://aria.company/users/boris","https://kawen.space/users/crushv","https://freespeech.host/users/cvcvcv","https://pleroma.site/users/picpub","https://pixelfed.social/users/nosleep","https://boopsnoot.gq/users/5c1896d162f7d337f90492a3","https://pikachu.rocks/users/waifu","https://royal.crablettesare.life/users/crablettes"]}

View file

@ -1,18 +1,18 @@
{
"type": "OrderedCollection",
"totalItems": 527,
"id": "http://localhost:4001/users/fuser2/followers",
"id": "https://remote.org/users/fuser2/followers",
"first": {
"type": "OrderedCollectionPage",
"totalItems": 527,
"partOf": "http://localhost:4001/users/fuser2/followers",
"partOf": "https://remote.org/users/fuser2/followers",
"orderedItems": [],
"next": "http://localhost:4001/users/fuser2/followers?page=2",
"id": "http://localhost:4001/users/fuser2/followers?page=1"
"next": "https://remote.org/users/fuser2/followers?page=2",
"id": "https://remote.org/users/fuser2/followers?page=1"
},
"@context": [
"https://www.w3.org/ns/activitystreams",
"http://localhost:4001/schemas/litepub-0.1.jsonld",
"https://remote.org/schemas/litepub-0.1.jsonld",
{
"@language": "und"
}

View file

@ -1,18 +1,18 @@
{
"type": "OrderedCollection",
"totalItems": 267,
"id": "http://localhost:4001/users/fuser2/following",
"id": "https://remote.org/users/fuser2/following",
"first": {
"type": "OrderedCollectionPage",
"totalItems": 267,
"partOf": "http://localhost:4001/users/fuser2/following",
"partOf": "https://remote.org/users/fuser2/following",
"orderedItems": [],
"next": "http://localhost:4001/users/fuser2/following?page=2",
"id": "http://localhost:4001/users/fuser2/following?page=1"
"next": "https://remote.org/users/fuser2/following?page=2",
"id": "https://remote.org/users/fuser2/following?page=1"
},
"@context": [
"https://www.w3.org/ns/activitystreams",
"http://localhost:4001/schemas/litepub-0.1.jsonld",
"https://remote.org/schemas/litepub-0.1.jsonld",
{
"@language": "und"
}

View file

@ -1 +0,0 @@
21.1

View file

@ -1 +0,0 @@
22.1

View file

@ -1 +0,0 @@
22.4

View file

@ -1 +0,0 @@
23.0

View file

@ -3,7 +3,7 @@
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Mix.PleromaTest do
use ExUnit.Case, async: true
use ExUnit.Case, async: false
import Mix.Pleroma
setup_all do

View file

@ -3,7 +3,7 @@
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Mix.Tasks.Pleroma.AppTest do
use Pleroma.DataCase, async: true
use Pleroma.DataCase, async: false
setup_all do
Mix.shell(Mix.Shell.Process)
@ -42,9 +42,10 @@ defmodule Mix.Tasks.Pleroma.AppTest do
test "with errors" do
Mix.Tasks.Pleroma.App.run(["create"])
{:mix_shell, :error, ["Creating failed:"]}
{:mix_shell, :error, ["name: can't be blank"]}
{:mix_shell, :error, ["redirect_uris: can't be blank"]}
assert_receive {:mix_shell, :error, ["Creating failed:"]}
assert_receive {:mix_shell, :error, ["name: can't be blank"]}
assert_receive {:mix_shell, :error, ["redirect_uris: can't be blank"]}
end
defp assert_app(name, redirect, scopes) do

View file

@ -87,6 +87,14 @@ defmodule Mix.Tasks.Pleroma.ConfigTest do
config = ConfigDB.get_by_params(%{group: ":pleroma", key: ":first_setting"})
assert config.value == [key: "value", key2: [Repo]]
end
test "static search settings remain in the config file" do
MixTask.migrate_to_db("test/fixtures/config/static_search.config")
refute ConfigDB.get_by_group_and_key(:pleroma, Pleroma.Search)
refute ConfigDB.get_by_group_and_key(:pleroma, Pleroma.Search.ParadeDB)
refute ConfigDB.get_by_group_and_key(:pleroma, Pleroma.Search.ParadeDB.Repo)
end
end
describe "with deletion of temp file" do
@ -144,7 +152,13 @@ defmodule Mix.Tasks.Pleroma.ConfigTest do
quarantined_instances: [],
managed_config: true,
static_dir: "instance/static/",
allowed_post_formats: ["text/plain", "text/html", "text/markdown", "text/bbcode"],
allowed_post_formats: [
"text/plain",
"text/html",
"text/markdown",
"text/bbcode",
"text/x.misskeymarkdown"
],
autofollowed_nicknames: [],
max_pinned_statuses: 1,
attachment_links: false,
@ -329,5 +343,73 @@ defmodule Mix.Tasks.Pleroma.ConfigTest do
assert config_records() == []
end
test "filters non-whitelisted settings" do
clear_config(:database_config_whitelist, [
{:pleroma},
{:web_push_encryption, :vapid_details}
])
insert_config_record(:web_push_encryption, :non_whitelisted_key, a: 1)
insert_config_record(:web_push_encryption, :vapid_details, b: 1)
MixTask.run(["filter_whitelisted", "--force"])
assert [
%ConfigDB{group: :pleroma, key: :instance},
%ConfigDB{group: :pleroma, key: Pleroma.Captcha},
%ConfigDB{group: :web_push_encryption, key: :vapid_details}
] = config_records()
end
test "filter_whitelisted doesn't crash when whitelist is unset" do
clear_config(:database_config_whitelist, nil)
existing = config_records()
MixTask.run(["filter_whitelisted", "--force"])
assert config_records() == existing
end
test "filter_whitelisted doesn't crash when whitelist is disabled" do
clear_config(:database_config_whitelist, false)
existing = config_records()
MixTask.run(["filter_whitelisted", "--force"])
assert config_records() == existing
end
test "filters blacklisted settings" do
clear_config(:database_config_whitelist, [{:pleroma}])
clear_config(:database_config_blacklist, [{:pleroma, :logger}, {:blacklisted_group}])
insert_config_record(:pleroma, :logger, backends: :console)
insert_config_record(:web_push_encryption, :vapid_details, a: 1)
insert_config_record(:blacklisted_group, :anything, a: 1)
MixTask.run(["filter_blacklisted", "--force"])
assert [
%ConfigDB{group: :pleroma, key: :instance},
%ConfigDB{group: :pleroma, key: Pleroma.Captcha},
%ConfigDB{group: :pleroma2, key: :key2},
%ConfigDB{group: :web_push_encryption, key: :vapid_details}
] = config_records()
end
test "filter_blacklisted doesn't crash when blacklist is unset" do
clear_config(:database_config_blacklist, nil)
existing = config_records()
MixTask.run(["filter_blacklisted", "--force"])
assert config_records() == existing
end
test "filter_blacklisted doesn't crash when blacklist is disabled" do
clear_config(:database_config_blacklist, false)
existing = config_records()
MixTask.run(["filter_blacklisted", "--force"])
assert config_records() == existing
end
end
end

View file

@ -3,11 +3,12 @@
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Mix.Tasks.Pleroma.DatabaseTest do
use Pleroma.DataCase, async: true
use Pleroma.DataCase, async: false
use Oban.Testing, repo: Pleroma.Repo
alias Pleroma.Activity
alias Pleroma.Bookmark
alias Pleroma.Hashtag
alias Pleroma.Object
alias Pleroma.Repo
alias Pleroma.User
@ -411,12 +412,13 @@ defmodule Mix.Tasks.Pleroma.DatabaseTest do
["scheduled_activities"],
["schema_migrations"],
["thread_mutes"],
# ["user_follows_hashtag"], # not in pleroma
["user_follows_hashtag"],
# ["user_frontend_setting_profiles"], # not in pleroma
["user_invite_tokens"],
["user_notes"],
["user_relationships"],
["users"]
["users"],
["webhooks"]
]
end
@ -550,6 +552,39 @@ defmodule Mix.Tasks.Pleroma.DatabaseTest do
assert length(activities) == 3
end
test "it prunes hashtags with no objects associated", %{old_insert_date: old_insert_date} do
user = insert(:user)
{:ok, hashtag_post_activity} =
CommonAPI.post(user, %{status: "morning #cofe", local: true})
hashtag_post_object = Object.normalize(hashtag_post_activity)
{:ok, hashtag_post2_activity} =
CommonAPI.post(user, %{status: "morning #cawfee", local: true})
hashtag_post2_object = Object.normalize(hashtag_post2_activity)
hashtag_post_object
|> Ecto.Changeset.change(%{updated_at: old_insert_date})
|> Repo.update!()
hashtag_post2_object
|> Ecto.Changeset.change(%{updated_at: old_insert_date})
|> Repo.update!()
# Test whether hashtags with follow relationships are kept
User.follow_hashtag(user, Hashtag.get_by_name("cofe"))
assert length(Repo.all(Hashtag)) == 2
assert length(Repo.all(Object)) == 2
Mix.Tasks.Pleroma.Database.run(["prune_objects"])
assert length(Repo.all(Hashtag)) == 1
assert length(Repo.all(Object)) == 0
assert Repo.one(Hashtag) |> Map.fetch!(:name) == "cofe"
end
end
describe "running update_users_following_followers_counts" do

View file

@ -24,7 +24,7 @@ defmodule Mix.Tasks.Pleroma.DigestTest do
setup do: clear_config([Pleroma.Emails.Mailer, :enabled], true)
setup do
Mox.stub_with(Pleroma.UnstubbedConfigMock, Pleroma.Config)
Mox.stub_with(Pleroma.UnstubbedConfigMock, Pleroma.Test.StaticConfig)
:ok
end

View file

@ -11,7 +11,7 @@ defmodule Mix.Tasks.Pleroma.FrontendTest do
@dir "test/frontend_static_test"
setup do
File.mkdir_p!(@dir)
Pleroma.Backports.mkdir_p!(@dir)
clear_config([:instance, :static_dir], @dir)
on_exit(fn ->
@ -50,7 +50,7 @@ defmodule Mix.Tasks.Pleroma.FrontendTest do
folder = Path.join([@dir, "frontends", "pleroma", "fantasy"])
previously_existing = Path.join([folder, "temp"])
File.mkdir_p!(folder)
Pleroma.Backports.mkdir_p!(folder)
File.write!(previously_existing, "yey")
assert File.exists?(previously_existing)

View file

@ -7,7 +7,7 @@ defmodule Mix.Tasks.Pleroma.InstanceTest do
use Pleroma.DataCase
setup do
File.mkdir_p!(tmp_path())
Pleroma.Backports.mkdir_p!(tmp_path())
on_exit(fn ->
File.rm_rf(tmp_path())

View file

@ -62,7 +62,7 @@ defmodule Mix.Tasks.Pleroma.UploadsTest do
upload_dir = Config.get([Pleroma.Uploaders.Local, :uploads])
if not File.exists?(upload_dir) || File.ls!(upload_dir) == [] do
File.mkdir_p(upload_dir)
Pleroma.Backports.mkdir_p(upload_dir)
Path.join([upload_dir, "file.txt"])
|> File.touch()

View file

@ -21,7 +21,7 @@ defmodule Mix.Tasks.Pleroma.UserTest do
import Pleroma.Factory
setup do
Mox.stub_with(Pleroma.UnstubbedConfigMock, Pleroma.Config)
Mox.stub_with(Pleroma.UnstubbedConfigMock, Pleroma.Test.StaticConfig)
:ok
end

View file

@ -261,23 +261,27 @@ defmodule Pleroma.ActivityTest do
test "add_by_params_query/3" do
user = insert(:user)
note = insert(:note_activity, user: user)
note_activity = insert(:note_activity, user: user)
insert(:add_activity, user: user, note: note)
insert(:add_activity, user: user, note: note)
insert(:add_activity, user: user, note_activity: note_activity)
insert(:add_activity, user: user, note_activity: note_activity)
insert(:add_activity, user: user)
assert Repo.aggregate(Activity, :count, :id) == 4
assert Repo.aggregate(Activity, :count, :id) == 5
add_query =
Activity.add_by_params_query(note.data["object"], user.ap_id, user.featured_address)
Activity.add_by_params_query(
note_activity.data["object"],
user.ap_id,
user.featured_address
)
assert Repo.aggregate(add_query, :count, :id) == 2
Repo.delete_all(add_query)
assert Repo.aggregate(add_query, :count, :id) == 0
assert Repo.aggregate(Activity, :count, :id) == 2
assert Repo.aggregate(Activity, :count, :id) == 3
end
describe "associated_object_id() sql function" do

View file

@ -0,0 +1,39 @@
# Pleroma: A lightweight social networking server
# Copyright © 2017-2026 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.ApplicationTest do
use Pleroma.DataCase
import ExUnit.CaptureLog
# TODO HACK: Temporarily disabled.
# Currently causes a bunch of warnings about :warn being deprecated in logs.
# ex_syslogger needs to either be updated upstream or forked.
describe "config :pleroma, :logger, backends: [{:ex_syslogger, :ex_syslogger}]" do
setup do
clear_config([:logger], backends: [{:ex_syslogger, :ex_syslogger}])
end
@tag :skip
test "is handled" do
log = capture_log(fn -> Pleroma.Application.configure_logger() end)
assert log =~
"Configuration {:ex_syslogger, :ex_syslogger} is incorrect. Use {ExSyslogger, :ex_syslogger} instead!"
assert log =~ "Successfully added logger backend: {ExSyslogger, :ex_syslogger}"
end
end
describe "config :pleroma, :logger, :backends: [:console]" do
setup do
clear_config([:logger], backends: [:console])
end
test "emits a warning" do
assert capture_log(fn -> Pleroma.Application.configure_logger() end) =~
":console is no longer considered a backend and is enabled by default"
end
end
end

View file

@ -3,7 +3,7 @@
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Config.DeprecationWarningsTest do
use ExUnit.Case
use ExUnit.Case, async: false
use Pleroma.Tests.Helpers
import ExUnit.CaptureLog
@ -388,4 +388,62 @@ defmodule Pleroma.Config.DeprecationWarningsTest do
end) =~
"Your config is using the old namespace for the Shoutbox configuration."
end
describe "check_deprecated_logger_config" do
setup do
initial_console = Application.get_env(:logger, :console, nil)
initial_backends = Application.get_env(:logger, :backends, nil)
# NOTE: The Logger does not get reconfigured when running in the test env,
# the added and on_exit removed settings below should have no effect besides
# triggering the deprecation warnings
Application.put_env(:logger, :console, level: :all)
Application.put_env(:logger, :backends, [:console])
on_exit(fn ->
if initial_console do
Application.put_env(:logger, :console, initial_console)
else
Application.delete_env(:logger, :console)
end
if initial_backends do
Application.put_env(:logger, :backends, initial_backends)
else
Application.delete_env(:logger, :backends)
end
end)
end
test "warns on deprecated syntax" do
log =
capture_log(fn ->
Pleroma.Config.DeprecationWarnings.check_deprecated_logger_config()
end)
assert log =~
"""
!!!DEPRECATION WARNING!!!
Your configuration is using deprecated syntax for configuring backends of Elixir's logger.
`config :logger, backends: [...]` is deprecated syntax due to changes in Elixir.
Please update your configuration at your earliest convenience to use:
`config :pleroma, :logger, backends: [...]`
Note: `:console` is no longer considered a backend and is used by default, you can disable it using:
`config :logger, :default_handler: false`
"""
assert log =~
"""
!!!DEPRECATION WARNING!!!
Your configuration is using deprecated syntax for configuring logging to console.
`config :logger, :console` is deprecated syntax due to changes in Elixir.
Please update your configuration at your earliest convenience to use
`config :logger, default_handler` and `config :logger, :default_formatter`.
Note: `:default_handler` is used only for the `level` setting. All other configurations go under
`:default_formatter`. For more info visit: https://hexdocs.pm/logger/Logger.html#module-backends-and-backwards-compatibility
"""
end
end
end

View file

@ -71,6 +71,31 @@ defmodule Pleroma.Config.TransferTaskTest do
assert assets_env[:mascots] == [a: 1, b: 2]
end
test "does not transfer static search configuration" do
search_config = Application.get_env(:pleroma, Pleroma.Search)
parade_config = Application.get_env(:pleroma, Pleroma.Search.ParadeDB)
clear_config(:database_config_blacklist, [])
insert(:config, key: Pleroma.Search, value: [module: Pleroma.Search.ParadeDB])
insert(:config,
key: Pleroma.Search.ParadeDB,
value: [url: "postgres://localhost/paradedb"]
)
insert(:config,
key: Pleroma.Search.ParadeDB.Repo,
value: [pool_size: 50]
)
TransferTask.start_link([])
assert Application.get_env(:pleroma, Pleroma.Search) == search_config
assert Application.get_env(:pleroma, Pleroma.Search.ParadeDB) == parade_config
assert Application.get_env(:pleroma, Pleroma.Search.ParadeDB.Repo)[:pool_size] == 2
end
describe "pleroma restart" do
setup do
on_exit(fn ->

View file

@ -36,6 +36,20 @@ defmodule Pleroma.ConfigDBTest do
assert config[:goose][:webhook_url] == "https://gander.com/"
end
test "get_all_as_keyword/1 excludes static search configuration" do
insert(:config, key: Pleroma.Search, value: [module: Pleroma.Search.ParadeDB])
insert(:config, key: Pleroma.Search.ParadeDB, value: [url: "postgres://example/db"])
insert(:config, key: Pleroma.Search.ParadeDB.Repo, value: [pool_size: 50])
insert(:config, key: :instance, value: [name: "Pleroma"])
config = ConfigDB.get_all_as_keyword(exclude_static: true)
refute config[:pleroma][Pleroma.Search]
refute config[:pleroma][Pleroma.Search.ParadeDB]
refute config[:pleroma][Pleroma.Search.ParadeDB.Repo]
assert config[:pleroma][:instance] == [name: "Pleroma"]
end
describe "update_or_create/1" do
test "common" do
config = insert(:config)
@ -174,6 +188,22 @@ defmodule Pleroma.ConfigDBTest do
assert updated1.value == [groups: [c: 3, d: 4], key: [a: 1, b: 2]]
assert updated2.value == [mascots: [c: 3, d: 4], key: [a: 1, b: 2]]
end
test "rejects invalid :rate_limit values (e.g. empty-string scale from AdminFE)" do
assert {:error, _changeset} =
ConfigDB.update_or_create(%{
group: ":pleroma",
key: ":rate_limit",
value: [
%{
"tuple" => [
":statuses_actions",
[%{"tuple" => ["", 0]}, %{"tuple" => ["", ""]}]
]
}
]
})
end
end
describe "delete/1" do
@ -273,24 +303,28 @@ defmodule Pleroma.ConfigDBTest do
end
test "sigil" do
assert ConfigDB.to_elixir_types("~r[comp[lL][aA][iI][nN]er]") == ~r/comp[lL][aA][iI][nN]er/
assert ConfigDB.to_elixir_types("~r[comp[lL][aA][iI][nN]er]").source ==
~r/comp[lL][aA][iI][nN]er/.source
end
test "link sigil" do
assert ConfigDB.to_elixir_types("~r/https:\/\/example.com/") == ~r/https:\/\/example.com/
assert ConfigDB.to_elixir_types("~r/https:\/\/example.com/").source ==
~r/https:\/\/example.com/.source
end
test "link sigil with um modifiers" do
assert ConfigDB.to_elixir_types("~r/https:\/\/example.com/um") ==
~r/https:\/\/example.com/um
assert ConfigDB.to_elixir_types("~r/https:\/\/example.com/um").source ==
~r/https:\/\/example.com/um.source
end
test "link sigil with i modifier" do
assert ConfigDB.to_elixir_types("~r/https:\/\/example.com/i") == ~r/https:\/\/example.com/i
assert ConfigDB.to_elixir_types("~r/https:\/\/example.com/i").source ==
~r/https:\/\/example.com/i.source
end
test "link sigil with s modifier" do
assert ConfigDB.to_elixir_types("~r/https:\/\/example.com/s") == ~r/https:\/\/example.com/s
assert ConfigDB.to_elixir_types("~r/https:\/\/example.com/s").source ==
~r/https:\/\/example.com/s.source
end
test "raise if valid delimiter not found" do
@ -460,11 +494,11 @@ defmodule Pleroma.ConfigDBTest do
test "complex keyword with sigil" do
assert ConfigDB.to_elixir_types([
%{"tuple" => [":federated_timeline_removal", []]},
%{"tuple" => [":reject", ["~r/comp[lL][aA][iI][nN]er/"]]},
%{"tuple" => [":reject", [~r/comp[lL][aA][iI][nN]er/.source]]},
%{"tuple" => [":replace", []]}
]) == [
federated_timeline_removal: [],
reject: [~r/comp[lL][aA][iI][nN]er/],
reject: [~r/comp[lL][aA][iI][nN]er/.source],
replace: []
]
end

View file

@ -332,7 +332,7 @@ defmodule Pleroma.Conversation.ParticipationTest do
# When it's a reply from the blocked user
{:ok, _direct2} =
CommonAPI.post(blocked, %{
status: "reply",
status: "@#{third_user.nickname}, #{blocker.nickname} reply",
visibility: "direct",
in_reply_to_conversation_id: blocked_participation.id
})

View file

@ -14,7 +14,7 @@ defmodule Pleroma.ConversationTest do
setup_all do: clear_config([:instance, :federating], true)
setup do
Mox.stub_with(Pleroma.UnstubbedConfigMock, Pleroma.Config)
Mox.stub_with(Pleroma.UnstubbedConfigMock, Pleroma.Test.StaticConfig)
:ok
end
@ -66,8 +66,10 @@ defmodule Pleroma.ConversationTest do
jafnhar = insert(:user, local: false)
tridi = insert(:user)
to = [har.nickname, jafnhar.nickname, tridi.nickname]
{:ok, activity} =
CommonAPI.post(har, %{status: "Hey @#{jafnhar.nickname}", visibility: "direct"})
CommonAPI.post(har, %{status: "Hey @#{jafnhar.nickname}", visibility: "direct", to: to})
object = Pleroma.Object.normalize(activity, fetch: false)
context = object.data["context"]
@ -88,7 +90,8 @@ defmodule Pleroma.ConversationTest do
CommonAPI.post(jafnhar, %{
status: "Hey @#{har.nickname}",
visibility: "direct",
in_reply_to_status_id: activity.id
in_reply_to_status_id: activity.id,
to: to
})
object = Pleroma.Object.normalize(activity, fetch: false)
@ -112,7 +115,8 @@ defmodule Pleroma.ConversationTest do
CommonAPI.post(tridi, %{
status: "Hey @#{har.nickname}",
visibility: "direct",
in_reply_to_status_id: activity.id
in_reply_to_status_id: activity.id,
to: to
})
object = Pleroma.Object.normalize(activity, fetch: false)

View file

@ -0,0 +1,16 @@
# Pleroma: A lightweight social networking server
# Copyright © 2017-2022 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.DependencyVersionTest do
use ExUnit.Case, async: true
test "uses majic 1.2" do
majic_version =
:majic
|> Application.spec(:vsn)
|> to_string()
assert Version.match?(majic_version, "~> 1.2")
end
end

View file

@ -0,0 +1,56 @@
# Pleroma: A lightweight social networking server
# Copyright © 2017-2023 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.EctoType.ActivityPub.ObjectValidators.ContentLanguageMapTest do
use Pleroma.DataCase, async: true
alias Pleroma.EctoType.ActivityPub.ObjectValidators.ContentLanguageMap
test "it validates" do
data = %{
"en-US" => "mew mew",
"en-GB" => "meow meow"
}
assert {:ok, ^data} = ContentLanguageMap.cast(data)
end
test "it validates empty strings" do
data = %{
"en-US" => "mew mew",
"en-GB" => ""
}
assert {:ok, ^data} = ContentLanguageMap.cast(data)
end
test "it ignores non-strings within the map" do
data = %{
"en-US" => "mew mew",
"en-GB" => 123
}
assert {:ok, validated_data} = ContentLanguageMap.cast(data)
assert validated_data == %{"en-US" => "mew mew"}
end
test "it ignores bad locale codes" do
data = %{
"en-US" => "mew mew",
"en_GB" => "meow meow",
"en<<#@!$#!@%!GB" => "meow meow"
}
assert {:ok, validated_data} = ContentLanguageMap.cast(data)
assert validated_data == %{"en-US" => "mew mew"}
end
test "it complains with non-map data" do
assert :error = ContentLanguageMap.cast("mew")
assert :error = ContentLanguageMap.cast(["mew"])
assert :error = ContentLanguageMap.cast([%{"en-US" => "mew"}])
end
end

View file

@ -0,0 +1,29 @@
# Pleroma: A lightweight social networking server
# Copyright © 2017-2023 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.EctoType.ActivityPub.ObjectValidators.LanguageCodeTest do
use Pleroma.DataCase, async: true
alias Pleroma.EctoType.ActivityPub.ObjectValidators.LanguageCode
test "it accepts language code" do
text = "pl"
assert {:ok, ^text} = LanguageCode.cast(text)
end
test "it accepts language code with region" do
text = "pl-PL"
assert {:ok, ^text} = LanguageCode.cast(text)
end
test "errors for invalid language code" do
assert {:error, :invalid_language} = LanguageCode.cast("ru_RU")
assert {:error, :invalid_language} = LanguageCode.cast(" ")
assert {:error, :invalid_language} = LanguageCode.cast("en-US\n")
end
test "errors for non-text" do
assert :error == LanguageCode.cast(42)
end
end

View file

@ -4,6 +4,7 @@
defmodule Pleroma.Emoji.PackTest do
use Pleroma.DataCase
alias Pleroma.Emoji
alias Pleroma.Emoji.Pack
@emoji_path Path.join(
@ -12,6 +13,9 @@ defmodule Pleroma.Emoji.PackTest do
)
setup do
# Reload emoji to ensure a clean state
Emoji.reload()
pack_path = Path.join(@emoji_path, "dump_pack")
File.mkdir(pack_path)
@ -53,6 +57,63 @@ defmodule Pleroma.Emoji.PackTest do
assert updated_pack.files_count == 5
end
test "skips existing emojis when adding from zip file", %{pack: pack} do
# First, let's create a test pack with a "bear" emoji
test_pack_path = Path.join(@emoji_path, "test_bear_pack")
Pleroma.Backports.mkdir_p(test_pack_path)
# Create a pack.json file
File.write!(Path.join(test_pack_path, "pack.json"), """
{
"files": { "bear": "bear.png" },
"pack": {
"description": "Bear Pack", "homepage": "https://pleroma.social",
"license": "Test license", "share-files": true
}}
""")
# Copy a test image to use as the bear emoji
File.cp!(
Path.absname("test/instance_static/emoji/test_pack/blank.png"),
Path.join(test_pack_path, "bear.png")
)
# Load the pack to register the "bear" emoji in the global registry
{:ok, _bear_pack} = Pleroma.Emoji.Pack.load_pack("test_bear_pack")
# Reload emoji to make sure the bear emoji is in the global registry
Emoji.reload()
# Verify that the bear emoji exists in the global registry
assert Emoji.exist?("bear")
# Now try to add a zip file that contains an emoji with the same shortcode
file = %Plug.Upload{
content_type: "application/zip",
filename: "emojis.zip",
path: Path.absname("test/fixtures/emojis.zip")
}
{:ok, updated_pack} = Pack.add_file(pack, nil, nil, file)
# Verify that the "bear" emoji was skipped
refute Map.has_key?(updated_pack.files, "bear")
# Other emojis should be added
assert Map.has_key?(updated_pack.files, "a_trusted_friend-128")
assert Map.has_key?(updated_pack.files, "auroraborealis")
assert Map.has_key?(updated_pack.files, "baby_in_a_box")
assert Map.has_key?(updated_pack.files, "bear-128")
# Total count should be 4 (all emojis except "bear")
assert updated_pack.files_count == 4
# Clean up the test pack
on_exit(fn ->
File.rm_rf!(test_pack_path)
end)
end
end
test "returns error when zip file is bad", %{pack: pack} do
@ -62,7 +123,7 @@ defmodule Pleroma.Emoji.PackTest do
path: Path.absname("test/instance_static/emoji/test_pack/blank.png")
}
assert Pack.add_file(pack, nil, nil, file) == {:error, :einval}
assert {:error, _} = Pack.add_file(pack, nil, nil, file)
end
test "returns pack when zip file is empty", %{pack: pack} do

View file

@ -9,7 +9,7 @@ defmodule Pleroma.FrontendTest do
@dir "test/frontend_static_test"
setup do
File.mkdir_p!(@dir)
Pleroma.Backports.mkdir_p!(@dir)
clear_config([:instance, :static_dir], @dir)
on_exit(fn ->
@ -46,7 +46,7 @@ defmodule Pleroma.FrontendTest do
folder = Path.join([@dir, "frontends", "pleroma", "fantasy"])
previously_existing = Path.join([folder, "temp"])
File.mkdir_p!(folder)
Pleroma.Backports.mkdir_p!(folder)
File.write!(previously_existing, "yey")
assert File.exists?(previously_existing)

View file

@ -0,0 +1,179 @@
# Pleroma: A lightweight social networking server
# Copyright © 2017-2022 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Gun.ConnTest do
use ExUnit.Case
import Mox
alias Pleroma.Gun.Conn
setup :verify_on_exit!
setup do
stub(Pleroma.GunMock, :await_up, fn _, _ -> {:ok, :http} end)
stub(Pleroma.GunMock, :await_tunnel_up, fn _, _, _ -> {:ok, :http2} end)
stub(Pleroma.GunMock, :close, fn _ -> :ok end)
:ok
end
test "selects the direct transport from the URI scheme" do
expect(Pleroma.GunMock, :open, 2, fn
~c"example.com", 8443, %{transport: :tls, tls_opts: tls_opts} ->
assert tls_opts[:verify] == :verify_peer
{:ok, spawn_link(fn -> Process.sleep(:infinity) end)}
~c"example.com", 443, %{transport: :tcp} ->
{:ok, spawn_link(fn -> Process.sleep(:infinity) end)}
end)
assert {:ok, https_conn, :http, nil} = Conn.open(URI.parse("https://example.com:8443"), [])
assert {:ok, http_conn, :http, nil} = Conn.open(URI.parse("http://example.com:443"), [])
stop_conn(https_conn)
stop_conn(http_conn)
end
test "passes authentication to an HTTP CONNECT proxy" do
conn = spawn_link(fn -> Process.sleep(:infinity) end)
stream = make_ref()
expect(Pleroma.GunMock, :open, fn ~c"proxy.example", 8080, opts ->
assert opts.transport == :tcp
refute Map.has_key?(opts, :tls_opts)
{:ok, conn}
end)
expect(Pleroma.GunMock, :connect, fn ^conn, connect_opts ->
assert connect_opts.username == "alice"
assert connect_opts.password == "secret"
assert connect_opts.transport == :tls
assert connect_opts.protocols == [:http2, :http]
stream
end)
expect(Pleroma.GunMock, :await, fn ^conn, ^stream -> {:response, :fin, 200, []} end)
assert {:ok, ^conn, :http2, ^stream} =
Conn.open(URI.parse("https://origin.example/inbox"),
proxy: {~c"proxy.example", 8080},
proxy_auth: {"alice", "secret"}
)
stop_conn(conn)
end
test "uses separate TLS options for a TLS CONNECT proxy" do
conn = spawn_link(fn -> Process.sleep(:infinity) end)
stream = make_ref()
expect(Pleroma.GunMock, :open, fn ~c"proxy.example", 8443, opts ->
assert opts.transport == :tls
assert opts.tls_opts == [verify: :verify_none]
{:ok, conn}
end)
expect(Pleroma.GunMock, :connect, fn ^conn, _connect_opts -> stream end)
expect(Pleroma.GunMock, :await, fn ^conn, ^stream -> {:response, :fin, 200, []} end)
assert {:ok, ^conn, :http2, ^stream} =
Conn.open(URI.parse("https://origin.example/inbox"),
proxy: {~c"proxy.example", 8443},
transport: :tls,
proxy_tls_opts: [verify: :verify_none]
)
stop_conn(conn)
end
test "uses HTTP/1 for a TLS forwarding proxy" do
conn = spawn_link(fn -> Process.sleep(:infinity) end)
expect(Pleroma.GunMock, :open, fn ~c"proxy.example", 8443, opts ->
assert opts.transport == :tls
assert opts.tls_opts == [verify: :verify_none]
assert opts.protocols == [:http]
{:ok, conn}
end)
assert {:ok, ^conn, :http, :forward_proxy} =
Conn.open(URI.parse("http://origin.example/inbox"),
proxy: {~c"proxy.example", 8443},
transport: :tls,
proxy_tls_opts: [verify: :verify_none]
)
stop_conn(conn)
end
test "passes authentication and remote DNS destination to a SOCKS5 proxy" do
conn = spawn_link(fn -> Process.sleep(:infinity) end)
expect(Pleroma.GunMock, :open, fn ~c"proxy.example", 1080, opts ->
assert opts.protocols == [:socks]
assert opts.transport == :tcp
assert opts.socks_opts.host == ~c"origin.example"
assert opts.socks_opts.port == 443
assert opts.socks_opts.auth == [{:username_password, "alice", "secret"}]
assert opts.socks_opts.transport == :tls
assert opts.socks_opts.protocols == [:http2, :http]
refute Map.has_key?(opts, :tls_opts)
{:ok, conn}
end)
stub(Pleroma.GunMock, :await_up, fn _, _ -> {:ok, :socks} end)
assert {:ok, ^conn, :http2, nil} =
Conn.open(URI.parse("https://origin.example/inbox"),
proxy: {:socks5, ~c"proxy.example", 1080},
proxy_auth: {"alice", "secret"}
)
stop_conn(conn)
end
test "closes a CONNECT socket when proxy authentication fails" do
conn = spawn_link(fn -> Process.sleep(:infinity) end)
stream = make_ref()
expect(Pleroma.GunMock, :open, fn _, _, _ -> {:ok, conn} end)
expect(Pleroma.GunMock, :connect, fn ^conn, _ -> stream end)
expect(Pleroma.GunMock, :await, fn ^conn, ^stream -> {:response, :nofin, 407, []} end)
expect(Pleroma.GunMock, :close, fn ^conn -> Process.exit(conn, :normal) end)
assert {:error, :proxy_auth_failed} =
Conn.open(URI.parse("https://origin.example/inbox"),
proxy: {~c"proxy.example", 8080},
proxy_auth: {"alice", "wrong"}
)
end
test "returns the proxy status when CONNECT is forbidden" do
conn = spawn_link(fn -> Process.sleep(:infinity) end)
stream = make_ref()
expect(Pleroma.GunMock, :open, fn _, _, _ -> {:ok, conn} end)
expect(Pleroma.GunMock, :connect, fn ^conn, _ -> stream end)
expect(Pleroma.GunMock, :await, fn ^conn, ^stream -> {:response, :fin, 403, []} end)
expect(Pleroma.GunMock, :close, fn ^conn -> Process.exit(conn, :normal) end)
assert {:error, {:proxy_connect_failed, 403}} =
Conn.open(URI.parse("https://origin.example/inbox"),
proxy: {~c"proxy.example", 8080}
)
end
test "rejects SOCKS4 before opening a socket" do
assert {:error, :socks4_unsupported} =
Conn.open(URI.parse("https://origin.example/inbox"),
proxy: {:socks4, ~c"proxy.example", 1080}
)
end
defp stop_conn(conn) do
Process.unlink(conn)
Process.exit(conn, :kill)
end
end

View file

@ -11,8 +11,11 @@ defmodule Pleroma.Gun.ConnectionPoolTest do
defp gun_mock(_) do
Pleroma.GunMock
|> stub(:open, fn _, _, _ -> Task.start_link(fn -> Process.sleep(100) end) end)
|> stub(:open, fn _, _, _ -> Task.start_link(fn -> Process.sleep(:infinity) end) end)
|> stub(:await_up, fn _, _ -> {:ok, :http} end)
|> stub(:connect, fn _, _ -> make_ref() end)
|> stub(:await, fn _, _ -> {:response, :fin, 200, []} end)
|> stub(:await_tunnel_up, fn _, _, _ -> {:ok, :http2} end)
|> stub(:set_owner, fn _, _ -> :ok end)
:ok
@ -20,40 +23,213 @@ defmodule Pleroma.Gun.ConnectionPoolTest do
setup :gun_mock
test "gives the same connection to 2 concurrent requests" do
Enum.map(
[
"http://www.korean-books.com.kp/KBMbooks/en/periodic/pictorial/20200530163914.pdf",
"http://www.korean-books.com.kp/KBMbooks/en/periodic/pictorial/20200528183427.pdf"
],
fn uri ->
uri = URI.parse(uri)
task_parent = self()
test "opens sibling HTTP/1 connections while an existing connection is in use" do
uri = URI.parse("http://www.korean-books.com.kp/document.pdf")
Task.start_link(fn ->
{:ok, conn} = ConnectionPool.get_conn(uri, [])
ConnectionPool.release_conn(conn)
send(task_parent, conn)
end)
end
)
assert {:ok, first} = ConnectionPool.get_conn(uri, [])
assert {:ok, second} = ConnectionPool.get_conn(uri, [])
assert first != second
[pid, pid] =
for _ <- 1..2 do
receive do
pid -> pid
end
assert :ok = ConnectionPool.release_conn(first)
assert :ok = ConnectionPool.release_conn(second)
end
test "multiplexes concurrent HTTP/2 leases on one connection" do
stub(Pleroma.GunMock, :await_up, fn _, _ -> {:ok, :http2} end)
uri = URI.parse("https://h2.example/inbox")
assert {:ok, conn} = ConnectionPool.get_conn(uri, [])
assert {:ok, ^conn} = ConnectionPool.get_conn(uri, [])
assert :ok = ConnectionPool.release_conn(conn)
assert :ok = ConnectionPool.release_conn(conn)
assert Process.alive?(conn)
end
test "does not share connections across direct and authenticated proxy routes" do
uri = URI.parse("https://routes.example/inbox")
assert {:ok, direct} = ConnectionPool.get_conn(uri, [])
assert {:ok, proxied} =
ConnectionPool.get_conn(uri,
proxy: {~c"proxy.example", 8080},
proxy_auth: {"alice", "secret"}
)
assert {:ok, other_credentials} =
ConnectionPool.get_conn(uri,
proxy: {~c"proxy.example", 8080},
proxy_auth: {"bob", "different-secret"}
)
assert Enum.uniq([direct, proxied, other_credentials]) ==
[direct, proxied, other_credentials]
registry_dump = inspect(Registry.select(ConnectionPool, [{{:"$1", :_, :_}, [], [:"$1"]}]))
refute registry_dump =~ "secret"
Enum.each([direct, proxied, other_credentials], &ConnectionPool.release_conn/1)
end
test "coalesces concurrent cold HTTP/2 checkouts behind one connection" do
test_pid = self()
stub(Pleroma.GunMock, :await_up, fn _, _ ->
send(test_pid, {:awaiting_connection, self()})
receive do
:continue -> {:ok, :http2}
end
end)
uri = URI.parse("https://cold-h2.example/inbox")
tasks =
for _ <- 1..10 do
Task.async(fn -> ConnectionPool.get_conn(uri, []) end)
end
assert_receive {:awaiting_connection, worker_pid}
refute_receive {:awaiting_connection, _other_worker}, 50
send(worker_pid, :continue)
assert [{:ok, conn}] = tasks |> Enum.map(&Task.await/1) |> Enum.uniq()
assert Process.alive?(conn)
end
test "shares a cold connection failure with concurrent waiters" do
test_pid = self()
stub(Pleroma.GunMock, :open, fn _, _, _ ->
send(test_pid, {:opening_connection, self()})
receive do
:fail -> {:error, :econnrefused}
end
end)
uri = URI.parse("https://unreachable.example/inbox")
tasks =
for _ <- 1..10 do
Task.async(fn -> ConnectionPool.get_conn(uri, []) end)
end
assert_receive {:opening_connection, worker_pid}
refute_receive {:opening_connection, _other_worker}, 50
send(worker_pid, :fail)
assert Enum.all?(tasks, fn task -> match?({:error, _reason}, Task.await(task)) end)
refute_receive {:opening_connection, _other_worker}, 50
end
test "bounds many-host workloads and closes released idle workers" do
clear_config([:connections_pool, :max_connections]) do
clear_config([:connections_pool, :max_connections], 25)
clear_config([:connections_pool, :max_idle_time], 5)
restart_worker_supervisor()
on_exit(&restart_worker_supervisor/0)
end
connections =
Enum.map(1..25, fn host_number ->
uri = URI.parse("https://host-#{host_number}.example/inbox")
assert {:ok, conn} = ConnectionPool.get_conn(uri, [])
conn
end)
assert DynamicSupervisor.count_children(Pleroma.Gun.ConnectionPool.WorkerSupervisor).active ==
25
assert {:error, :pool_full} =
ConnectionPool.get_conn(URI.parse("https://overflow.example/inbox"), [])
Enum.each(connections, &ConnectionPool.release_conn/1)
assert eventually(fn ->
DynamicSupervisor.count_children(Pleroma.Gun.ConnectionPool.WorkerSupervisor).active ==
0
end)
end
test "does not reclaim a worker that became active after idle selection" do
uri = URI.parse("https://reclaim-race.example/inbox")
assert {:ok, conn} = ConnectionPool.get_conn(uri, [])
assert :ok = ConnectionPool.release_conn(conn)
worker = worker_for_conn(conn)
assert {:idle, _crf, _last_reference} = GenServer.call(worker, :reclaim_info)
assert {:ok, ^conn} = ConnectionPool.get_conn(uri, [])
assert :in_use = GenServer.call(worker, :reclaim)
assert Process.alive?(worker)
assert :ok = ConnectionPool.release_conn(conn)
end
test "cancels one HTTP/2 stream without discarding the connection" do
stub(Pleroma.GunMock, :await_up, fn _, _ -> {:ok, :http2} end)
uri = URI.parse("https://cancel-h2.example/inbox")
stream = make_ref()
expect(Pleroma.GunMock, :cancel, fn conn, ^stream when is_pid(conn) -> :ok end)
assert {:ok, conn} = ConnectionPool.get_conn(uri, [])
assert :ok = ConnectionPool.register_stream(conn, stream)
assert :ok = ConnectionPool.cancel_stream(conn, stream)
assert {:ok, ^conn} = ConnectionPool.get_conn(uri, [])
assert :ok = ConnectionPool.release_conn(conn)
end
test "ignores a stale Gun error after cancelling an HTTP/2 stream" do
stub(Pleroma.GunMock, :await_up, fn _, _ -> {:ok, :http2} end)
uri = URI.parse("https://stale-cancel-h2.example/inbox")
stream = make_ref()
expect(Pleroma.GunMock, :cancel, fn conn, ^stream when is_pid(conn) -> :ok end)
assert {:ok, conn} = ConnectionPool.get_conn(uri, [])
worker = worker_for_conn(conn)
assert :ok = ConnectionPool.register_stream(conn, stream)
assert :ok = ConnectionPool.cancel_stream(conn, stream)
send(worker, {:gun_error, conn, stream, {:badstate, "The stream cannot be found."}})
assert %{clients: %{}} = :sys.get_state(worker)
assert Process.alive?(worker)
assert {:ok, ^conn} = ConnectionPool.get_conn(uri, [])
assert :ok = ConnectionPool.release_conn(conn)
end
test "stops an HTTP/1 worker when a client dies with an active stream" do
uri = URI.parse("http://dead-stream.example/media")
parent = self()
client =
spawn(fn ->
{:ok, conn} = ConnectionPool.get_conn(uri, [])
:ok = ConnectionPool.register_stream(conn, make_ref())
send(parent, {:active_stream, conn})
Process.sleep(:infinity)
end)
assert_receive {:active_stream, conn}
worker = worker_for_conn(conn)
Process.exit(client, :kill)
assert eventually(fn -> not Process.alive?(worker) end)
end
test "connection limit is respected with concurrent requests" do
clear_config([:connections_pool, :max_connections]) do
clear_config([:connections_pool, :max_connections], 1)
# The supervisor needs a reboot to apply the new config setting
Process.exit(Process.whereis(Pleroma.Gun.ConnectionPool.WorkerSupervisor), :kill)
restart_worker_supervisor()
on_exit(fn ->
Process.exit(Process.whereis(Pleroma.Gun.ConnectionPool.WorkerSupervisor), :kill)
restart_worker_supervisor()
end)
end
@ -96,4 +272,41 @@ defmodule Pleroma.Gun.ConnectionPoolTest do
|> Enum.sort()
end)
end
defp restart_worker_supervisor do
supervisor = Process.whereis(Pleroma.Gun.ConnectionPool.WorkerSupervisor)
monitor = Process.monitor(supervisor)
Process.exit(supervisor, :kill)
assert_receive {:DOWN, ^monitor, :process, ^supervisor, :killed}
assert eventually(fn ->
case Process.whereis(Pleroma.Gun.ConnectionPool.WorkerSupervisor) do
pid when is_pid(pid) -> pid != supervisor and Process.alive?(pid)
nil -> false
end
end)
end
defp worker_for_conn(conn) do
[worker] =
Registry.select(ConnectionPool, [
{{:_, :"$1", {:"$2", :_}}, [{:==, :"$2", conn}], [:"$1"]}
])
worker
end
defp eventually(predicate, attempts \\ 50)
defp eventually(_predicate, 0), do: false
defp eventually(predicate, attempts) do
if predicate.() do
true
else
Process.sleep(10)
eventually(predicate, attempts - 1)
end
end
end

View file

@ -14,4 +14,133 @@ defmodule Pleroma.HashtagTest do
assert {:name, {"can't be blank", [validation: :required]}} in changeset.errors
end
end
describe "search_hashtags" do
test "searches hashtags by partial match" do
{:ok, _} = Hashtag.get_or_create_by_name("car")
{:ok, _} = Hashtag.get_or_create_by_name("racecar")
{:ok, _} = Hashtag.get_or_create_by_name("nascar")
{:ok, _} = Hashtag.get_or_create_by_name("bicycle")
results = Hashtag.search("car")
assert "car" in results
assert "racecar" in results
assert "nascar" in results
refute "bicycle" in results
results = Hashtag.search("race")
assert "racecar" in results
refute "car" in results
refute "nascar" in results
refute "bicycle" in results
results = Hashtag.search("nonexistent")
assert results == []
end
test "searches hashtags by multiple words in query" do
{:ok, _} = Hashtag.get_or_create_by_name("computer")
{:ok, _} = Hashtag.get_or_create_by_name("laptop")
{:ok, _} = Hashtag.get_or_create_by_name("desktop")
{:ok, _} = Hashtag.get_or_create_by_name("phone")
# Search for "new computer" - should return "computer"
results = Hashtag.search("new computer")
assert "computer" in results
refute "laptop" in results
refute "desktop" in results
refute "phone" in results
# Search for "computer laptop" - should return both
results = Hashtag.search("computer laptop")
assert "computer" in results
assert "laptop" in results
refute "desktop" in results
refute "phone" in results
# Search for "new phone" - should return "phone"
results = Hashtag.search("new phone")
assert "phone" in results
refute "computer" in results
refute "laptop" in results
refute "desktop" in results
end
test "supports pagination" do
{:ok, _} = Hashtag.get_or_create_by_name("alpha")
{:ok, _} = Hashtag.get_or_create_by_name("beta")
{:ok, _} = Hashtag.get_or_create_by_name("gamma")
{:ok, _} = Hashtag.get_or_create_by_name("delta")
results = Hashtag.search("a", limit: 2)
assert length(results) == 2
results = Hashtag.search("a", limit: 2, offset: 1)
assert length(results) == 2
end
test "handles matching many search terms" do
{:ok, _} = Hashtag.get_or_create_by_name("computer")
{:ok, _} = Hashtag.get_or_create_by_name("laptop")
{:ok, _} = Hashtag.get_or_create_by_name("phone")
{:ok, _} = Hashtag.get_or_create_by_name("tablet")
results = Hashtag.search("new fast computer laptop phone tablet device")
assert "computer" in results
assert "laptop" in results
assert "phone" in results
assert "tablet" in results
end
test "ranks results by match quality" do
{:ok, _} = Hashtag.get_or_create_by_name("my_computer")
{:ok, _} = Hashtag.get_or_create_by_name("computer_science")
{:ok, _} = Hashtag.get_or_create_by_name("computer")
results = Hashtag.search("computer")
# Exact match first
assert Enum.at(results, 0) == "computer"
# Prefix match would be next
assert Enum.at(results, 1) == "computer_science"
# worst match is last
assert Enum.at(results, 2) == "my_computer"
end
test "prioritizes shorter names when ranking is equal" do
# Create hashtags with same ranking but different lengths
{:ok, _} = Hashtag.get_or_create_by_name("car")
{:ok, _} = Hashtag.get_or_create_by_name("racecar")
{:ok, _} = Hashtag.get_or_create_by_name("nascar")
# Search for "car" - shorter names should come first
results = Hashtag.search("car")
# Shortest exact match first
assert Enum.at(results, 0) == "car"
assert "racecar" in results
assert "nascar" in results
end
test "handles hashtag symbols in search query" do
{:ok, _} = Hashtag.get_or_create_by_name("computer")
{:ok, _} = Hashtag.get_or_create_by_name("laptop")
{:ok, _} = Hashtag.get_or_create_by_name("phone")
results_with_hash = Hashtag.search("#computer #laptop")
results_without_hash = Hashtag.search("computer laptop")
assert results_with_hash == results_without_hash
results_mixed = Hashtag.search("#computer laptop #phone")
assert "computer" in results_mixed
assert "laptop" in results_mixed
assert "phone" in results_mixed
results_only_hash = Hashtag.search("#computer")
results_no_hash = Hashtag.search("computer")
assert results_only_hash == results_no_hash
end
end
end

View file

@ -73,5 +73,11 @@ defmodule Pleroma.HTTP.AdapterHelper.GunTest do
assert opts[:proxy] == {~c"example.com", 4321}
end
test "uses managed chunks for streamed responses" do
opts = Gun.options([stream: true], URI.parse("https://example.com"))
assert opts[:body_as] == :chunks
end
end
end

View file

@ -16,6 +16,14 @@ defmodule Pleroma.HTTP.AdapterHelper.HackneyTest do
describe "options/2" do
setup do: clear_config([:http, :adapter], a: 1, b: 2)
test "uses redirect-safe defaults", %{uri: uri} do
opts = Hackney.options([], uri)
assert opts[:follow_redirect] == false
assert opts[:force_redirect] == false
assert opts[:with_body] == true
end
test "add proxy and opts from config", %{uri: uri} do
opts = Hackney.options([proxy: "localhost:8123"], uri)

View file

@ -0,0 +1,808 @@
# Pleroma: A lightweight social networking server
# Copyright © 2017-2022 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.HTTP.GunIntegrationTest do
use ExUnit.Case, async: false
use Pleroma.Tests.Helpers
alias Pleroma.Tesla.Middleware.ConnectionPool
setup do
clear_config([Pleroma.Gun], Pleroma.Gun.API)
clear_config([:connections_pool, :max_idle_time], 1_000)
origin = start_tls_origin(self())
connect_proxy = start_connect_proxy(self())
forward_proxy = start_forward_proxy(self())
socks_proxy = start_socks5_proxy(self())
on_exit(fn ->
Process.sleep(20)
stop_server(socks_proxy)
stop_server(forward_proxy)
stop_server(connect_proxy)
stop_server(origin)
end)
{:ok,
origin: origin,
connect_proxy: connect_proxy,
forward_proxy: forward_proxy,
socks_proxy: socks_proxy}
end
test "requests HTTPS directly on a non-standard port", %{origin: origin} do
assert {:ok, %Tesla.Env{status: 200, body: "ok"}} = request(origin.base_url <> "/final")
end
test "follows redirects through an authenticated CONNECT proxy", %{
origin: origin,
connect_proxy: proxy
} do
proxy_opts = [
proxy: {~c"127.0.0.1", proxy.port},
proxy_auth: {"alice", "secret"}
]
assert {:ok, %Tesla.Env{status: 200, body: "ok"}} =
request(origin.base_url <> "/redirect", proxy_opts, true)
expected = "Basic " <> Base.encode64("alice:secret")
assert_receive {:connect_proxy, ^expected, "127.0.0.1", origin_port}
assert origin_port == origin.port
end
test "requests through an authenticated CONNECT proxy", %{
origin: origin,
connect_proxy: proxy
} do
proxy_opts = [
proxy: {~c"127.0.0.1", proxy.port},
proxy_auth: {"alice", "secret"}
]
result = request(origin.base_url <> "/final", proxy_opts)
expected = "Basic " <> Base.encode64("alice:secret")
assert_receive {:connect_proxy, ^expected, "127.0.0.1", origin_port}
assert origin_port == origin.port
assert worker_protocol(origin.port) == :http
assert_receive {:origin_request, "/final"}
assert {:ok, %Tesla.Env{status: 200, body: "ok"}} = result
end
test "requests HTTP through a forwarding proxy", %{forward_proxy: proxy} do
proxy_opts = [proxy: {~c"127.0.0.1", proxy.port}]
url = "http://origin.example:8080/final?key=value"
assert {:ok, %Tesla.Env{status: 200, body: "ok"}} = request(url, proxy_opts)
assert_receive {:forward_proxy, ^url, "origin.example:8080", nil}
end
test "authenticates HTTP requests to a forwarding proxy", %{forward_proxy: proxy} do
proxy_opts = [
proxy: {~c"127.0.0.1", proxy.port},
proxy_auth: {"alice", "secret"}
]
url = "http://origin.example/final"
assert {:ok, %Tesla.Env{status: 200, body: "ok"}} = request(url, proxy_opts)
expected = "Basic " <> Base.encode64("alice:secret")
assert_receive {:forward_proxy, ^url, "origin.example", ^expected}
end
test "opens a request-ready authenticated CONNECT tunnel", %{
origin: origin,
connect_proxy: proxy
} do
uri = URI.parse(origin.base_url <> "/final")
opts = [
proxy: {~c"127.0.0.1", proxy.port},
proxy_auth: {"alice", "secret"},
tls_opts: [verify: :verify_none],
connect_timeout: 2_000
]
assert {:ok, conn, :http, tunnel} = Pleroma.Gun.Conn.open(uri, opts)
stream = :gun.get(conn, "/final", [], %{tunnel: tunnel})
assert {:response, :nofin, 200, _headers} = :gun.await(conn, stream, 2_000)
assert {:ok, "ok"} = :gun.await_body(conn, stream, 2_000)
:gun.close(conn)
end
test "streams chunks through an authenticated CONNECT tunnel", %{
origin: origin,
connect_proxy: proxy
} do
proxy_opts = [
proxy: {~c"127.0.0.1", proxy.port},
proxy_auth: {"alice", "secret"}
]
assert {:ok, %Tesla.Env{status: 200, body: client}} =
request_chunks(origin.base_url <> "/chunks", proxy_opts)
assert collect_chunks(client) == "hello"
assert :ok = Pleroma.Gun.ConnectionPool.release_conn(client.pid)
end
test "discards an unfinished HTTP/1 redirect stream before following it", %{
origin: origin,
connect_proxy: proxy
} do
proxy_opts = [
proxy: {~c"127.0.0.1", proxy.port},
proxy_auth: {"alice", "secret"}
]
assert {:ok, %Tesla.Env{status: 200, body: client}} =
request_chunks(origin.base_url <> "/redirect_chunks", proxy_opts, true)
assert collect_chunks(client) == "hello"
assert :ok = Pleroma.Gun.ConnectionPool.release_conn(client.pid)
expected = "Basic " <> Base.encode64("alice:secret")
assert_receive {:connect_proxy, ^expected, "127.0.0.1", _port}
assert_receive {:connect_proxy, ^expected, "127.0.0.1", _port}
end
test "uploads multipart bodies through an authenticated CONNECT tunnel", %{
origin: origin,
connect_proxy: proxy
} do
proxy_opts = [
proxy: {~c"127.0.0.1", proxy.port},
proxy_auth: {"alice", "secret"}
]
multipart = Tesla.Multipart.new() |> Tesla.Multipart.add_field("name", "value")
assert {:ok, %Tesla.Env{status: 200}} =
request_multipart(origin.base_url <> "/upload", multipart, proxy_opts)
assert_receive {:origin_headers, "/upload", headers}
assert String.downcase(headers) =~ "content-type: multipart/form-data;"
assert_receive {:origin_body, "/upload", body}
assert body =~ "name=\"name\""
assert body =~ "value"
end
test "uploads streamed bodies through an authenticated CONNECT tunnel", %{
origin: origin,
connect_proxy: proxy
} do
proxy_opts = [
proxy: {~c"127.0.0.1", proxy.port},
proxy_auth: {"alice", "secret"}
]
body = Stream.map(["streamed", "-body"], & &1)
assert {:ok, %Tesla.Env{status: 200}} =
request_stream(origin.base_url <> "/upload", body, proxy_opts)
assert_receive {:origin_body, "/upload", "streamed-body"}
end
test "discards a partial HTTP/1 upload when body enumeration raises", %{origin: origin} do
body =
Stream.map(["partial", "raise"], fn
"raise" -> raise "upload failed"
part -> part
end)
assert_raise RuntimeError, "upload failed", fn ->
request_stream(origin.base_url <> "/upload", body, [])
end
assert_receive {:origin_headers, "/upload", _headers}
assert worker_protocol(origin.port) == nil
assert {:ok, %Tesla.Env{status: 200, body: "ok"}} = request(origin.base_url <> "/final")
end
test "releases the lease when Gun rejects an invalid request header", %{origin: origin} do
adapter_opts = [
tls_opts: [verify: :verify_none],
connect_timeout: 2_000,
timeout: 2_000
]
client = Tesla.client([ConnectionPool], Tesla.Adapter.Gun)
assert {:invalid_request_header, "x-test", _message} =
catch_error(
Tesla.get(client, origin.base_url <> "/final",
headers: [{"x-test", "value\r\nx-injected: true"}],
opts: [adapter: adapter_opts]
)
)
assert worker_clients(origin.port) == %{}
refute_receive {:origin_request, "/final"}
assert {:ok, %Tesla.Env{status: 200, body: "ok"}} = request(origin.base_url <> "/final")
end
test "requests through authenticated SOCKS5 with proxy-side DNS", %{
origin: origin,
socks_proxy: proxy
} do
proxy_opts = [
proxy: {:socks5, ~c"127.0.0.1", proxy.port},
proxy_auth: {"alice", "secret"}
]
url = "https://localhost:#{origin.port}/final"
assert {:ok, %Tesla.Env{status: 200, body: "ok"}} = request(url, proxy_opts)
assert_receive {:socks5_proxy, "alice", "secret", "localhost", origin_port}
assert origin_port == origin.port
end
test "requests through unauthenticated SOCKS5 with an IP proxy host", %{
origin: origin,
socks_proxy: proxy
} do
proxy_opts = [proxy: {:socks5, {127, 0, 0, 1}, proxy.port}]
assert {:ok, %Tesla.Env{status: 200, body: "ok"}} =
request(origin.base_url <> "/final", proxy_opts)
assert_receive {:socks5_proxy, nil, nil, "127.0.0.1", origin_port}
assert origin_port == origin.port
end
test "requests through unauthenticated SOCKS5 with a localhost proxy host", %{
origin: origin,
socks_proxy: proxy
} do
proxy_opts = [proxy: {:socks5, ~c"localhost", proxy.port}]
assert {:ok, %Tesla.Env{status: 200, body: "ok"}} =
request(origin.base_url <> "/final", proxy_opts)
assert_receive {:socks5_proxy, nil, nil, "127.0.0.1", origin_port}
assert origin_port == origin.port
end
defp request(url, proxy_opts \\ [], follow_redirects \\ false) do
middleware =
if follow_redirects do
[Tesla.Middleware.FollowRedirects, ConnectionPool]
else
[ConnectionPool]
end
adapter_opts =
Keyword.merge(
[tls_opts: [verify: :verify_none], connect_timeout: 2_000, timeout: 2_000],
proxy_opts
)
middleware
|> Tesla.client(Tesla.Adapter.Gun)
|> Tesla.get(url, opts: [adapter: adapter_opts])
end
defp request_chunks(url, proxy_opts, follow_redirects \\ false) do
adapter_opts =
Keyword.merge(
[
body_as: :chunks,
tls_opts: [verify: :verify_none],
connect_timeout: 2_000,
timeout: 2_000
],
proxy_opts
)
middleware =
if follow_redirects do
[Tesla.Middleware.FollowRedirects, ConnectionPool]
else
[ConnectionPool]
end
middleware
|> Tesla.client(Tesla.Adapter.Gun)
|> Tesla.get(url, opts: [adapter: adapter_opts])
end
defp request_multipart(url, multipart, proxy_opts) do
adapter_opts =
Keyword.merge(
[tls_opts: [verify: :verify_none], connect_timeout: 2_000, timeout: 2_000],
proxy_opts
)
[ConnectionPool]
|> Tesla.client(Tesla.Adapter.Gun)
|> Tesla.post(url, multipart, opts: [adapter: adapter_opts])
end
defp request_stream(url, body, proxy_opts) do
adapter_opts =
Keyword.merge(
[tls_opts: [verify: :verify_none], connect_timeout: 2_000, timeout: 2_000],
proxy_opts
)
[ConnectionPool]
|> Tesla.client(Tesla.Adapter.Gun)
|> Tesla.post(url, body, opts: [adapter: adapter_opts])
end
defp collect_chunks(client, acc \\ "") do
case Tesla.Adapter.Gun.read_chunk(client.pid, client.stream, client.opts) do
{:nofin, chunk} -> collect_chunks(client, acc <> chunk)
{:fin, chunk} -> acc <> chunk
end
end
defp start_tls_origin(parent) do
certfile = Path.expand("../../fixtures/server.pem", __DIR__)
keyfile = Path.expand("../../fixtures/private_key.pem", __DIR__)
{:ok, listener} =
:ssl.listen(0, [
:binary,
certfile: certfile,
keyfile: keyfile,
reuseaddr: true,
active: false,
packet: :raw,
ip: {127, 0, 0, 1}
])
{:ok, {{127, 0, 0, 1}, port}} = :ssl.sockname(listener)
{:ok, acceptor} = Task.start_link(fn -> accept_tls_loop(listener, parent) end)
%{
listener: listener,
acceptor: acceptor,
transport: :ssl,
port: port,
base_url: "https://127.0.0.1:#{port}"
}
end
defp accept_tls_loop(listener, parent) do
case :ssl.transport_accept(listener) do
{:ok, tcp_socket} ->
case :ssl.handshake(tcp_socket, 2_000) do
{:ok, socket} ->
pid = spawn(fn -> receive_and_serve_tls(socket, parent) end)
:ok = :ssl.controlling_process(socket, pid)
send(pid, :serve)
{:error, _reason} ->
:gen_tcp.close(tcp_socket)
end
accept_tls_loop(listener, parent)
{:error, _reason} ->
:ok
end
end
defp receive_and_serve_tls(socket, parent) do
receive do
:serve -> serve_tls(socket, parent)
end
end
defp serve_tls(socket, parent) do
case recv_headers(:ssl, socket) do
{:ok, request} ->
{headers, buffered_body} = split_headers(request)
path = request_path(headers)
send(parent, {:origin_request, path})
send(parent, {:origin_headers, path, headers})
case path do
"/redirect" ->
send_tls_response(socket, 302, "Found", [{"Location", "/final"}], "")
"/redirect_chunks" ->
send_tls_response(socket, 302, "Found", [{"Location", "/chunks"}], "discard-me")
"/final" ->
send_tls_response(socket, 200, "OK", [], "ok")
"/chunks" ->
send_tls_chunks(socket, ["hel", "lo"])
"/upload" ->
{:ok, body} = recv_request_body(socket, headers, buffered_body)
send(parent, {:origin_body, path, body})
send_tls_response(socket, 200, "OK", [], "uploaded")
_ ->
send_tls_response(socket, 404, "Not Found", [], "not found")
end
serve_tls(socket, parent)
{:error, _reason} ->
:ssl.close(socket)
end
end
defp send_tls_response(socket, status, reason, headers, body) do
headers =
[
{"Content-Length", Integer.to_string(byte_size(body))},
{"Connection", "keep-alive"}
] ++ headers
:ssl.send(socket, [
"HTTP/1.1 ",
Integer.to_string(status),
" ",
reason,
"\r\n",
Enum.map(headers, fn {key, value} -> [key, ": ", value, "\r\n"] end),
"\r\n",
body
])
end
defp send_tls_chunks(socket, chunks) do
:ssl.send(socket, [
"HTTP/1.1 200 OK\r\n",
"Transfer-Encoding: chunked\r\n",
"Connection: keep-alive\r\n\r\n",
Enum.map(chunks, fn chunk ->
[Integer.to_string(byte_size(chunk), 16), "\r\n", chunk, "\r\n"]
end),
"0\r\n\r\n"
])
end
defp start_connect_proxy(parent) do
start_tcp_server(fn socket -> serve_connect_proxy(socket, parent) end)
end
defp serve_connect_proxy(socket, parent) do
with {:ok, headers} <- recv_headers(:gen_tcp, socket),
{:ok, host, port} <- parse_connect(headers),
auth when is_binary(auth) <- header(headers, "proxy-authorization"),
{:ok, upstream} <- tcp_connect(host, port) do
send(parent, {:connect_proxy, auth, host, port})
:ok = :gen_tcp.send(socket, "HTTP/1.1 200 Connection established\r\n\r\n")
tunnel(socket, upstream)
else
_ ->
:gen_tcp.send(
socket,
"HTTP/1.1 407 Proxy Authentication Required\r\nContent-Length: 0\r\n\r\n"
)
:gen_tcp.close(socket)
end
end
defp start_forward_proxy(parent) do
start_tcp_server(fn socket -> serve_forward_proxy(socket, parent) end)
end
defp serve_forward_proxy(socket, parent) do
with {:ok, request} <- recv_headers(:gen_tcp, socket) do
{headers, _buffered_body} = split_headers(request)
target = request_path(headers)
host = header(headers, "host")
auth = header(headers, "proxy-authorization")
if String.starts_with?(target, "http://") do
send(parent, {:forward_proxy, target, host, auth})
:gen_tcp.send(
socket,
"HTTP/1.1 200 OK\r\nContent-Length: 2\r\nConnection: keep-alive\r\n\r\nok"
)
serve_forward_proxy(socket, parent)
else
:gen_tcp.send(
socket,
"HTTP/1.1 403 Forbidden\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"
)
:gen_tcp.close(socket)
end
end
end
defp start_socks5_proxy(parent) do
start_tcp_server(fn socket -> serve_socks5_proxy(socket, parent) end)
end
defp serve_socks5_proxy(socket, parent) do
with {:ok, <<5, method_count>>} <- :gen_tcp.recv(socket, 2, 2_000),
{:ok, methods} <- :gen_tcp.recv(socket, method_count, 2_000),
{:ok, username, password} <- negotiate_socks5_auth(socket, methods),
{:ok, host, port} <- recv_socks5_destination(socket),
{:ok, upstream} <- tcp_connect(host, port),
:ok <- :gen_tcp.send(socket, <<5, 0, 0, 1, 0, 0, 0, 0, 0, 0>>) do
send(parent, {:socks5_proxy, username, password, host, port})
tunnel(socket, upstream)
else
_ ->
:gen_tcp.close(socket)
end
end
defp negotiate_socks5_auth(socket, methods) do
cond do
:binary.match(methods, <<2>>) != :nomatch ->
with :ok <- :gen_tcp.send(socket, <<5, 2>>),
{:ok, username, password} <- recv_socks5_auth(socket),
:ok <- :gen_tcp.send(socket, <<1, 0>>) do
{:ok, username, password}
end
:binary.match(methods, <<0>>) != :nomatch ->
with :ok <- :gen_tcp.send(socket, <<5, 0>>) do
{:ok, nil, nil}
end
true ->
{:error, :unsupported_auth}
end
end
defp recv_socks5_auth(socket) do
with {:ok, <<1, username_length>>} <- :gen_tcp.recv(socket, 2, 2_000),
{:ok, username} <- :gen_tcp.recv(socket, username_length, 2_000),
{:ok, <<password_length>>} <- :gen_tcp.recv(socket, 1, 2_000),
{:ok, password} <- :gen_tcp.recv(socket, password_length, 2_000) do
{:ok, username, password}
end
end
defp recv_socks5_destination(socket) do
with {:ok, <<5, 1, 0, address_type>>} <- :gen_tcp.recv(socket, 4, 2_000),
{:ok, host} <- recv_socks5_host(socket, address_type),
{:ok, <<port::16>>} <- :gen_tcp.recv(socket, 2, 2_000) do
{:ok, host, port}
end
end
defp recv_socks5_host(socket, 1) do
with {:ok, <<a, b, c, d>>} <- :gen_tcp.recv(socket, 4, 2_000) do
{:ok, Enum.join([a, b, c, d], ".")}
end
end
defp recv_socks5_host(socket, 3) do
with {:ok, <<length>>} <- :gen_tcp.recv(socket, 1, 2_000),
{:ok, host} <- :gen_tcp.recv(socket, length, 2_000) do
{:ok, host}
end
end
defp start_tcp_server(handler) do
{:ok, listener} =
:gen_tcp.listen(0, [
:binary,
active: false,
packet: :raw,
reuseaddr: true,
ip: {127, 0, 0, 1}
])
{:ok, {{127, 0, 0, 1}, port}} = :inet.sockname(listener)
{:ok, acceptor} = Task.start_link(fn -> accept_tcp_loop(listener, handler) end)
%{listener: listener, acceptor: acceptor, transport: :gen_tcp, port: port}
end
defp accept_tcp_loop(listener, handler) do
case :gen_tcp.accept(listener) do
{:ok, socket} ->
pid = spawn(fn -> receive_and_serve_tcp(socket, handler) end)
:ok = :gen_tcp.controlling_process(socket, pid)
send(pid, :serve)
accept_tcp_loop(listener, handler)
{:error, _reason} ->
:ok
end
end
defp receive_and_serve_tcp(socket, handler) do
receive do
:serve -> handler.(socket)
end
end
defp recv_headers(transport, socket, acc \\ <<>>) do
case transport.recv(socket, 0, 2_000) do
{:ok, data} ->
acc = acc <> data
if :binary.match(acc, "\r\n\r\n") == :nomatch do
recv_headers(transport, socket, acc)
else
{:ok, acc}
end
{:error, _reason} = error ->
error
end
end
defp request_path(headers) do
headers
|> String.split("\r\n", parts: 2)
|> hd()
|> String.split(" ")
|> Enum.at(1)
end
defp split_headers(request) do
{index, 4} = :binary.match(request, "\r\n\r\n")
split_at = index + 4
<<headers::binary-size(split_at), body::binary>> = request
{headers, body}
end
defp recv_request_body(socket, headers, buffered_body) do
cond do
String.contains?(String.downcase(headers), "transfer-encoding: chunked") ->
recv_chunked_body(socket, buffered_body, "")
content_length = header(headers, "content-length") ->
recv_exact(socket, buffered_body, String.to_integer(content_length))
true ->
{:ok, buffered_body}
end
end
defp recv_chunked_body(socket, buffer, acc) do
with {:ok, line, buffer} <- recv_line(socket, buffer),
{size, ""} <- Integer.parse(line, 16) do
if size == 0 do
{:ok, acc}
else
with {:ok, chunk_and_crlf, buffer} <- take_bytes(socket, buffer, size + 2),
<<chunk::binary-size(size), "\r\n">> <- chunk_and_crlf do
recv_chunked_body(socket, buffer, acc <> chunk)
end
end
end
end
defp recv_line(socket, buffer) do
case :binary.match(buffer, "\r\n") do
{index, 2} ->
<<line::binary-size(index), "\r\n", rest::binary>> = buffer
{:ok, line, rest}
:nomatch ->
with {:ok, data} <- :ssl.recv(socket, 0, 2_000) do
recv_line(socket, buffer <> data)
end
end
end
defp take_bytes(_socket, buffer, size) when byte_size(buffer) >= size do
<<bytes::binary-size(size), rest::binary>> = buffer
{:ok, bytes, rest}
end
defp take_bytes(socket, buffer, size) do
with {:ok, data} <- :ssl.recv(socket, 0, 2_000) do
take_bytes(socket, buffer <> data, size)
end
end
defp recv_exact(socket, buffer, size) do
with {:ok, body, _rest} <- take_bytes(socket, buffer, size) do
{:ok, body}
end
end
defp parse_connect(headers) do
case headers |> String.split("\r\n", parts: 2) |> hd() |> String.split(" ") do
["CONNECT", authority, _protocol] ->
case String.split(authority, ":", parts: 2) do
[host, port] -> {:ok, host, String.to_integer(port)}
_ -> {:error, :invalid_authority}
end
_ ->
{:error, :invalid_connect}
end
end
defp header(headers, wanted_name) do
headers
|> String.split("\r\n")
|> Enum.find_value(fn line ->
case String.split(line, ":", parts: 2) do
[name, value] ->
if String.downcase(name) == wanted_name, do: String.trim(value)
_ ->
nil
end
end)
end
defp tcp_connect(host, port) do
:gen_tcp.connect(
String.to_charlist(host),
port,
[:binary, active: false, packet: :raw],
2_000
)
end
defp worker_protocol(port) do
case worker_state(port) do
nil -> nil
state -> state.protocol
end
end
defp worker_clients(port) do
case worker_state(port) do
nil -> nil
state -> state.clients
end
end
defp worker_state(port) do
prefix = "https:127.0.0.1:#{port}:"
Pleroma.Gun.ConnectionPool
|> Registry.select([{{:"$1", :"$2", :_}, [], [{{:"$1", :"$2"}}]}])
|> Enum.find_value(fn {key, worker} ->
if String.starts_with?(key, prefix), do: :sys.get_state(worker)
end)
end
defp tunnel(client, upstream) do
:ok = :inet.setopts(client, active: :once)
:ok = :inet.setopts(upstream, active: :once)
tunnel_loop(client, upstream)
end
defp tunnel_loop(client, upstream) do
receive do
{:tcp, ^client, data} ->
:ok = :gen_tcp.send(upstream, data)
:ok = :inet.setopts(client, active: :once)
tunnel_loop(client, upstream)
{:tcp, ^upstream, data} ->
:ok = :gen_tcp.send(client, data)
:ok = :inet.setopts(upstream, active: :once)
tunnel_loop(client, upstream)
{:tcp_closed, _socket} ->
:gen_tcp.close(client)
:gen_tcp.close(upstream)
{:tcp_error, _socket, _reason} ->
:gen_tcp.close(client)
:gen_tcp.close(upstream)
after
10_000 ->
:gen_tcp.close(client)
:gen_tcp.close(upstream)
end
end
defp stop_server(%{listener: listener, acceptor: acceptor, transport: transport}) do
transport.close(listener)
if Process.alive?(acceptor), do: Process.exit(acceptor, :normal)
end
end

View file

@ -0,0 +1,404 @@
# Pleroma: A lightweight social networking server
# Copyright © 2017-2022 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.HTTP.HackneyFollowRedirectRegressionTest do
use ExUnit.Case, async: false
setup do
{:ok, _} = Application.ensure_all_started(:hackney)
{:ok, tls_server} = start_tls_redirect_server()
{:ok, proxy} = start_connect_proxy()
on_exit(fn ->
stop_connect_proxy(proxy)
stop_tls_redirect_server(tls_server)
end)
{:ok, tls_server: tls_server, proxy: proxy}
end
test "hackney follow_redirect crashes behind CONNECT proxy on relative redirects", %{
tls_server: tls_server,
proxy: proxy
} do
url = "#{tls_server.base_url}/redirect"
opts = [
pool: :media,
proxy: proxy.proxy_url,
insecure: true,
connect_timeout: 1_000,
recv_timeout: 1_000,
follow_redirect: true,
force_redirect: true
]
{pid, ref} = spawn_monitor(fn -> :hackney.request(:get, url, [], <<>>, opts) end)
assert_receive {:DOWN, ^ref, :process, ^pid, reason}, 5_000
assert match?({%FunctionClauseError{}, _}, reason) or match?(%FunctionClauseError{}, reason) or
match?({:function_clause, _}, reason)
end
test "redirects work via proxy when hackney follow_redirect is disabled", %{
tls_server: tls_server,
proxy: proxy
} do
url = "#{tls_server.base_url}/redirect"
adapter_opts = [
pool: :media,
proxy: proxy.proxy_url,
insecure: true,
connect_timeout: 1_000,
recv_timeout: 1_000,
follow_redirect: false,
force_redirect: false,
with_body: true
]
client = Tesla.client([Tesla.Middleware.FollowRedirects], Tesla.Adapter.Hackney)
assert {:ok, %Tesla.Env{status: 200, body: "ok"}} =
Tesla.request(client, method: :get, url: url, opts: [adapter: adapter_opts])
end
test "reverse proxy hackney client follows redirects via proxy without crashing", %{
tls_server: tls_server,
proxy: proxy
} do
url = "#{tls_server.base_url}/redirect"
opts = [
pool: :media,
proxy: proxy.proxy_url,
insecure: true,
connect_timeout: 1_000,
recv_timeout: 1_000,
follow_redirect: true
]
assert {:ok, 200, _headers, ref} =
Pleroma.ReverseProxy.Client.Hackney.request(:get, url, [], "", opts)
assert collect_body(ref) == "ok"
Pleroma.ReverseProxy.Client.Hackney.close(ref)
end
test "hackney reverse proxy follows nested redirects via proxy", %{
tls_server: tls_server,
proxy: proxy
} do
url = "#{tls_server.base_url}/nested_redirect"
opts = [
pool: :media,
proxy: proxy.proxy_url,
insecure: true,
connect_timeout: 1_000,
recv_timeout: 1_000,
follow_redirect: true
]
assert {:ok, 200, _headers, ref} =
Pleroma.ReverseProxy.Client.Hackney.request(:get, url, [], "", opts)
assert collect_body(ref) == "ok"
Pleroma.ReverseProxy.Client.Hackney.close(ref)
end
test "hackney reverse proxy stops following redirects after limit is reached", %{
tls_server: tls_server,
proxy: proxy
} do
url = "#{tls_server.base_url}/infinite_redirect"
opts = [
pool: :media,
proxy: proxy.proxy_url,
insecure: true,
connect_timeout: 1_000,
recv_timeout: 1_000,
follow_redirect: true
]
assert {:ok, 302, _headers, ref} =
Pleroma.ReverseProxy.Client.Hackney.request(:get, url, [], "", opts)
Pleroma.ReverseProxy.Client.Hackney.close(ref)
end
defp collect_body(ref, acc \\ "") do
case Pleroma.ReverseProxy.Client.Hackney.stream_body(ref) do
:done -> acc
{:ok, data, _ref} -> collect_body(ref, acc <> data)
{:error, error} -> flunk("stream_body failed: #{inspect(error)}")
end
end
defp start_tls_redirect_server do
certfile = Path.expand("../../fixtures/server.pem", __DIR__)
keyfile = Path.expand("../../fixtures/private_key.pem", __DIR__)
{:ok, listener} =
:ssl.listen(0, [
:binary,
certfile: certfile,
keyfile: keyfile,
reuseaddr: true,
active: false,
packet: :raw,
ip: {127, 0, 0, 1}
])
{:ok, {{127, 0, 0, 1}, port}} = :ssl.sockname(listener)
{:ok, acceptor} =
Task.start_link(fn ->
accept_tls_loop(listener)
end)
{:ok, %{listener: listener, acceptor: acceptor, base_url: "https://127.0.0.1:#{port}"}}
end
defp stop_tls_redirect_server(%{listener: listener, acceptor: acceptor}) do
:ok = :ssl.close(listener)
if Process.alive?(acceptor) do
Process.exit(acceptor, :normal)
end
end
defp accept_tls_loop(listener) do
case :ssl.transport_accept(listener) do
{:ok, socket} ->
_ = Task.start(fn -> serve_tls(socket) end)
accept_tls_loop(listener)
{:error, :closed} ->
:ok
{:error, _reason} ->
:ok
end
end
defp serve_tls(tcp_socket) do
with {:ok, ssl_socket} <- :ssl.handshake(tcp_socket, 2_000),
{:ok, data} <- recv_ssl_headers(ssl_socket),
{:ok, path} <- parse_path(data) do
case path do
"/infinite_redirect" ->
send_ssl_response(ssl_socket, 302, "Found", [{"Location", "/infinite_redirect"}], "")
"/nested_redirect" ->
send_ssl_response(ssl_socket, 302, "Found", [{"Location", "/redirect"}], "")
"/redirect" ->
send_ssl_response(ssl_socket, 302, "Found", [{"Location", "/final"}], "")
"/final" ->
send_ssl_response(ssl_socket, 200, "OK", [], "ok")
_ ->
send_ssl_response(ssl_socket, 404, "Not Found", [], "not found")
end
:ssl.close(ssl_socket)
else
_ ->
_ = :gen_tcp.close(tcp_socket)
:ok
end
end
defp recv_ssl_headers(socket, acc \\ <<>>) do
case :ssl.recv(socket, 0, 1_000) do
{:ok, data} ->
acc = acc <> data
if :binary.match(acc, "\r\n\r\n") != :nomatch do
{:ok, acc}
else
if byte_size(acc) > 8_192 do
{:error, :too_large}
else
recv_ssl_headers(socket, acc)
end
end
{:error, _} = error ->
error
end
end
defp send_ssl_response(socket, status, reason, headers, body) do
base_headers =
[
{"Content-Length", Integer.to_string(byte_size(body))},
{"Connection", "close"}
] ++ headers
iodata =
[
"HTTP/1.1 ",
Integer.to_string(status),
" ",
reason,
"\r\n",
Enum.map(base_headers, fn {k, v} -> [k, ": ", v, "\r\n"] end),
"\r\n",
body
]
:ssl.send(socket, iodata)
end
defp start_connect_proxy do
{:ok, listener} =
:gen_tcp.listen(0, [
:binary,
active: false,
packet: :raw,
reuseaddr: true,
ip: {127, 0, 0, 1}
])
{:ok, {{127, 0, 0, 1}, port}} = :inet.sockname(listener)
{:ok, acceptor} =
Task.start_link(fn ->
accept_proxy_loop(listener)
end)
{:ok, %{listener: listener, acceptor: acceptor, proxy_url: "127.0.0.1:#{port}"}}
end
defp stop_connect_proxy(%{listener: listener, acceptor: acceptor}) do
:ok = :gen_tcp.close(listener)
if Process.alive?(acceptor) do
Process.exit(acceptor, :normal)
end
end
defp accept_proxy_loop(listener) do
case :gen_tcp.accept(listener) do
{:ok, socket} ->
_ = Task.start(fn -> serve_proxy(socket) end)
accept_proxy_loop(listener)
{:error, :closed} ->
:ok
{:error, _reason} ->
:ok
end
end
defp serve_proxy(client_socket) do
with {:ok, {headers, rest}} <- recv_tcp_headers(client_socket),
{:ok, {host, port}} <- parse_connect(headers),
{:ok, upstream_socket} <- connect_upstream(host, port) do
:gen_tcp.send(client_socket, "HTTP/1.1 200 Connection established\r\n\r\n")
if rest != <<>> do
:gen_tcp.send(upstream_socket, rest)
end
tunnel(client_socket, upstream_socket)
else
_ ->
:gen_tcp.close(client_socket)
:ok
end
end
defp tunnel(client_socket, upstream_socket) do
parent = self()
_ = spawn_link(fn -> forward(client_socket, upstream_socket, parent) end)
_ = spawn_link(fn -> forward(upstream_socket, client_socket, parent) end)
receive do
:tunnel_closed -> :ok
after
10_000 -> :ok
end
:gen_tcp.close(client_socket)
:gen_tcp.close(upstream_socket)
end
defp forward(from_socket, to_socket, parent) do
case :gen_tcp.recv(from_socket, 0, 10_000) do
{:ok, data} ->
_ = :gen_tcp.send(to_socket, data)
forward(from_socket, to_socket, parent)
{:error, _reason} ->
send(parent, :tunnel_closed)
:ok
end
end
defp recv_tcp_headers(socket, acc \\ <<>>) do
case :gen_tcp.recv(socket, 0, 1_000) do
{:ok, data} ->
acc = acc <> data
case :binary.match(acc, "\r\n\r\n") do
:nomatch ->
if byte_size(acc) > 8_192 do
{:error, :too_large}
else
recv_tcp_headers(socket, acc)
end
{idx, _len} ->
split_at = idx + 4
<<headers::binary-size(split_at), rest::binary>> = acc
{:ok, {headers, rest}}
end
{:error, _} = error ->
error
end
end
defp parse_connect(data) do
with [request_line | _] <- String.split(data, "\r\n", trim: true),
["CONNECT", hostport | _] <- String.split(request_line, " ", parts: 3),
[host, port_str] <- String.split(hostport, ":", parts: 2),
{port, ""} <- Integer.parse(port_str) do
{:ok, {host, port}}
else
_ -> {:error, :invalid_connect}
end
end
defp connect_upstream(host, port) do
address =
case :inet.parse_address(String.to_charlist(host)) do
{:ok, ip} -> ip
{:error, _} -> String.to_charlist(host)
end
:gen_tcp.connect(address, port, [:binary, active: false, packet: :raw], 1_000)
end
defp parse_path(data) do
case String.split(data, "\r\n", parts: 2) do
[request_line | _] ->
case String.split(request_line, " ") do
[_method, path, _protocol] -> {:ok, path}
_ -> {:error, :invalid_request}
end
_ ->
{:error, :invalid_request}
end
end
end

View file

@ -0,0 +1,151 @@
# Pleroma: A lightweight social networking server
# Copyright © 2017-2022 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.HTTP.HackneyRedirectRegressionTest do
use ExUnit.Case, async: false
alias Pleroma.HTTP.AdapterHelper.Hackney, as: HackneyAdapterHelper
setup do
{:ok, _} = Application.ensure_all_started(:hackney)
{:ok, server} = start_server()
on_exit(fn -> stop_server(server) end)
{:ok, server: server}
end
test "pooled redirects work with follow_redirect disabled", %{server: server} do
url = "#{server.base_url}/redirect"
uri = URI.parse(url)
adapter_opts =
HackneyAdapterHelper.options(
[pool: :media, follow_redirect: false, no_proxy_env: true],
uri
)
client = Tesla.client([Tesla.Middleware.FollowRedirects], Tesla.Adapter.Hackney)
assert {:ok, %Tesla.Env{status: 200, body: "ok"}} =
Tesla.request(client, method: :get, url: url, opts: [adapter: adapter_opts])
end
defp start_server do
{:ok, listener} =
:gen_tcp.listen(0, [
:binary,
active: false,
packet: :raw,
reuseaddr: true,
ip: {127, 0, 0, 1}
])
{:ok, {{127, 0, 0, 1}, port}} = :inet.sockname(listener)
{:ok, acceptor} =
Task.start_link(fn ->
accept_loop(listener)
end)
{:ok, %{listener: listener, acceptor: acceptor, base_url: "http://127.0.0.1:#{port}"}}
end
defp stop_server(%{listener: listener, acceptor: acceptor}) do
:ok = :gen_tcp.close(listener)
if Process.alive?(acceptor) do
Process.exit(acceptor, :normal)
end
end
defp accept_loop(listener) do
case :gen_tcp.accept(listener) do
{:ok, socket} ->
serve(socket)
accept_loop(listener)
{:error, :closed} ->
:ok
{:error, _reason} ->
:ok
end
end
defp serve(socket) do
with {:ok, data} <- recv_headers(socket),
{:ok, path} <- parse_path(data) do
case path do
"/redirect" ->
send_response(socket, 302, "Found", [{"Location", "/final"}], "")
"/final" ->
send_response(socket, 200, "OK", [], "ok")
_ ->
send_response(socket, 404, "Not Found", [], "not found")
end
else
_ -> :ok
end
:gen_tcp.close(socket)
end
defp recv_headers(socket, acc \\ <<>>) do
case :gen_tcp.recv(socket, 0, 1_000) do
{:ok, data} ->
acc = acc <> data
if :binary.match(acc, "\r\n\r\n") != :nomatch do
{:ok, acc}
else
if byte_size(acc) > 8_192 do
{:error, :too_large}
else
recv_headers(socket, acc)
end
end
{:error, _} = error ->
error
end
end
defp parse_path(data) do
case String.split(data, "\r\n", parts: 2) do
[request_line | _] ->
case String.split(request_line, " ") do
[_method, path, _protocol] -> {:ok, path}
_ -> {:error, :invalid_request}
end
_ ->
{:error, :invalid_request}
end
end
defp send_response(socket, status, reason, headers, body) do
base_headers =
[
{"Content-Length", Integer.to_string(byte_size(body))},
{"Connection", "close"}
] ++ headers
iodata =
[
"HTTP/1.1 ",
Integer.to_string(status),
" ",
reason,
"\r\n",
Enum.map(base_headers, fn {k, v} -> [k, ": ", v, "\r\n"] end),
"\r\n",
body
]
:gen_tcp.send(socket, iodata)
end
end

View file

@ -3,10 +3,13 @@
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.HTTPTest do
use ExUnit.Case, async: true
use ExUnit.Case, async: false
use Pleroma.Tests.Helpers
import Tesla.Mock
alias Pleroma.HTTP
alias Pleroma.Utils.URIEncoding
setup do
mock(fn
@ -25,6 +28,39 @@ defmodule Pleroma.HTTPTest do
%{method: :post, url: "http://example.com/world"} ->
%Tesla.Env{status: 200, body: "world"}
%{method: :get, url: "https://example.com/emoji/Pack%201/koronebless.png?foo=bar+baz"} ->
%Tesla.Env{status: 200, body: "emoji data"}
%{
method: :get,
url: "https://example.com/media/foo/bar%20!$&'()*+,;=/:%20@a%20%5Bbaz%5D.mp4"
} ->
%Tesla.Env{status: 200, body: "video data"}
%{method: :get, url: "https://example.com/media/unicode%20%F0%9F%99%82%20.gif"} ->
%Tesla.Env{status: 200, body: "unicode data"}
%{
method: :get,
url:
"https://i.guim.co.uk/img/media/1069ef13c447908272c4de94174cec2b6352cb2f/0_91_2000_1201/master/2000.jpg?width=1200&height=630&quality=85&auto=format&fit=crop&precrop=40:21,offset-x50,offset-y0&overlay-align=bottom%2Cleft&overlay-width=100p&overlay-base64=L2ltZy9zdGF0aWMvb3ZlcmxheXMvdGctb3BpbmlvbnMtYWdlLTIwMTkucG5n&enable=upscale&s=cba21427a73512fdc9863c486c03fdd8"
} ->
%Tesla.Env{status: 200, body: "Guardian image quirk"}
%{
method: :get,
url:
"https://i.guim.co.uk/emoji/Pack%201/koronebless.png?precrop=40:21,overlay-x0,overlay-y0&foo=bar+baz"
} ->
%Tesla.Env{status: 200, body: "Space in query with Guardian quirk"}
%{
method: :get,
url:
"https://examplebucket.s3.amazonaws.com/test.txt?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=accessKEY%2F20130721%2Fus-east-1%2Fs3%2Faws4_request&X-Amz-Date=20130721T201207Z&X-Amz-Expires=86400&X-Amz-Signature=SIGNATURE&X-Amz-SignedHeaders=host"
} ->
%Tesla.Env{status: 200, body: "AWS S3 data"}
end)
:ok
@ -67,4 +103,115 @@ defmodule Pleroma.HTTPTest do
}
end
end
test "URL encoding properly encodes URLs with spaces" do
clear_config(:test_url_encoding, true)
url_with_space = "https://example.com/emoji/Pack 1/koronebless.png?foo=bar baz"
{:ok, result} = HTTP.get(url_with_space)
assert result.status == 200
properly_encoded_url = "https://example.com/emoji/Pack%201/koronebless.png?foo=bar+baz"
{:ok, result} = HTTP.get(properly_encoded_url)
assert result.status == 200
url_with_reserved_chars = "https://example.com/media/foo/bar !$&'()*+,;=/: @a [baz].mp4"
{:ok, result} = HTTP.get(url_with_reserved_chars)
assert result.status == 200
url_with_unicode = "https://example.com/media/unicode 🙂 .gif"
{:ok, result} = HTTP.get(url_with_unicode)
assert result.status == 200
end
test "decodes URL first by default" do
clear_config(:test_url_encoding, true)
normal_url = "https://example.com/media/file%20with%20space.jpg?name=a+space.jpg"
result = URIEncoding.encode_url(normal_url)
assert result == "https://example.com/media/file%20with%20space.jpg?name=a+space.jpg"
end
test "doesn't decode URL first when specified" do
clear_config(:test_url_encoding, true)
normal_url = "https://example.com/media/file%20with%20space.jpg"
result = URIEncoding.encode_url(normal_url, bypass_decode: true)
assert result == "https://example.com/media/file%2520with%2520space.jpg"
end
test "properly applies Guardian image query quirk" do
clear_config(:test_url_encoding, true)
url =
"https://i.guim.co.uk/img/media/1069ef13c447908272c4de94174cec2b6352cb2f/0_91_2000_1201/master/2000.jpg?width=1200&height=630&quality=85&auto=format&fit=crop&precrop=40:21,offset-x50,offset-y0&overlay-align=bottom%2Cleft&overlay-width=100p&overlay-base64=L2ltZy9zdGF0aWMvb3ZlcmxheXMvdGctb3BpbmlvbnMtYWdlLTIwMTkucG5n&enable=upscale&s=cba21427a73512fdc9863c486c03fdd8"
result = URIEncoding.encode_url(url)
assert result == url
{:ok, result_get} = HTTP.get(result)
assert result_get.status == 200
end
test "properly encodes spaces as \"pluses\" in query when using quirks" do
clear_config(:test_url_encoding, true)
url =
"https://i.guim.co.uk/emoji/Pack 1/koronebless.png?precrop=40:21,overlay-x0,overlay-y0&foo=bar baz"
properly_encoded_url =
"https://i.guim.co.uk/emoji/Pack%201/koronebless.png?precrop=40:21,overlay-x0,overlay-y0&foo=bar+baz"
result = URIEncoding.encode_url(url)
assert result == properly_encoded_url
{:ok, result_get} = HTTP.get(result)
assert result_get.status == 200
end
test "properly encode AWS S3 queries" do
clear_config(:test_url_encoding, true)
url =
"https://examplebucket.s3.amazonaws.com/test.txt?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=accessKEY%2F20130721%2Fus-east-1%2Fs3%2Faws4_request&X-Amz-Date=20130721T201207Z&X-Amz-Expires=86400&X-Amz-Signature=SIGNATURE&X-Amz-SignedHeaders=host"
unencoded_url =
"https://examplebucket.s3.amazonaws.com/test.txt?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=accessKEY/20130721/us-east-1/s3/aws4_request&X-Amz-Date=20130721T201207Z&X-Amz-Expires=86400&X-Amz-Signature=SIGNATURE&X-Amz-SignedHeaders=host"
result = URIEncoding.encode_url(url)
result_unencoded = URIEncoding.encode_url(unencoded_url)
assert result == url
assert result == result_unencoded
{:ok, result_get} = HTTP.get(result)
assert result_get.status == 200
end
test "preserves query key order" do
clear_config(:test_url_encoding, true)
url = "https://example.com/foo?hjkl=qwertz&xyz=abc&bar=baz"
result = URIEncoding.encode_url(url)
assert result == url
end
end

View file

@ -3,19 +3,15 @@
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Instances.InstanceTest do
alias Pleroma.Instances
alias Pleroma.Instances.Instance
alias Pleroma.Repo
alias Pleroma.Tests.ObanHelpers
alias Pleroma.Web.CommonAPI
use Oban.Testing, repo: Pleroma.Repo
use Pleroma.DataCase
import ExUnit.CaptureLog
import Pleroma.Factory
setup_all do: clear_config([:instance, :federation_reachability_timeout_days], 1)
describe "set_reachable/1" do
test "clears `unreachable_since` of existing matching Instance record having non-nil `unreachable_since`" do
unreachable_since = NaiveDateTime.to_iso8601(NaiveDateTime.utc_now())
@ -31,6 +27,32 @@ defmodule Pleroma.Instances.InstanceTest do
assert {:ok, instance} = Instance.set_reachable(instance.host)
refute instance.unreachable_since
end
test "cancels all ReachabilityWorker jobs for the domain" do
domain = "cancelme.example.org"
insert(:instance, host: domain, unreachable_since: NaiveDateTime.utc_now())
# Insert a ReachabilityWorker job for this domain, scheduled 5 minutes in the future
scheduled_at = DateTime.add(DateTime.utc_now(), 300, :second)
{:ok, job} =
Pleroma.Workers.ReachabilityWorker.new(
%{"domain" => domain, "phase" => "phase_1min", "attempt" => 1},
scheduled_at: scheduled_at
)
|> Oban.insert()
# Ensure the job is present
job = Pleroma.Repo.get(Oban.Job, job.id)
assert job
# Call set_reachable, which should delete the job
assert {:ok, _} = Instance.set_reachable(domain)
# Reload the job and assert it is deleted
job = Pleroma.Repo.get(Oban.Job, job.id)
refute job
end
end
describe "set_unreachable/1" do
@ -145,7 +167,11 @@ defmodule Pleroma.Instances.InstanceTest do
end
test "Doesn't scrapes unreachable instances" do
instance = insert(:instance, unreachable_since: Instances.reachability_datetime_threshold())
instance =
insert(:instance,
unreachable_since: NaiveDateTime.utc_now() |> NaiveDateTime.add(-:timer.hours(24))
)
url = "https://" <> instance.host
assert capture_log(fn -> assert nil == Instance.get_or_update_favicon(URI.parse(url)) end) =~
@ -213,32 +239,44 @@ defmodule Pleroma.Instances.InstanceTest do
end
end
test "delete_users_and_activities/1 deletes remote instance users and activities" do
[mario, luigi, _peach, wario] =
users = [
insert(:user, nickname: "mario@mushroom.kingdom", name: "Mario"),
insert(:user, nickname: "luigi@mushroom.kingdom", name: "Luigi"),
insert(:user, nickname: "peach@mushroom.kingdom", name: "Peach"),
insert(:user, nickname: "wario@greedville.biz", name: "Wario")
]
test "delete/1 schedules a job to delete the instance and users" do
insert(:user, nickname: "mario@mushroom.kingdom", name: "Mario")
{:ok, post1} = CommonAPI.post(mario, %{status: "letsa go!"})
{:ok, post2} = CommonAPI.post(luigi, %{status: "itsa me... luigi"})
{:ok, post3} = CommonAPI.post(wario, %{status: "WHA-HA-HA!"})
{:ok, _job} = Instance.delete("mushroom.kingdom")
{:ok, job} = Instance.delete_users_and_activities("mushroom.kingdom")
:ok = ObanHelpers.perform(job)
assert_enqueued(
worker: Pleroma.Workers.DeleteWorker,
args: %{"op" => "delete_instance", "host" => "mushroom.kingdom"}
)
end
[mario, luigi, peach, wario] = Repo.reload(users)
describe "check_unreachable/1" do
test "schedules a ReachabilityWorker job for the given domain" do
domain = "test.example.com"
refute mario.is_active
refute luigi.is_active
refute peach.is_active
refute peach.name == "Peach"
# Call check_unreachable
assert {:ok, _job} = Instance.check_unreachable(domain)
assert wario.is_active
assert wario.name == "Wario"
# Verify that a ReachabilityWorker job was scheduled
jobs = all_enqueued(worker: Pleroma.Workers.ReachabilityWorker)
assert length(jobs) == 1
[job] = jobs
assert job.args["domain"] == domain
end
assert [nil, nil, %{}] = Repo.reload([post1, post2, post3])
test "handles multiple calls for the same domain (uniqueness enforced)" do
domain = "duplicate.example.com"
assert {:ok, _job1} = Instance.check_unreachable(domain)
# Second call for the same domain
assert {:ok, %Oban.Job{conflict?: true}} = Instance.check_unreachable(domain)
# Should only have one job due to uniqueness
jobs = all_enqueued(worker: Pleroma.Workers.ReachabilityWorker)
assert length(jobs) == 1
[job] = jobs
assert job.args["domain"] == domain
end
end
end

View file

@ -6,74 +6,42 @@ defmodule Pleroma.InstancesTest do
alias Pleroma.Instances
use Pleroma.DataCase
setup_all do: clear_config([:instance, :federation_reachability_timeout_days], 1)
use Oban.Testing, repo: Pleroma.Repo
describe "reachable?/1" do
test "returns `true` for host / url with unknown reachability status" do
assert Instances.reachable?("unknown.site")
assert Instances.reachable?("http://unknown.site")
end
test "returns `false` for host / url marked unreachable for at least `reachability_datetime_threshold()`" do
host = "consistently-unreachable.name"
Instances.set_consistently_unreachable(host)
refute Instances.reachable?(host)
refute Instances.reachable?("http://#{host}/path")
end
test "returns `true` for host / url marked unreachable for less than `reachability_datetime_threshold()`" do
url = "http://eventually-unreachable.name/path"
Instances.set_unreachable(url)
assert Instances.reachable?(url)
assert Instances.reachable?(URI.parse(url).host)
end
test "raises FunctionClauseError exception on non-binary input" do
assert_raise FunctionClauseError, fn -> Instances.reachable?(nil) end
assert_raise FunctionClauseError, fn -> Instances.reachable?(1) end
end
end
describe "filter_reachable/1" do
setup do
host = "consistently-unreachable.name"
url1 = "http://eventually-unreachable.com/path"
url2 = "http://domain.com/path"
unreachable_host = "consistently-unreachable.name"
reachable_host = "http://domain.com/path"
Instances.set_consistently_unreachable(host)
Instances.set_unreachable(url1)
Instances.set_unreachable(unreachable_host)
result = Instances.filter_reachable([host, url1, url2, nil])
%{result: result, url1: url1, url2: url2}
result = Instances.filter_reachable([unreachable_host, reachable_host, nil])
%{result: result, reachable_host: reachable_host, unreachable_host: unreachable_host}
end
test "returns a map with keys containing 'not marked consistently unreachable' elements of supplied list",
%{result: result, url1: url1, url2: url2} do
assert is_map(result)
assert Enum.sort([url1, url2]) == result |> Map.keys() |> Enum.sort()
test "returns a list of only reachable elements",
%{result: result, reachable_host: reachable_host} do
assert is_list(result)
assert [reachable_host] == result
end
test "returns a map with `unreachable_since` values for keys",
%{result: result, url1: url1, url2: url2} do
assert is_map(result)
assert %NaiveDateTime{} = result[url1]
assert is_nil(result[url2])
end
test "returns an empty map for empty list or list containing no hosts / url" do
assert %{} == Instances.filter_reachable([])
assert %{} == Instances.filter_reachable([nil])
test "returns an empty list when provided no data" do
assert [] == Instances.filter_reachable([])
assert [] == Instances.filter_reachable([nil])
end
end
describe "set_reachable/1" do
test "sets unreachable url or host reachable" do
host = "domain.com"
Instances.set_consistently_unreachable(host)
Instances.set_unreachable(host)
refute Instances.reachable?(host)
Instances.set_reachable(host)
@ -103,22 +71,68 @@ defmodule Pleroma.InstancesTest do
end
end
describe "set_consistently_unreachable/1" do
test "sets reachable url or host unreachable" do
url = "http://domain.com?q="
assert Instances.reachable?(url)
describe "check_all_unreachable/0" do
test "schedules ReachabilityWorker jobs for all unreachable instances" do
domain1 = "unreachable1.example.com"
domain2 = "unreachable2.example.com"
domain3 = "unreachable3.example.com"
Instances.set_consistently_unreachable(url)
refute Instances.reachable?(url)
Instances.set_unreachable(domain1)
Instances.set_unreachable(domain2)
Instances.set_unreachable(domain3)
Instances.check_all_unreachable()
# Verify that ReachabilityWorker jobs were scheduled for all unreachable domains
jobs = all_enqueued(worker: Pleroma.Workers.ReachabilityWorker)
assert length(jobs) == 3
domains = Enum.map(jobs, & &1.args["domain"])
assert domain1 in domains
assert domain2 in domains
assert domain3 in domains
end
test "keeps unreachable url or host unreachable" do
host = "site.name"
Instances.set_consistently_unreachable(host)
refute Instances.reachable?(host)
test "does not schedule jobs for reachable instances" do
unreachable_domain = "unreachable.example.com"
reachable_domain = "reachable.example.com"
Instances.set_consistently_unreachable(host)
refute Instances.reachable?(host)
Instances.set_unreachable(unreachable_domain)
Instances.set_reachable(reachable_domain)
Instances.check_all_unreachable()
# Verify that only one job was scheduled (for the unreachable domain)
jobs = all_enqueued(worker: Pleroma.Workers.ReachabilityWorker)
assert length(jobs) == 1
[job] = jobs
assert job.args["domain"] == unreachable_domain
end
end
test "delete_all_unreachable/0 schedules DeleteWorker jobs for all unreachable instances" do
domain1 = "unreachable1.example.com"
domain2 = "unreachable2.example.com"
domain3 = "unreachable3.example.com"
Instances.set_unreachable(domain1)
Instances.set_unreachable(domain2)
Instances.set_unreachable(domain3)
Instances.delete_all_unreachable()
# Verify that DeleteWorker jobs were scheduled for all unreachable domains
jobs = all_enqueued(worker: Pleroma.Workers.DeleteWorker)
assert length(jobs) == 3
domains = Enum.map(jobs, & &1.args["host"])
assert domain1 in domains
assert domain2 in domains
assert domain3 in domains
# Verify all jobs are delete_instance operations
Enum.each(jobs, fn job ->
assert job.args["op"] == "delete_instance"
end)
end
end

View file

@ -11,6 +11,7 @@ defmodule Pleroma.Integration.MastodonWebsocketTest do
alias Pleroma.Integration.WebsocketClient
alias Pleroma.Web.CommonAPI
alias Pleroma.Web.MastodonAPI.StatusView
alias Pleroma.Web.OAuth
@moduletag needs_streamer: true, capture_log: true
@ -31,6 +32,48 @@ defmodule Pleroma.Integration.MastodonWebsocketTest do
WebsocketClient.start_link(self(), path, headers)
end
defp raw_websocket_handshake(qs, headers) do
uri = URI.parse(@path <> qs)
port = uri.port || 80
path = uri.path <> if(uri.query, do: "?" <> uri.query, else: "")
default_headers = [
{"host", "#{uri.host}:#{port}"},
{"upgrade", "websocket"},
{"connection", "Upgrade"},
{"sec-websocket-key", Base.encode64(:crypto.strong_rand_bytes(16))},
{"sec-websocket-version", "13"}
]
request = [
"GET #{path} HTTP/1.1\r\n",
Enum.map(default_headers ++ headers, fn {name, value} -> "#{name}: #{value}\r\n" end),
"\r\n"
]
with {:ok, socket} <-
:gen_tcp.connect(String.to_charlist(uri.host), port, [:binary, active: false], 1_000),
:ok <- :gen_tcp.send(socket, request),
{:ok, response} <- :gen_tcp.recv(socket, 0, 1_000) do
:gen_tcp.close(socket)
{:ok, parse_http_response(response)}
end
end
defp parse_http_response(response) do
[headers | _] = String.split(response, "\r\n\r\n", parts: 2)
[status_line | header_lines] = String.split(headers, "\r\n")
[_, status | _] = String.split(status_line, " ")
headers =
Enum.map(header_lines, fn line ->
[name, value] = String.split(line, ":", parts: 2)
{String.downcase(name), String.trim(value)}
end)
%{status: String.to_integer(status), headers: headers}
end
defp decode_json(json) do
with {:ok, %{"event" => event, "payload" => payload_text}} <- Jason.decode(json),
{:ok, payload} <- Jason.decode(payload_text) do
@ -85,9 +128,7 @@ defmodule Pleroma.Integration.MastodonWebsocketTest do
assert json["payload"]
assert {:ok, json} = Jason.decode(json["payload"])
view_json =
Pleroma.Web.MastodonAPI.StatusView.render("show.json", activity: activity, for: nil)
|> atom_key_to_string()
view_json = atom_key_to_string(StatusView.render("show.json", activity: activity, for: nil))
assert json == view_json
end
@ -114,10 +155,7 @@ defmodule Pleroma.Integration.MastodonWebsocketTest do
assert json["payload"]
assert {:ok, json} = Jason.decode(json["payload"])
view_json =
Pleroma.Web.MastodonAPI.StatusView.render("show.json", activity: activity, for: nil)
|> Jason.encode!()
|> Jason.decode!()
view_json = atom_key_to_string(StatusView.render("show.json", activity: activity, for: nil))
assert json == view_json
end
@ -279,6 +317,54 @@ defmodule Pleroma.Integration.MastodonWebsocketTest do
end)
end
test "echoes the Sec-WebSocket-Protocol token in the handshake", %{token: token} do
assert {:ok, %{status: 101, headers: headers}} =
raw_websocket_handshake("?stream=user", [
{"sec-websocket-protocol", token.token}
])
assert {"sec-websocket-protocol", token.token} in headers
end
test "echoes the selected Sec-WebSocket-Protocol token", %{token: token} do
assert {:ok, %{status: 101, headers: headers}} =
raw_websocket_handshake("?stream=user", [
{"sec-websocket-protocol", "#{token.token}, phoenix"}
])
assert {"sec-websocket-protocol", token.token} in headers
end
test "does not echo an invalid Sec-WebSocket-Protocol token", %{token: token} do
assert {:ok, %{status: 401, headers: headers}} =
raw_websocket_handshake("?stream=user", [
{"sec-websocket-protocol", "invalid"}
])
refute {"sec-websocket-protocol", token.token} in headers
refute List.keymember?(headers, "sec-websocket-protocol", 0)
end
test "prefers sec-websocket-protocol token over query access_token", %{
token: token,
user: user
} do
assert {:ok, state} =
Pleroma.Web.MastodonAPI.WebsocketHandler.connect(%{
params: %{"stream" => "user", "access_token" => "invalid"},
connect_info: %{
sec_websocket_headers: [
{"sec-websocket-version", "13"},
{"sec-websocket-protocol", token.token}
]
}
})
assert state.user.id == user.id
assert state.oauth_token.id == token.id
assert state.topics != []
end
test "accepts valid token on client-sent event", %{token: token} do
assert {:ok, pid} = start_socket()
@ -363,7 +449,7 @@ defmodule Pleroma.Integration.MastodonWebsocketTest do
test "accepts the 'list' stream", %{token: token, user: user} do
posting_user = insert(:user)
{:ok, list} = Pleroma.List.create("test", user)
{:ok, list} = Pleroma.List.create(%{title: "test"}, user)
Pleroma.List.follow(list, posting_user)
assert {:ok, _} = start_socket("?stream=list&access_token=#{token.token}&list=#{list.id}")
@ -430,12 +516,12 @@ defmodule Pleroma.Integration.MastodonWebsocketTest do
assert {:ok, json} = Jason.decode(json["payload"])
view_json =
Pleroma.Web.MastodonAPI.StatusView.render("show.json",
activity: activity,
for: reading_user
atom_key_to_string(
StatusView.render("show.json",
activity: activity,
for: reading_user
)
)
|> Jason.encode!()
|> Jason.decode!()
assert json == view_json
end
@ -458,12 +544,12 @@ defmodule Pleroma.Integration.MastodonWebsocketTest do
activity = Pleroma.Activity.normalize(activity)
view_json =
Pleroma.Web.MastodonAPI.StatusView.render("show.json",
activity: activity,
for: reading_user
atom_key_to_string(
StatusView.render("show.json",
activity: activity,
for: reading_user
)
)
|> Jason.encode!()
|> Jason.decode!()
assert {:ok, %{"event" => "status.update", "payload" => ^view_json}} = decode_json(raw_json)
end

View file

@ -0,0 +1,56 @@
# Pleroma: A lightweight social networking server
# Copyright © 2017-2022 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Language.LanguageDetectorTest do
use Pleroma.DataCase, async: true
alias Pleroma.Language.LanguageDetector
alias Pleroma.Language.LanguageDetectorMock
alias Pleroma.StaticStubbedConfigMock
import Mox
setup do
# Stub the StaticStubbedConfigMock to return our mock for the provider
StaticStubbedConfigMock
|> stub(:get, fn
[Pleroma.Language.LanguageDetector, :provider] -> LanguageDetectorMock
_other -> nil
end)
# Stub the LanguageDetectorMock with default implementations
LanguageDetectorMock
|> stub(:missing_dependencies, fn -> [] end)
|> stub(:configured?, fn -> true end)
:ok
end
test "it detects text language" do
LanguageDetectorMock
|> expect(:detect, fn _text -> "fr" end)
detected_language = LanguageDetector.detect("Je viens d'atterrir en Tchéquie.")
assert detected_language == "fr"
end
test "it returns nil if text is not long enough" do
# No need to set expectations as the word count check happens before the provider is called
detected_language = LanguageDetector.detect("it returns nil")
assert detected_language == nil
end
test "it returns nil if no provider specified" do
# Override the stub to return nil for the provider
StaticStubbedConfigMock
|> expect(:get, fn [Pleroma.Language.LanguageDetector, :provider] -> nil end)
detected_language = LanguageDetector.detect("this should also return nil")
assert detected_language == nil
end
end

View file

@ -0,0 +1,37 @@
# Pleroma: A lightweight social networking server
# Copyright © 2017-2022 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Language.Translation.DeeplTest do
use Pleroma.Web.ConnCase
alias Pleroma.Language.Translation.Deepl
test "it translates text" do
Tesla.Mock.mock_global(fn env -> apply(HttpRequestMock, :request, [env]) end)
clear_config([Pleroma.Language.Translation.Deepl, :base_url], "https://api-free.deepl.com")
clear_config([Pleroma.Language.Translation.Deepl, :api_key], "API_KEY")
{:ok, res} =
Deepl.translate(
"USUNĄĆ ŚLEDZIKA!Wklej to na swojego śledzika. Jeżeli uzbieramy 70% użytkowników nk...to usuną śledzika!!!",
"pl",
"en"
)
assert %{
detected_source_language: "PL",
provider: "DeepL"
} = res
end
test "it returns languages list" do
Tesla.Mock.mock_global(fn env -> apply(HttpRequestMock, :request, [env]) end)
clear_config([Pleroma.Language.Translation.Deepl, :base_url], "https://api-free.deepl.com")
clear_config([Pleroma.Language.Translation.Deepl, :api_key], "API_KEY")
assert {:ok, [language | _languages]} = Deepl.supported_languages(:target)
assert is_binary(language)
end
end

View file

@ -0,0 +1,59 @@
# Pleroma: A lightweight social networking server
# Copyright © 2017-2024 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Language.Translation.TranslateLocallyTest do
use Pleroma.DataCase
alias Pleroma.Language.Translation.TranslateLocally
@example_models %{
"de" => %{
"en" => "de-en-base"
},
"en" => %{
"de" => "en-de-base",
"pl" => "en-pl-tiny"
},
"cs" => %{
"en" => "cs-en-base"
},
"pl" => %{
"en" => "pl-en-tiny"
}
}
test "it returns languages list" do
clear_config([Pleroma.Language.Translation.TranslateLocally, :models], @example_models)
assert {:ok, languages} = TranslateLocally.supported_languages(:source)
assert ["cs", "de", "en", "pl"] = languages |> Enum.sort()
end
describe "it returns languages matrix" do
test "without intermediary language" do
clear_config([Pleroma.Language.Translation.TranslateLocally, :models], @example_models)
assert {:ok,
%{
"cs" => ["en"],
"de" => ["en"],
"en" => ["de", "pl"],
"pl" => ["en"]
}} = TranslateLocally.languages_matrix()
end
test "with intermediary language" do
clear_config([Pleroma.Language.Translation.TranslateLocally, :models], @example_models)
clear_config([Pleroma.Language.Translation.TranslateLocally, :intermediary_language], "en")
assert {:ok,
%{
"cs" => ["de", "en", "pl"],
"de" => ["en", "pl"],
"en" => ["de", "pl"],
"pl" => ["de", "en"]
}} = TranslateLocally.languages_matrix()
end
end
end

View file

@ -0,0 +1,28 @@
defmodule Pleroma.Language.TranslationTest do
use Pleroma.Web.ConnCase
alias Pleroma.Language.Translation
setup do: clear_config([Pleroma.Language.Translation, :provider], TranslationMock)
test "it translates text" do
assert {:ok,
%{
content: "txet emos",
detected_source_language: _,
provider: _
}} = Translation.translate("some text", "en", "uk")
end
test "it stores translation result in cache" do
Translation.translate("some text", "en", "uk")
assert {:ok, result} =
Cachex.get(
:translations_cache,
"en/uk/#{:crypto.hash(:sha256, "some text") |> Base.encode64()}"
)
assert result.content == "txet emos"
end
end

View file

@ -10,22 +10,46 @@ defmodule Pleroma.ListTest do
test "creating a list" do
user = insert(:user)
{:ok, %Pleroma.List{} = list} = Pleroma.List.create("title", user)
%Pleroma.List{title: title} = Pleroma.List.get(list.id, user)
{:ok, %Pleroma.List{} = list} = Pleroma.List.create(%{title: "title"}, user)
%Pleroma.List{title: title, exclusive: exclusive} = Pleroma.List.get(list.id, user)
assert title == "title"
assert exclusive == false
end
test "validates title" do
user = insert(:user)
assert {:error, changeset} = Pleroma.List.create("", user)
assert {:error, changeset} = Pleroma.List.create(%{title: ""}, user)
assert changeset.errors == [title: {"can't be blank", [validation: :required]}]
end
test "creating a list with an emoji" do
user = insert(:user)
{:ok, %Pleroma.List{} = list} = Pleroma.List.create(%{title: "title", emoji: "🕓"}, user)
%Pleroma.List{emoji: emoji} = Pleroma.List.get(list.id, user)
assert emoji == "🕓"
end
test "validates emoji" do
user = insert(:user)
assert {:error, changeset} =
Pleroma.List.create(%{title: "title", emoji: "not an emoji"}, user)
assert changeset.errors == [emoji: {"Invalid emoji", []}]
end
test "updating a list's emoji" do
user = insert(:user)
{:ok, list} = Pleroma.List.create(%{title: "title"}, user)
{:ok, %{emoji: emoji}} = Pleroma.List.update(list, %{emoji: "🕓"})
assert "🕓" == emoji
end
test "getting a list not belonging to the user" do
user = insert(:user)
other_user = insert(:user)
{:ok, %Pleroma.List{} = list} = Pleroma.List.create("title", user)
{:ok, %Pleroma.List{} = list} = Pleroma.List.create(%{title: "title"}, user)
ret = Pleroma.List.get(list.id, other_user)
assert is_nil(ret)
end
@ -33,7 +57,7 @@ defmodule Pleroma.ListTest do
test "adding an user to a list" do
user = insert(:user)
other_user = insert(:user)
{:ok, list} = Pleroma.List.create("title", user)
{:ok, list} = Pleroma.List.create(%{title: "title"}, user)
{:ok, %{following: following}} = Pleroma.List.follow(list, other_user)
assert [other_user.follower_address] == following
end
@ -41,7 +65,7 @@ defmodule Pleroma.ListTest do
test "removing an user from a list" do
user = insert(:user)
other_user = insert(:user)
{:ok, list} = Pleroma.List.create("title", user)
{:ok, list} = Pleroma.List.create(%{title: "title"}, user)
{:ok, %{following: _following}} = Pleroma.List.follow(list, other_user)
{:ok, %{following: following}} = Pleroma.List.unfollow(list, other_user)
assert [] == following
@ -49,14 +73,27 @@ defmodule Pleroma.ListTest do
test "renaming a list" do
user = insert(:user)
{:ok, list} = Pleroma.List.create("title", user)
{:ok, %{title: title}} = Pleroma.List.rename(list, "new")
{:ok, list} = Pleroma.List.create(%{title: "title"}, user)
{:ok, %{title: title}} = Pleroma.List.update(list, %{title: "new"})
assert "new" == title
end
test "updating a list exclusivity" do
user = insert(:user)
{:ok, %{exclusive: exclusive} = list} =
Pleroma.List.create(%{title: "title", exclusive: true}, user)
assert exclusive == true
{:ok, %{exclusive: exclusive} = list} = Pleroma.List.update(list, %{exclusive: false})
assert exclusive == false
{:ok, %{exclusive: exclusive}} = Pleroma.List.update(list, %{exclusive: true})
assert exclusive == true
end
test "deleting a list" do
user = insert(:user)
{:ok, list} = Pleroma.List.create("title", user)
{:ok, list} = Pleroma.List.create(%{title: "title"}, user)
{:ok, list} = Pleroma.List.delete(list)
assert is_nil(Repo.get(Pleroma.List, list.id))
end
@ -65,7 +102,7 @@ defmodule Pleroma.ListTest do
user = insert(:user)
other_user = insert(:user)
third_user = insert(:user)
{:ok, list} = Pleroma.List.create("title", user)
{:ok, list} = Pleroma.List.create(%{title: "title"}, user)
{:ok, list} = Pleroma.List.follow(list, other_user)
{:ok, list} = Pleroma.List.follow(list, third_user)
{:ok, following} = Pleroma.List.get_following(list)
@ -76,9 +113,9 @@ defmodule Pleroma.ListTest do
test "getting all lists by an user" do
user = insert(:user)
other_user = insert(:user)
{:ok, list_one} = Pleroma.List.create("title", user)
{:ok, list_two} = Pleroma.List.create("other title", user)
{:ok, list_three} = Pleroma.List.create("third title", other_user)
{:ok, list_one} = Pleroma.List.create(%{title: "title"}, user)
{:ok, list_two} = Pleroma.List.create(%{title: "other title"}, user)
{:ok, list_three} = Pleroma.List.create(%{title: "third title"}, other_user)
lists = Pleroma.List.for_user(user, %{})
assert list_one in lists
assert list_two in lists
@ -88,9 +125,9 @@ defmodule Pleroma.ListTest do
test "getting all lists the user is a member of" do
user = insert(:user)
other_user = insert(:user)
{:ok, list_one} = Pleroma.List.create("title", user)
{:ok, list_two} = Pleroma.List.create("other title", user)
{:ok, list_three} = Pleroma.List.create("third title", other_user)
{:ok, list_one} = Pleroma.List.create(%{title: "title"}, user)
{:ok, list_two} = Pleroma.List.create(%{title: "other title"}, user)
{:ok, list_three} = Pleroma.List.create(%{title: "third title"}, other_user)
{:ok, list_one} = Pleroma.List.follow(list_one, other_user)
{:ok, list_two} = Pleroma.List.follow(list_two, other_user)
{:ok, list_three} = Pleroma.List.follow(list_three, user)
@ -106,8 +143,8 @@ defmodule Pleroma.ListTest do
not_owner = insert(:user)
member_1 = insert(:user)
member_2 = insert(:user)
{:ok, owned_list} = Pleroma.List.create("owned", owner)
{:ok, not_owned_list} = Pleroma.List.create("not owned", not_owner)
{:ok, owned_list} = Pleroma.List.create(%{title: "owned"}, owner)
{:ok, not_owned_list} = Pleroma.List.create(%{title: "not owned"}, not_owner)
{:ok, owned_list} = Pleroma.List.follow(owned_list, member_1)
{:ok, owned_list} = Pleroma.List.follow(owned_list, member_2)
{:ok, not_owned_list} = Pleroma.List.follow(not_owned_list, member_1)
@ -123,14 +160,14 @@ defmodule Pleroma.ListTest do
test "get by ap_id" do
user = insert(:user)
{:ok, list} = Pleroma.List.create("foo", user)
{:ok, list} = Pleroma.List.create(%{title: "foo"}, user)
assert Pleroma.List.get_by_ap_id(list.ap_id) == list
end
test "memberships" do
user = insert(:user)
member = insert(:user)
{:ok, list} = Pleroma.List.create("foo", user)
{:ok, list} = Pleroma.List.create(%{title: "foo"}, user)
{:ok, list} = Pleroma.List.follow(list, member)
assert Pleroma.List.memberships(member) == [list.ap_id]
@ -140,7 +177,7 @@ defmodule Pleroma.ListTest do
user = insert(:user)
member = insert(:user)
{:ok, list} = Pleroma.List.create("foo", user)
{:ok, list} = Pleroma.List.create(%{title: "foo"}, user)
{:ok, list} = Pleroma.List.follow(list, member)
assert Pleroma.List.member?(list, member)

View file

@ -36,11 +36,12 @@ defmodule Pleroma.MarkerTest do
insert(:notification, user: user, activity: insert(:note_activity))
insert(:notification, user: user, activity: insert(:note_activity))
insert(:marker, timeline: "home", user: user)
%Marker{} = refreshed_marker = refresh_record(marker)
assert Marker.get_markers(
user,
["notifications"]
) == [%Marker{refresh_record(marker) | unread_count: 2}]
) == [%{refreshed_marker | unread_count: 2}]
end
end

View file

@ -308,4 +308,37 @@ defmodule Pleroma.ModerationLogTest do
assert log.data["message"] == "@#{moderator.nickname} deleted status ##{note.id}"
end
end
describe "get_log_entry_message/1" do
setup do
moderator = insert(:user, is_moderator: true)
[moderator: moderator]
end
test "handles unknown action types gracefully", %{moderator: moderator} do
log_entry = %ModerationLog{
data: %{
"actor" => %{"nickname" => moderator.nickname},
"action" => "unknown_action",
"some_data" => "test_value"
}
}
assert ModerationLog.get_log_entry_message(log_entry) =~ moderator.nickname
assert ModerationLog.get_log_entry_message(log_entry) =~ "unknown_action"
end
test "handles malformed log entries gracefully" do
log_entry = %ModerationLog{
data: %{
"action" => "force_password_reset"
# Missing "actor" and "subject" fields
}
}
message = ModerationLog.get_log_entry_message(log_entry)
assert is_binary(message)
assert message =~ "force_password_reset"
end
end
end

View file

@ -17,9 +17,10 @@ defmodule Pleroma.NotificationTest do
alias Pleroma.Web.ActivityPub.Transmogrifier
alias Pleroma.Web.CommonAPI
alias Pleroma.Web.MastodonAPI.NotificationView
alias Pleroma.Web.Streamer
setup do
Mox.stub_with(Pleroma.UnstubbedConfigMock, Pleroma.Config)
Mox.stub_with(Pleroma.UnstubbedConfigMock, Pleroma.Test.StaticConfig)
:ok
end
@ -446,8 +447,7 @@ defmodule Pleroma.NotificationTest do
describe "set_read_up_to()" do
test "it sets all notifications as read up to a specified notification ID" do
user = insert(:user)
other_user = insert(:user)
[user, other_user] = insert_pair(:user)
{:ok, _activity} =
CommonAPI.post(user, %{
@ -486,6 +486,37 @@ defmodule Pleroma.NotificationTest do
assert m.last_read_id == to_string(n2.id)
end
@tag needs_streamer: true
test "it sends updated marker to the 'user' and the 'user:notification' stream" do
%{user: user, token: oauth_token} = oauth_access(["read"])
other_user = insert(:user)
{:ok, _activity} =
CommonAPI.post(other_user, %{
status: "hi @#{user.nickname}!"
})
[%{id: notification_id}] = Notification.for_user(user)
notification_id = to_string(notification_id)
task =
Task.async(fn ->
{:ok, _topic} =
Streamer.get_topic_and_add_socket("user:notification", user, oauth_token)
assert_receive {:text, event}, 4_000
assert %{"event" => "marker", "payload" => payload} = Jason.decode!(event)
assert %{"notifications" => %{"last_read_id" => ^notification_id}} =
Jason.decode!(payload)
end)
Notification.set_read_up_to(user, notification_id)
Task.await(task)
end
end
describe "for_user_since/2" do

View file

@ -27,6 +27,11 @@ defmodule Pleroma.Object.ContainmentTest do
})
end
test "rejects an empty actor list without crashing" do
assert :error ==
Containment.contain_origin("https://example.com/objects/1", %{"actor" => []})
end
test "contain_origin_from_id() catches obvious spoofing attempts" do
data = %{
"id" => "http://example.com/~alyssa/activities/1234.json"

View file

@ -6,7 +6,6 @@ defmodule Pleroma.Object.FetcherTest do
use Pleroma.DataCase
alias Pleroma.Activity
alias Pleroma.Instances
alias Pleroma.Object
alias Pleroma.Object.Fetcher
alias Pleroma.Web.ActivityPub.ObjectValidator
@ -166,15 +165,89 @@ defmodule Pleroma.Object.FetcherTest do
)
end
test "it resets instance reachability on successful fetch" do
id = "http://mastodon.example.org/@admin/99541947525187367"
Instances.set_consistently_unreachable(id)
refute Instances.reachable?(id)
test "it does not fetch from local instance" do
local_url = Pleroma.Web.Endpoint.url() <> "/objects/local_resource"
{:ok, _object} =
Fetcher.fetch_object_from_id("http://mastodon.example.org/@admin/99541947525187367")
assert {:fetch, {:error, "Trying to fetch local resource"}} =
Fetcher.fetch_object_from_id(local_url)
end
assert Instances.reachable?(id)
test "it validates content-type headers according to ActivityPub spec" do
# Setup a mock for an object with invalid content-type
mock(fn
%{method: :get, url: "https://example.com/objects/invalid-content-type"} ->
%Tesla.Env{
status: 200,
# Not a valid AP content-type
headers: [{"content-type", "application/json"}],
body:
Jason.encode!(%{
"id" => "https://example.com/objects/invalid-content-type",
"type" => "Note",
"content" => "This has an invalid content type",
"actor" => "https://example.com/users/actor",
"attributedTo" => "https://example.com/users/actor"
})
}
end)
assert {:fetch, {:error, {:content_type, "application/json"}}} =
Fetcher.fetch_object_from_id("https://example.com/objects/invalid-content-type")
end
test "it accepts objects with application/ld+json and ActivityStreams profile" do
# Setup a mock for an object with ld+json content-type and AS profile
mock(fn
%{method: :get, url: "https://example.com/objects/valid-ld-json"} ->
%Tesla.Env{
status: 200,
headers: [
{"content-type",
"application/ld+json; profile=\"https://www.w3.org/ns/activitystreams\""}
],
body:
Jason.encode!(%{
"id" => "https://example.com/objects/valid-ld-json",
"type" => "Note",
"content" => "This has a valid ld+json content type",
"actor" => "https://example.com/users/actor",
"attributedTo" => "https://example.com/users/actor"
})
}
end)
# This should pass if content-type validation works correctly
assert {:ok, object} =
Fetcher.fetch_and_contain_remote_object_from_id(
"https://example.com/objects/valid-ld-json"
)
assert object["content"] == "This has a valid ld+json content type"
end
test "it rejects objects with no content-type header" do
# Setup a mock for an object with no content-type header
mock(fn
%{method: :get, url: "https://example.com/objects/no-content-type"} ->
%Tesla.Env{
status: 200,
# No content-type header
headers: [],
body:
Jason.encode!(%{
"id" => "https://example.com/objects/no-content-type",
"type" => "Note",
"content" => "This has no content type header",
"actor" => "https://example.com/users/actor",
"attributedTo" => "https://example.com/users/actor"
})
}
end)
# We want to test that the request fails with a missing content-type error
# but the actual error is {:fetch, {:error, nil}} - we'll check for this format
result = Fetcher.fetch_object_from_id("https://example.com/objects/no-content-type")
assert {:fetch, {:error, nil}} = result
end
end
@ -534,6 +607,110 @@ defmodule Pleroma.Object.FetcherTest do
end
end
describe "cross-domain redirect handling" do
setup do
mock(fn
# Cross-domain redirect with original domain in id
%{method: :get, url: "https://original.test/objects/123"} ->
%Tesla.Env{
status: 200,
url: "https://media.test/objects/123",
headers: [{"content-type", "application/activity+json"}],
body:
Jason.encode!(%{
"id" => "https://original.test/objects/123",
"type" => "Note",
"content" => "This is redirected content",
"actor" => "https://original.test/users/actor",
"attributedTo" => "https://original.test/users/actor"
})
}
# Cross-domain redirect with final domain in id
%{method: :get, url: "https://original.test/objects/final-domain-id"} ->
%Tesla.Env{
status: 200,
url: "https://media.test/objects/final-domain-id",
headers: [{"content-type", "application/activity+json"}],
body:
Jason.encode!(%{
"id" => "https://media.test/objects/final-domain-id",
"type" => "Note",
"content" => "This has final domain in id",
"actor" => "https://original.test/users/actor",
"attributedTo" => "https://original.test/users/actor"
})
}
# No redirect - same domain
%{method: :get, url: "https://original.test/objects/same-domain-redirect"} ->
%Tesla.Env{
status: 200,
url: "https://original.test/objects/different-path",
headers: [{"content-type", "application/activity+json"}],
body:
Jason.encode!(%{
"id" => "https://original.test/objects/same-domain-redirect",
"type" => "Note",
"content" => "This has a same-domain redirect",
"actor" => "https://original.test/users/actor",
"attributedTo" => "https://original.test/users/actor"
})
}
# Test case with missing url field in response (common in tests)
%{method: :get, url: "https://original.test/objects/missing-url"} ->
%Tesla.Env{
status: 200,
# No url field
headers: [{"content-type", "application/activity+json"}],
body:
Jason.encode!(%{
"id" => "https://original.test/objects/missing-url",
"type" => "Note",
"content" => "This has no URL field in response",
"actor" => "https://original.test/users/actor",
"attributedTo" => "https://original.test/users/actor"
})
}
end)
:ok
end
test "it rejects objects from cross-domain redirects with original domain in id" do
assert {:error, {:cross_domain_redirect, true}} =
Fetcher.fetch_and_contain_remote_object_from_id(
"https://original.test/objects/123"
)
end
test "it rejects objects from cross-domain redirects with final domain in id" do
assert {:error, {:cross_domain_redirect, true}} =
Fetcher.fetch_and_contain_remote_object_from_id(
"https://original.test/objects/final-domain-id"
)
end
test "it accepts objects with same-domain redirects" do
assert {:ok, data} =
Fetcher.fetch_and_contain_remote_object_from_id(
"https://original.test/objects/same-domain-redirect"
)
assert data["content"] == "This has a same-domain redirect"
end
test "it handles responses without URL field (common in tests)" do
assert {:ok, data} =
Fetcher.fetch_and_contain_remote_object_from_id(
"https://original.test/objects/missing-url"
)
assert data["content"] == "This has no URL field in response"
end
end
describe "fetch with history" do
setup do
object2 = %{

View file

@ -156,7 +156,7 @@ defmodule Pleroma.ObjectTest do
uploads_dir = Pleroma.Config.get!([Pleroma.Uploaders.Local, :uploads])
File.mkdir_p!(uploads_dir)
Pleroma.Backports.mkdir_p!(uploads_dir)
file = %Plug.Upload{
content_type: "image/jpeg",

View file

@ -1,42 +0,0 @@
# Pleroma: A lightweight social networking server
# Copyright © 2017-2022 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.OTPVersionTest do
use ExUnit.Case, async: true
alias Pleroma.OTPVersion
describe "check/1" do
test "22.4" do
assert OTPVersion.get_version_from_files(["test/fixtures/warnings/otp_version/22.4"]) ==
"22.4"
end
test "22.1" do
assert OTPVersion.get_version_from_files(["test/fixtures/warnings/otp_version/22.1"]) ==
"22.1"
end
test "21.1" do
assert OTPVersion.get_version_from_files(["test/fixtures/warnings/otp_version/21.1"]) ==
"21.1"
end
test "23.0" do
assert OTPVersion.get_version_from_files(["test/fixtures/warnings/otp_version/23.0"]) ==
"23.0"
end
test "with nonexistent file" do
assert OTPVersion.get_version_from_files([
"test/fixtures/warnings/otp_version/non-exising",
"test/fixtures/warnings/otp_version/22.4"
]) == "22.4"
end
test "empty paths" do
assert OTPVersion.get_version_from_files([]) == nil
end
end
end

View file

@ -0,0 +1,293 @@
# Pleroma: A lightweight social networking server
# Copyright © 2017-2022 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.PleromaCtlTest do
use ExUnit.Case, async: false
@pleroma_ctl Path.expand("../../rel/files/bin/pleroma_ctl", __DIR__)
setup do
tmp_dir =
Path.join(System.tmp_dir!(), "pleroma_ctl_test_#{System.unique_integer([:positive])}")
release_root = Path.join(tmp_dir, "release")
bin_dir = Path.join(release_root, "bin")
File.mkdir_p!(bin_dir)
File.cp!(@pleroma_ctl, Path.join(bin_dir, "pleroma_ctl"))
File.chmod!(Path.join(bin_dir, "pleroma_ctl"), 0o755)
on_exit(fn -> File.rm_rf!(tmp_dir) end)
{:ok, tmp_dir: tmp_dir, release_root: release_root, bin_dir: bin_dir}
end
test "update downloads branch-scoped latest OTP package", %{
tmp_dir: tmp_dir,
bin_dir: bin_dir,
release_root: release_root
} do
stubs_dir = Path.join(tmp_dir, "stubs")
curl_args_path = Path.join(tmp_dir, "curl.args")
File.mkdir_p!(stubs_dir)
write_update_stubs(stubs_dir, curl_args_path, "unused", "glibc")
update_tmp_dir = Path.join(tmp_dir, "update_tmp")
File.mkdir_p!(update_tmp_dir)
{output, status} =
System.cmd(
Path.join(bin_dir, "pleroma_ctl"),
[
"update",
"--branch",
"develop",
"--flavour",
"amd64",
"--tmp-dir",
update_tmp_dir,
"--no-rm"
],
env: [{"PATH", stubs_dir <> ":" <> System.get_env("PATH", "")}],
stderr_to_stdout: true
)
assert status == 0, output
assert File.exists?(Path.join(release_root, "bin/marker"))
assert ["-fL", url, "-o", artifact_path] =
curl_args_path
|> File.read!()
|> String.split("\n", trim: true)
assert url ==
"https://git.pleroma.social/api/packages/pleroma/generic/pleroma-otp-develop-amd64/latest/pleroma.zip"
assert artifact_path == Path.join(update_tmp_dir, "pleroma.zip")
end
test "update detects stable branch and local flavour", %{
tmp_dir: tmp_dir,
bin_dir: bin_dir,
release_root: release_root
} do
stubs_dir = Path.join(tmp_dir, "stubs")
curl_args_path = Path.join(tmp_dir, "curl.args")
File.mkdir_p!(stubs_dir)
write_update_stubs(stubs_dir, curl_args_path, "x86_64", "glibc")
write_start_erl_data(release_root, "2.10.0")
{output, status} =
System.cmd(
Path.join(bin_dir, "pleroma_ctl"),
["update", "--tmp-dir", create_update_tmp_dir(tmp_dir), "--no-rm"],
env: [{"PATH", stubs_dir <> ":" <> System.get_env("PATH", "")}],
stderr_to_stdout: true
)
assert status == 0, output
assert curl_url(curl_args_path) ==
"https://git.pleroma.social/api/packages/pleroma/generic/pleroma-otp-stable-amd64/latest/pleroma.zip"
end
test "update detects develop branch and musl arm flavour", %{
tmp_dir: tmp_dir,
bin_dir: bin_dir,
release_root: release_root
} do
stubs_dir = Path.join(tmp_dir, "stubs")
curl_args_path = Path.join(tmp_dir, "curl.args")
File.mkdir_p!(stubs_dir)
write_update_stubs(stubs_dir, curl_args_path, "armv7l", "musl")
write_start_erl_data(release_root, "2.10.0.develop")
{output, status} =
System.cmd(
Path.join(bin_dir, "pleroma_ctl"),
["update", "--tmp-dir", create_update_tmp_dir(tmp_dir), "--no-rm"],
env: [{"PATH", stubs_dir <> ":" <> System.get_env("PATH", "")}],
stderr_to_stdout: true
)
assert status == 0, output
assert curl_url(curl_args_path) ==
"https://git.pleroma.social/api/packages/pleroma/generic/pleroma-otp-develop-arm-musl/latest/pleroma.zip"
end
test "update with zip URL bypasses branch and flavour detection", %{
tmp_dir: tmp_dir,
bin_dir: bin_dir,
release_root: release_root
} do
stubs_dir = Path.join(tmp_dir, "stubs")
curl_args_path = Path.join(tmp_dir, "curl.args")
File.mkdir_p!(stubs_dir)
write_update_stubs(stubs_dir, curl_args_path, "unsupported-arch", "unsupported-libc")
write_start_erl_data(release_root, "2.10.0.custombranch")
custom_url = "https://example.test/custom.zip"
{output, status} =
System.cmd(
Path.join(bin_dir, "pleroma_ctl"),
[
"update",
"--zip-url",
custom_url,
"--tmp-dir",
create_update_tmp_dir(tmp_dir),
"--no-rm"
],
env: [{"PATH", stubs_dir <> ":" <> System.get_env("PATH", "")}],
stderr_to_stdout: true
)
assert status == 0, output
assert curl_url(curl_args_path) == custom_url
end
test "passes arguments with spaces and Elixir string metacharacters", %{
tmp_dir: tmp_dir,
bin_dir: bin_dir
} do
capture_path = Path.join(tmp_dir, "captured_args")
eval_path = Path.join(tmp_dir, "pleroma_ctl_eval.exs")
write_executable(Path.join(bin_dir, "pleroma"), """
#!/bin/sh
{
printf '%s\n' 'defmodule Pleroma.ReleaseTasks do'
printf '%s\n' ' def run(args), do: File.write!(System.fetch_env!("PLEROMA_CTL_CAPTURE"), :erlang.term_to_binary(args))'
printf '%s\n' 'end'
printf '%s\n' "$2"
} > "$PLEROMA_CTL_EVAL_FILE"
exec elixir "$PLEROMA_CTL_EVAL_FILE"
""")
{output, status} =
System.cmd(
Path.join(bin_dir, "pleroma_ctl"),
[
"user",
"",
"has space",
~s(has "quote"),
~s(has \\ backslash),
~S(#{:not_interpolated})
],
env: [
{"PLEROMA_CTL_CAPTURE", capture_path},
{"PLEROMA_CTL_EVAL_FILE", eval_path}
],
stderr_to_stdout: true
)
assert status == 0, output
assert capture_path
|> File.read!()
|> :erlang.binary_to_term() == [
"user",
"",
"has space",
~s(has "quote"),
~s(has \\ backslash),
~S(#{:not_interpolated})
]
end
defp write_executable(path, contents) do
File.write!(path, contents)
File.chmod!(path, 0o755)
end
defp write_start_erl_data(release_root, version) do
releases_dir = Path.join(release_root, "releases")
File.mkdir_p!(releases_dir)
File.write!(Path.join(releases_dir, "start_erl.data"), "erts-15.0 #{version}\n")
end
defp create_update_tmp_dir(tmp_dir) do
update_tmp_dir = Path.join(tmp_dir, "update_tmp")
File.mkdir_p!(update_tmp_dir)
update_tmp_dir
end
defp write_update_stubs(stubs_dir, curl_args_path, arch, libc) do
write_executable(Path.join(stubs_dir, "curl"), """
#!/bin/sh
printf '%s\n' "$@" > "#{curl_args_path}"
while [ $# -gt 0 ]; do
case "$1" in
-o)
artifact="$2"
shift 2
;;
*)
shift
;;
esac
done
: > "$artifact"
""")
write_executable(Path.join(stubs_dir, "unzip"), """
#!/bin/sh
while [ $# -gt 0 ]; do
case "$1" in
-d)
dest="$2"
shift 2
;;
*)
shift
;;
esac
done
mkdir -p "$dest/release/bin"
printf 'marker' > "$dest/release/bin/marker"
""")
write_executable(Path.join(stubs_dir, "uname"), """
#!/bin/sh
printf '%s\n' '#{arch}'
""")
write_executable(Path.join(stubs_dir, "getconf"), getconf_stub(libc))
write_executable(Path.join(stubs_dir, "ldd"), """
#!/bin/sh
printf '%s\n' 'musl libc (mock)'
""")
end
defp getconf_stub("glibc") do
"""
#!/bin/sh
printf '%s\n' 'glibc 2.40'
"""
end
defp getconf_stub(_libc) do
"""
#!/bin/sh
exit 1
"""
end
defp curl_url(curl_args_path) do
["-fL", url, "-o", _artifact_path] =
curl_args_path
|> File.read!()
|> String.split("\n", trim: true)
url
end
end

View file

@ -3,12 +3,11 @@
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Repo.Migrations.AutolinkerToLinkifyTest do
use Pleroma.DataCase
use Pleroma.DataCase, async: false
import Pleroma.Factory
import Pleroma.Tests.Helpers
alias Pleroma.ConfigDB
setup do: clear_config(Pleroma.Formatter)
setup_all do: require_migration("20200716195806_autolinker_to_linkify")
test "change/0 converts auto_linker opts for Pleroma.Formatter", %{migration: migration} do

View file

@ -24,7 +24,8 @@ defmodule Pleroma.RepoTest do
describe "get_assoc/2" do
test "get assoc from preloaded data" do
user = %User{name: "Agent Smith"}
token = %Pleroma.Web.OAuth.Token{insert(:oauth_token) | user: user}
%Pleroma.Web.OAuth.Token{} = token = insert(:oauth_token)
token = %{token | user: user}
assert Repo.get_assoc(token, :user) == {:ok, user}
end

View file

@ -0,0 +1,22 @@
# Pleroma: A lightweight social networking server
# Copyright © 2017-2022 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.ReverseProxy.Client.TeslaTest do
use ExUnit.Case
import Mox
alias Pleroma.ReverseProxy.Client.Tesla
setup :verify_on_exit!
test "cancels an unfinished stream before releasing its connection" do
conn = self()
stream = [make_ref(), make_ref()]
expect(Pleroma.GunMock, :cancel, fn ^conn, ^stream -> :ok end)
assert :ok = Tesla.close(%{pid: conn, stream: stream})
end
end

View file

@ -63,7 +63,11 @@ defmodule Pleroma.ReverseProxyTest do
|> Plug.Conn.put_req_header("user-agent", "fake/1.0")
|> ReverseProxy.call("/user-agent")
assert json_response(conn, 200) == %{"user-agent" => Pleroma.Application.user_agent()}
# Convert the response to a map without relying on json_response
body = conn.resp_body
assert conn.status == 200
response = Jason.decode!(body)
assert response == %{"user-agent" => Pleroma.Application.user_agent()}
end
test "closed connection", %{conn: conn} do
@ -111,6 +115,7 @@ defmodule Pleroma.ReverseProxyTest do
describe "max_body" do
test "length returns error if content-length more than option", %{conn: conn} do
request_mock(0)
expect(ClientMock, :close, fn _ -> :ok end)
assert capture_log(fn ->
ReverseProxy.call(conn, "/huge-file", max_body_length: 4)
@ -124,25 +129,45 @@ defmodule Pleroma.ReverseProxyTest do
end) == ""
end
test "closes streamed responses with invalid status", %{conn: conn} do
ClientMock
|> expect(:request, fn :get, "/invalid-status", _, _, _ ->
{:ok, 404, [], %{url: "/invalid-status"}}
end)
|> expect(:close, fn %{url: "/invalid-status"} -> :ok end)
ReverseProxy.call(conn, "/invalid-status")
end
test "max_body_length returns error if streaming body more than that option", %{conn: conn} do
stream_mock(3, true)
assert capture_log(fn ->
ReverseProxy.call(conn, "/stream-bytes/50", max_body_length: 30)
ReverseProxy.call(conn, "/stream-bytes/50", max_body_length: 29)
end) =~
"Elixir.Pleroma.ReverseProxy request to /stream-bytes/50 failed while reading/chunking: :body_too_large"
end
test "max_body_length accepts a body exactly at the limit", %{conn: conn} do
stream_mock(4)
conn = ReverseProxy.call(conn, "/stream-bytes/30", max_body_length: 30)
assert byte_size(conn.resp_body) == 30
end
end
describe "HEAD requests" do
test "common", %{conn: conn} do
ClientMock
|> expect(:request, fn :head, "/head", _, _, _ ->
{:ok, 200, [{"content-type", "text/html; charset=utf-8"}]}
{:ok, 200, [{"content-type", "image/png"}]}
end)
conn = ReverseProxy.call(Map.put(conn, :method, "HEAD"), "/head")
assert html_response(conn, 200) == ""
assert conn.status == 200
assert Conn.get_resp_header(conn, "content-type") == ["image/png"]
assert conn.resp_body == ""
end
end
@ -193,7 +218,10 @@ defmodule Pleroma.ReverseProxyTest do
test "204", %{conn: conn} do
url = "/status/204"
expect(ClientMock, :request, fn :get, _url, _, _, _ -> {:ok, 204, [], %{}} end)
ClientMock
|> expect(:request, fn :get, _url, _, _, _ -> {:ok, 204, [], %{}} end)
|> expect(:close, fn %{} -> :ok end)
capture_log(fn ->
conn = ReverseProxy.call(conn, url)
@ -215,6 +243,56 @@ defmodule Pleroma.ReverseProxyTest do
assert Conn.get_resp_header(conn, "content-type") == ["application/octet-stream"]
end
test "sniffs and preserves an image body with a generic content type", %{conn: conn} do
body = File.read!("test/fixtures/image.jpg")
ClientMock
|> expect(:request, fn :get, "/extensionless", headers, _, _ ->
assert {"accept-encoding", "identity"} in headers
{:ok, 200, [{"content-type", "application/octet-stream"}], %{body: body}}
end)
|> expect(:stream_body, fn %{body: ^body} = client ->
{:ok, body, Map.delete(client, :body)}
end)
|> expect(:stream_body, fn %{} -> :done end)
conn =
ReverseProxy.call(conn, "/extensionless",
sniff_content_type: true,
max_read_duration: :infinity,
req_headers: [{"accept-encoding", "gzip"}]
)
assert conn.resp_body == body
assert Conn.get_resp_header(conn, "content-type") == ["image/jpeg"]
assert Conn.get_resp_header(conn, "content-disposition") == [
"inline; filename=\"inline.jpg\""
]
end
test "preserves a generic content type for a non-image body", %{conn: conn} do
body = "not an image"
ClientMock
|> expect(:request, fn :get, "/extensionless", _, _, _ ->
{:ok, 200, [], %{body: body}}
end)
|> expect(:stream_body, fn %{body: ^body} = client ->
{:ok, body, Map.delete(client, :body)}
end)
|> expect(:stream_body, fn %{} -> :done end)
conn = ReverseProxy.call(conn, "/extensionless", sniff_content_type: true)
assert conn.resp_body == body
assert Conn.get_resp_header(conn, "content-type") == ["application/octet-stream"]
assert Conn.get_resp_header(conn, "content-disposition") == [
"attachment; filename=\"extensionless\""
]
end
defp headers_mock(_) do
ClientMock
|> expect(:request, fn :get, "/headers", headers, _, _ ->
@ -249,7 +327,10 @@ defmodule Pleroma.ReverseProxyTest do
)
|> ReverseProxy.call("/headers")
%{"headers" => headers} = json_response(conn, 200)
body = conn.resp_body
assert conn.status == 200
response = Jason.decode!(body)
headers = response["headers"]
assert headers["Accept"] == "text/html"
end
@ -262,7 +343,10 @@ defmodule Pleroma.ReverseProxyTest do
)
|> ReverseProxy.call("/headers")
%{"headers" => headers} = json_response(conn, 200)
body = conn.resp_body
assert conn.status == 200
response = Jason.decode!(body)
headers = response["headers"]
refute headers["Accept-Language"]
end
end
@ -281,7 +365,7 @@ defmodule Pleroma.ReverseProxyTest do
|> expect(:stream_body, fn _ -> :done end)
conn = ReverseProxy.call(conn, "/cache")
assert {"cache-control", "public, max-age=1209600"} in conn.resp_headers
assert {"cache-control", "public, max-age=1209600, immutable"} in conn.resp_headers
end
end
@ -315,6 +399,24 @@ defmodule Pleroma.ReverseProxyTest do
conn = ReverseProxy.call(conn, "/disposition")
assert {"content-type", "image/gif"} in conn.resp_headers
assert {"content-disposition", "inline; filename=\"inline.gif\""} in conn.resp_headers
end
test "forces inline for inline content types overriding upstream attachment", %{
conn: conn
} do
disposition_headers_mock([
{"content-type", "image/png"},
{"content-disposition", "attachment; filename=\"filename.png\""},
{"content-disposition", "attachment; filename=\"duplicate.png\""},
{"content-length", "0"}
])
conn = ReverseProxy.call(conn, "/disposition")
[disposition] = Conn.get_resp_header(conn, "content-disposition")
assert String.starts_with?(disposition, "inline")
refute String.starts_with?(disposition, "attachment")
end
test "with content-disposition header", %{conn: conn} do
@ -327,5 +429,166 @@ defmodule Pleroma.ReverseProxyTest do
assert {"content-disposition", "attachment; filename=\"filename.jpg\""} in conn.resp_headers
end
test "with inline_content_types: true leaves upstream headers untouched", %{
conn: conn
} do
# opt == true: the proxy must not synthesise or rewrite content-disposition.
disposition_headers_mock([
{"content-type", "image/png"},
{"content-disposition", "attachment; filename=\"upstream.png\""},
{"content-length", "0"}
])
conn = ReverseProxy.call(conn, "/disposition", inline_content_types: true)
assert {"content-disposition", "attachment; filename=\"upstream.png\""} in conn.resp_headers
end
test "forces bare inline for a whitelisted type with no MIME extension", %{
conn: conn
} do
# image/x-foo-bar has no entry in MIME's database, so inline_filename/1
# returns nil and the disposition should be the bare token "inline".
disposition_headers_mock([
{"content-type", "image/x-foo-bar"},
{"content-length", "0"}
])
conn = ReverseProxy.call(conn, "/disposition", inline_content_types: ["image/x-foo-bar"])
[disposition] = Conn.get_resp_header(conn, "content-disposition")
assert disposition == "inline"
end
test "serves modern browser image types inline", %{conn: conn} do
disposition_headers_mock([
{"content-type", "image/webp"},
{"content-length", "0"}
])
conn = ReverseProxy.call(conn, "/disposition")
assert Conn.get_resp_header(conn, "content-disposition") == [
"inline; filename=\"inline.webp\""
]
end
end
describe "content-type sanitisation" do
test "preserves allowed image type", %{conn: conn} do
ClientMock
|> expect(:request, fn :get, "/content", _, _, _ ->
{:ok, 200, [{"content-type", "image/png"}], %{url: "/content"}}
end)
|> expect(:stream_body, fn _ -> :done end)
conn = ReverseProxy.call(conn, "/content")
assert conn.status == 200
assert Conn.get_resp_header(conn, "content-type") == ["image/png"]
end
test "preserves allowed video type", %{conn: conn} do
ClientMock
|> expect(:request, fn :get, "/content", _, _, _ ->
{:ok, 200, [{"content-type", "video/mp4"}], %{url: "/content"}}
end)
|> expect(:stream_body, fn _ -> :done end)
conn = ReverseProxy.call(conn, "/content")
assert conn.status == 200
assert Conn.get_resp_header(conn, "content-type") == ["video/mp4"]
end
test "sanitizes ActivityPub content type", %{conn: conn} do
ClientMock
|> expect(:request, fn :get, "/content", _, _, _ ->
{:ok, 200, [{"content-type", "application/activity+json"}], %{url: "/content"}}
end)
|> expect(:stream_body, fn _ -> :done end)
conn = ReverseProxy.call(conn, "/content")
assert conn.status == 200
assert Conn.get_resp_header(conn, "content-type") == ["application/octet-stream"]
end
test "sanitizes LD-JSON content type", %{conn: conn} do
ClientMock
|> expect(:request, fn :get, "/content", _, _, _ ->
{:ok, 200, [{"content-type", "application/ld+json"}], %{url: "/content"}}
end)
|> expect(:stream_body, fn _ -> :done end)
conn = ReverseProxy.call(conn, "/content")
assert conn.status == 200
assert Conn.get_resp_header(conn, "content-type") == ["application/octet-stream"]
end
end
# Hackney is used for Reverse Proxy when Hackney or Finch is the Tesla Adapter
# Gun is able to proxy through Tesla, so it does not need testing as the
# test cases in the Pleroma.HTTPTest module are sufficient
describe "Hackney URL encoding:" do
setup do
ClientMock
|> expect(:request, fn
:get,
"https://example.com/emoji/Pack%201/koronebless.png?foo=bar+baz",
_headers,
_body,
_opts ->
{:ok, 200, [{"content-type", "image/png"}], "It works!"}
:get,
"https://example.com/media/foo/bar%20!$&'()*+,;=/:%20@a%20%5Bbaz%5D.mp4",
_headers,
_body,
_opts ->
{:ok, 200, [{"content-type", "video/mp4"}], "Allowed reserved chars."}
:get, "https://example.com/media/unicode%20%F0%9F%99%82%20.gif", _headers, _body, _opts ->
{:ok, 200, [{"content-type", "image/gif"}], "Unicode emoji in path"}
end)
|> stub(:stream_body, fn _ -> :done end)
|> stub(:close, fn _ -> :ok end)
:ok
end
test "properly encodes URLs with spaces", %{conn: conn} do
url_with_space = "https://example.com/emoji/Pack 1/koronebless.png?foo=bar baz"
result = ReverseProxy.call(conn, url_with_space)
assert result.status == 200
end
test "properly encoded URL should not be altered", %{conn: conn} do
properly_encoded_url = "https://example.com/emoji/Pack%201/koronebless.png?foo=bar+baz"
result = ReverseProxy.call(conn, properly_encoded_url)
assert result.status == 200
end
test "properly encodes URLs with allowed reserved characters", %{conn: conn} do
url_with_reserved_chars = "https://example.com/media/foo/bar !$&'()*+,;=/: @a [baz].mp4"
result = ReverseProxy.call(conn, url_with_reserved_chars)
assert result.status == 200
end
test "properly encodes URLs with unicode in path", %{conn: conn} do
url_with_unicode = "https://example.com/media/unicode 🙂 .gif"
result = ReverseProxy.call(conn, url_with_unicode)
assert result.status == 200
end
end
end

View file

@ -0,0 +1,496 @@
defmodule Pleroma.SafeZipTest do
# Not making this async because it creates and deletes files
use ExUnit.Case
alias Pleroma.SafeZip
@fixtures_dir "test/fixtures"
@tmp_dir "test/zip_tmp"
setup do
# Ensure tmp directory exists
Pleroma.Backports.mkdir_p!(@tmp_dir)
on_exit(fn ->
# Clean up any files created during tests
File.rm_rf!(@tmp_dir)
Pleroma.Backports.mkdir_p!(@tmp_dir)
end)
:ok
end
describe "list_dir_file/1" do
test "lists files in a valid zip" do
{:ok, files} = SafeZip.list_dir_file(Path.join(@fixtures_dir, "emojis.zip"))
assert is_list(files)
assert length(files) > 0
end
test "returns an empty list for empty zip" do
{:ok, files} = SafeZip.list_dir_file(Path.join(@fixtures_dir, "empty.zip"))
assert files == []
end
test "returns error for non-existent file" do
assert {:error, _} = SafeZip.list_dir_file(Path.join(@fixtures_dir, "nonexistent.zip"))
end
test "only lists regular files, not directories" do
# Create a zip with both files and directories
zip_path = create_zip_with_directory()
# List files with SafeZip
{:ok, files} = SafeZip.list_dir_file(zip_path)
# Verify only regular files are listed, not directories
assert "file_in_dir/test_file.txt" in files
assert "root_file.txt" in files
# Directory entries should not be included in the list
refute "file_in_dir/" in files
end
end
describe "contains_all_data?/2" do
test "returns true when all files are in the archive" do
# For this test, we'll create our own zip file with known content
# to ensure we can test the contains_all_data? function properly
zip_path = create_zip_with_directory()
archive_data = File.read!(zip_path)
# Check if the archive contains the root file
# Note: The function expects charlists (Erlang strings) in the MapSet
assert SafeZip.contains_all_data?(archive_data, MapSet.new([~c"root_file.txt"]))
end
test "returns false when files are missing" do
archive_path = Path.join(@fixtures_dir, "emojis.zip")
archive_data = File.read!(archive_path)
# Create a MapSet with non-existent files
fset = MapSet.new([~c"nonexistent.txt"])
refute SafeZip.contains_all_data?(archive_data, fset)
end
test "returns false for invalid archive data" do
refute SafeZip.contains_all_data?("invalid data", MapSet.new([~c"file.txt"]))
end
test "only checks for regular files, not directories" do
# Create a zip with both files and directories
zip_path = create_zip_with_directory()
archive_data = File.read!(zip_path)
# Check if the archive contains a directory (should return false)
refute SafeZip.contains_all_data?(archive_data, MapSet.new([~c"file_in_dir/"]))
# For this test, we'll manually check if the file exists in the archive
# by extracting it and verifying it exists
extract_dir = Path.join(@tmp_dir, "extract_check")
Pleroma.Backports.mkdir_p!(extract_dir)
{:ok, files} = SafeZip.unzip_file(zip_path, extract_dir)
# Verify the root file was extracted
assert Enum.any?(files, fn file ->
Path.basename(file) == "root_file.txt"
end)
# Verify the file exists on disk
assert File.exists?(Path.join(extract_dir, "root_file.txt"))
end
end
describe "zip/4" do
test "creates a zip file on disk" do
# Create a test file
test_file_path = Path.join(@tmp_dir, "test_file.txt")
File.write!(test_file_path, "test content")
# Create a zip file
zip_path = Path.join(@tmp_dir, "test.zip")
assert {:ok, ^zip_path} = SafeZip.zip(zip_path, ["test_file.txt"], @tmp_dir, false)
# Verify the zip file exists
assert File.exists?(zip_path)
end
test "creates a zip file in memory" do
# Create a test file
test_file_path = Path.join(@tmp_dir, "test_file.txt")
File.write!(test_file_path, "test content")
# Create a zip file in memory
zip_name = Path.join(@tmp_dir, "test.zip")
assert {:ok, {^zip_name, zip_data}} =
SafeZip.zip(zip_name, ["test_file.txt"], @tmp_dir, true)
# Verify the zip data is binary
assert is_binary(zip_data)
end
test "returns error for unsafe paths" do
# Try to zip a file with path traversal
assert {:error, _} =
SafeZip.zip(
Path.join(@tmp_dir, "test.zip"),
["../fixtures/test.txt"],
@tmp_dir,
false
)
end
test "can create zip with directories" do
# Create a directory structure
dir_path = Path.join(@tmp_dir, "test_dir")
Pleroma.Backports.mkdir_p!(dir_path)
file_in_dir_path = Path.join(dir_path, "file_in_dir.txt")
File.write!(file_in_dir_path, "file in directory")
# Create a zip file
zip_path = Path.join(@tmp_dir, "dir_test.zip")
assert {:ok, ^zip_path} =
SafeZip.zip(
zip_path,
["test_dir/file_in_dir.txt"],
@tmp_dir,
false
)
# Verify the zip file exists
assert File.exists?(zip_path)
# Extract and verify the directory structure is preserved
extract_dir = Path.join(@tmp_dir, "extract")
{:ok, files} = SafeZip.unzip_file(zip_path, extract_dir)
# Check if the file path is in the list, accounting for possible full paths
assert Enum.any?(files, fn file ->
String.ends_with?(file, "file_in_dir.txt")
end)
# Verify the file exists in the expected location
assert File.exists?(Path.join([extract_dir, "test_dir", "file_in_dir.txt"]))
end
end
describe "unzip_file/3" do
test "extracts files from a zip archive" do
archive_path = Path.join(@fixtures_dir, "emojis.zip")
# Extract the archive
assert {:ok, files} = SafeZip.unzip_file(archive_path, @tmp_dir)
# Verify files were extracted
assert is_list(files)
assert length(files) > 0
# Verify at least one file exists
first_file = List.first(files)
# Simply check that the file exists in the tmp directory
assert File.exists?(first_file)
end
test "extracts specific files from a zip archive" do
archive_path = Path.join(@fixtures_dir, "emojis.zip")
# Get list of files in the archive
{:ok, all_files} = SafeZip.list_dir_file(archive_path)
file_to_extract = List.first(all_files)
# Extract only one file
assert {:ok, [extracted_file]} =
SafeZip.unzip_file(archive_path, @tmp_dir, [file_to_extract])
# Verify only the specified file was extracted
assert Path.basename(extracted_file) == Path.basename(file_to_extract)
# Check that the file exists in the tmp directory
assert File.exists?(Path.join(@tmp_dir, Path.basename(file_to_extract)))
end
test "returns error for invalid zip file" do
invalid_path = Path.join(@tmp_dir, "invalid.zip")
File.write!(invalid_path, "not a zip file")
assert {:error, _} = SafeZip.unzip_file(invalid_path, @tmp_dir)
end
test "creates directories when extracting files in subdirectories" do
# Create a zip with files in subdirectories
zip_path = create_zip_with_directory()
# Extract the archive
assert {:ok, files} = SafeZip.unzip_file(zip_path, @tmp_dir)
# Verify files were extracted - handle both relative and absolute paths
assert Enum.any?(files, fn file ->
Path.basename(file) == "test_file.txt" &&
String.contains?(file, "file_in_dir")
end)
assert Enum.any?(files, fn file ->
Path.basename(file) == "root_file.txt"
end)
# Verify directory was created
dir_path = Path.join(@tmp_dir, "file_in_dir")
assert File.exists?(dir_path)
assert File.dir?(dir_path)
# Verify file in directory was extracted
file_path = Path.join(dir_path, "test_file.txt")
assert File.exists?(file_path)
end
end
describe "unzip_data/3" do
test "extracts files from zip data" do
archive_path = Path.join(@fixtures_dir, "emojis.zip")
archive_data = File.read!(archive_path)
# Extract the archive from data
assert {:ok, files} = SafeZip.unzip_data(archive_data, @tmp_dir)
# Verify files were extracted
assert is_list(files)
assert length(files) > 0
# Verify at least one file exists
first_file = List.first(files)
# Simply check that the file exists in the tmp directory
assert File.exists?(first_file)
end
test "extracts specific files from zip data" do
archive_path = Path.join(@fixtures_dir, "emojis.zip")
archive_data = File.read!(archive_path)
# Get list of files in the archive
{:ok, all_files} = SafeZip.list_dir_file(archive_path)
file_to_extract = List.first(all_files)
# Extract only one file
assert {:ok, extracted_files} =
SafeZip.unzip_data(archive_data, @tmp_dir, [file_to_extract])
# Verify only the specified file was extracted
assert Enum.any?(extracted_files, fn path ->
Path.basename(path) == Path.basename(file_to_extract)
end)
# Simply check that the file exists in the tmp directory
assert File.exists?(Path.join(@tmp_dir, Path.basename(file_to_extract)))
end
test "returns error for invalid zip data" do
assert {:error, _} = SafeZip.unzip_data("not a zip file", @tmp_dir)
end
test "creates directories when extracting files in subdirectories from data" do
# Create a zip with files in subdirectories
zip_path = create_zip_with_directory()
archive_data = File.read!(zip_path)
# Extract the archive from data
assert {:ok, files} = SafeZip.unzip_data(archive_data, @tmp_dir)
# Verify files were extracted - handle both relative and absolute paths
assert Enum.any?(files, fn file ->
Path.basename(file) == "test_file.txt" &&
String.contains?(file, "file_in_dir")
end)
assert Enum.any?(files, fn file ->
Path.basename(file) == "root_file.txt"
end)
# Verify directory was created
dir_path = Path.join(@tmp_dir, "file_in_dir")
assert File.exists?(dir_path)
assert File.dir?(dir_path)
# Verify file in directory was extracted
file_path = Path.join(dir_path, "test_file.txt")
assert File.exists?(file_path)
end
end
# Security tests
describe "security checks" do
test "prevents path traversal in zip extraction" do
# Create a malicious zip file with path traversal
malicious_zip_path = create_malicious_zip_with_path_traversal()
# Try to extract it with SafeZip
assert {:error, _} = SafeZip.unzip_file(malicious_zip_path, @tmp_dir)
# Verify the file was not extracted outside the target directory
refute File.exists?(Path.join(Path.dirname(@tmp_dir), "traversal_attempt.txt"))
end
test "prevents directory traversal in zip listing" do
# Create a malicious zip file with path traversal
malicious_zip_path = create_malicious_zip_with_path_traversal()
# Try to list files with SafeZip
assert {:error, _} = SafeZip.list_dir_file(malicious_zip_path)
end
test "prevents path traversal in zip data extraction" do
# Create a malicious zip file with path traversal
malicious_zip_path = create_malicious_zip_with_path_traversal()
malicious_data = File.read!(malicious_zip_path)
# Try to extract it with SafeZip
assert {:error, _} = SafeZip.unzip_data(malicious_data, @tmp_dir)
# Verify the file was not extracted outside the target directory
refute File.exists?(Path.join(Path.dirname(@tmp_dir), "traversal_attempt.txt"))
end
test "handles zip bomb attempts" do
# Create a zip bomb (a zip with many files or large files)
zip_bomb_path = create_zip_bomb()
# The SafeZip module should handle this gracefully
# Either by successfully extracting it (if it's not too large)
# or by returning an error (if it detects a potential zip bomb)
result = SafeZip.unzip_file(zip_bomb_path, @tmp_dir)
case result do
{:ok, _} ->
# If it successfully extracts, make sure it didn't fill up the disk
# This is a simple check to ensure the extraction was controlled
assert File.exists?(@tmp_dir)
{:error, _} ->
# If it returns an error, that's also acceptable
# The important thing is that it doesn't crash or hang
assert true
end
end
test "handles deeply nested directory structures" do
# Create a zip with deeply nested directories
deep_nest_path = create_deeply_nested_zip()
# The SafeZip module should handle this gracefully
result = SafeZip.unzip_file(deep_nest_path, @tmp_dir)
case result do
{:ok, files} ->
# If it successfully extracts, verify the files were extracted
assert is_list(files)
assert length(files) > 0
{:error, _} ->
# If it returns an error, that's also acceptable
# The important thing is that it doesn't crash or hang
assert true
end
end
end
# Helper functions to create test fixtures
# Creates a zip file with a path traversal attempt
defp create_malicious_zip_with_path_traversal do
malicious_zip_path = Path.join(@tmp_dir, "path_traversal.zip")
# Create a file to include in the zip
test_file_path = Path.join(@tmp_dir, "test_file.txt")
File.write!(test_file_path, "malicious content")
# Use Erlang's zip module directly to create a zip with path traversal
{:ok, charlist_path} =
:zip.create(
String.to_charlist(malicious_zip_path),
[{String.to_charlist("../traversal_attempt.txt"), File.read!(test_file_path)}]
)
to_string(charlist_path)
end
# Creates a zip file with directory entries
defp create_zip_with_directory do
zip_path = Path.join(@tmp_dir, "with_directory.zip")
# Create files to include in the zip
root_file_path = Path.join(@tmp_dir, "root_file.txt")
File.write!(root_file_path, "root file content")
# Create a directory and a file in it
dir_path = Path.join(@tmp_dir, "file_in_dir")
Pleroma.Backports.mkdir_p!(dir_path)
file_in_dir_path = Path.join(dir_path, "test_file.txt")
File.write!(file_in_dir_path, "file in directory content")
# Use Erlang's zip module to create a zip with directory structure
{:ok, charlist_path} =
:zip.create(
String.to_charlist(zip_path),
[
{String.to_charlist("root_file.txt"), File.read!(root_file_path)},
{String.to_charlist("file_in_dir/test_file.txt"), File.read!(file_in_dir_path)}
]
)
to_string(charlist_path)
end
# Creates a zip bomb (a zip with many small files)
defp create_zip_bomb do
zip_path = Path.join(@tmp_dir, "zip_bomb.zip")
# Create a small file to duplicate many times
small_file_path = Path.join(@tmp_dir, "small_file.txt")
File.write!(small_file_path, String.duplicate("A", 100))
# Create a list of many files to include in the zip
file_entries =
for i <- 1..100 do
{String.to_charlist("file_#{i}.txt"), File.read!(small_file_path)}
end
# Use Erlang's zip module to create a zip with many files
{:ok, charlist_path} =
:zip.create(
String.to_charlist(zip_path),
file_entries
)
to_string(charlist_path)
end
# Creates a zip with deeply nested directories
defp create_deeply_nested_zip do
zip_path = Path.join(@tmp_dir, "deep_nest.zip")
# Create a file to include in the zip
file_content = "test content"
# Create a list of deeply nested files
file_entries =
for i <- 1..10 do
nested_path = Enum.reduce(1..i, "nested", fn j, acc -> "#{acc}/level_#{j}" end)
{String.to_charlist("#{nested_path}/file.txt"), file_content}
end
# Use Erlang's zip module to create a zip with deeply nested directories
{:ok, charlist_path} =
:zip.create(
String.to_charlist(zip_path),
file_entries
)
to_string(charlist_path)
end
end

View file

@ -0,0 +1,79 @@
# Pleroma: A lightweight social networking server
# Copyright © 2017-2026 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Search.BackfillTest do
use Pleroma.DataCase, async: false
use Oban.Testing, repo: Pleroma.Repo
import Pleroma.Factory
alias Pleroma.Search.Backfill
alias Pleroma.Workers.SearchIndexingWorker
test "enqueues the exact requested number across partial pages" do
activities = insert_list(5, :note_activity) |> Enum.sort_by(& &1.id, :desc)
assert %{enqueued: 3, exhausted: false, next_cursor: cursor} =
Backfill.run(limit: 3, step: 2, chunk: 2)
assert search_indexing_ids() == Enum.map(Enum.take(activities, 3), & &1.id)
assert cursor == Enum.at(activities, 2).id
end
test "resumes after the last processed activity without duplicates" do
activities = insert_list(5, :note_activity) |> Enum.sort_by(& &1.id, :desc)
assert %{enqueued: 2, next_cursor: cursor} = Backfill.run(limit: 2, step: 1)
Pleroma.Repo.delete_all(Oban.Job)
assert %{enqueued: 2, next_cursor: next_cursor} =
Backfill.run(before: cursor, limit: 2, step: 1)
assert search_indexing_ids() ==
activities
|> Enum.slice(2, 2)
|> Enum.map(& &1.id)
assert next_cursor == Enum.at(activities, 3).id
end
test "only queues Create activities" do
create = insert(:note_activity)
insert(:like_activity, note_activity: create)
assert %{enqueued: 1, exhausted: true, next_cursor: cursor} = Backfill.run(step: 2)
assert search_indexing_ids() == [create.id]
assert cursor == create.id
end
test "rejects unknown options and malformed cursors before enqueueing" do
assert_raise ArgumentError, fn -> Backfill.run(limt: 1) end
assert_raise ArgumentError, fn -> Backfill.run(before: "not-an-activity-id") end
assert_raise ArgumentError, fn -> Backfill.run(on_page: :invalid) end
assert all_enqueued(worker: SearchIndexingWorker) == []
end
test "does not duplicate or skip the initial activities when newer rows are inserted" do
activities = insert_list(3, :note_activity) |> Enum.sort_by(& &1.id, :desc)
assert %{enqueued: 3, exhausted: true} =
Backfill.run(
limit: :infinity,
step: 1,
on_page: fn %{enqueued: enqueued} ->
if enqueued == 1, do: insert(:note_activity)
end
)
assert search_indexing_ids() == Enum.map(activities, & &1.id)
end
defp search_indexing_ids do
all_enqueued(worker: SearchIndexingWorker)
|> Enum.sort_by(& &1.id)
|> Enum.map(& &1.args["activity"])
end
end

View file

@ -74,29 +74,6 @@ defmodule Pleroma.Search.MeilisearchTest do
assert_received("posted_to_meilisearch")
end
test "doesn't index posts that are not public" do
user = insert(:user)
Enum.each(["private", "direct"], fn visibility ->
{:ok, activity} =
CommonAPI.post(user, %{
status: "guys i just don't wanna leave the swamp",
visibility: visibility
})
args = %{"op" => "add_to_index", "activity" => activity.id}
Config
|> expect(:get, fn
[Pleroma.Search, :module], nil ->
Meilisearch
end)
assert_enqueued(worker: SearchIndexingWorker, args: args)
assert :ok = perform_job(SearchIndexingWorker, args)
end)
end
test "deletes posts from index when deleted locally" do
user = insert(:user)

View file

@ -0,0 +1,323 @@
# Pleroma: A lightweight social networking server
# Copyright © 2017-2022 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Search.ParadeDBIntegrationTest do
use Pleroma.DataCase, async: false
@moduletag :integration
@moduletag timeout: 180_000
import Pleroma.Factory
alias Pleroma.Activity
alias Pleroma.Search.ParadeDB
alias Pleroma.Web.CommonAPI
@image System.get_env(
"PARADEDB_TEST_IMAGE",
"paradedb/paradedb:0.24.3-pg15@sha256:997d62f6c2605becb7c45878af3f8c8c698f355006b675b3ce212d4eb2d14f2e"
)
@db_user "postgres"
@db_password "postgres"
@db_name "paradedb"
@docker_startup_timeout_ms 60_000
setup do
Mox.stub_with(Pleroma.UnstubbedConfigMock, Pleroma.Config)
:ok
end
setup_all do
case System.get_env("PARADEDB_TEST_URL") do
nil -> setup_docker_paradedb()
url -> setup_external_paradedb(url)
end
end
defp setup_docker_paradedb do
with :ok <- ensure_docker_available() do
container = "pleroma-paradedb-it-#{System.unique_integer([:positive])}"
on_exit(fn ->
_ = docker(["rm", "-f", container])
end)
{id, 0} =
docker([
"run",
"--rm",
"--name",
container,
"-e",
"POSTGRES_USER=#{@db_user}",
"-e",
"POSTGRES_PASSWORD=#{@db_password}",
"-e",
"POSTGRES_DB=#{@db_name}",
"-p",
"127.0.0.1::5432",
"-d",
@image
])
container_id = String.trim(id)
host_port =
container
|> wait_for_port!(@docker_startup_timeout_ms)
wait_for_pg_ready!(container, @docker_startup_timeout_ms)
url = "postgres://#{@db_user}:#{@db_password}@127.0.0.1:#{host_port}/#{@db_name}"
setup_repo(url, %{container: container, container_id: container_id})
end
end
defp setup_external_paradedb(url) do
uri = URI.parse(url)
wait_for_tcp_ready!(uri.host, uri.port || 5432, @docker_startup_timeout_ms)
setup_repo(url, %{})
end
defp setup_repo(url, context) do
prev_env = System.get_env("PARADEDB_DATABASE_URL")
System.put_env("PARADEDB_DATABASE_URL", url)
on_exit(fn ->
if is_nil(prev_env) do
System.delete_env("PARADEDB_DATABASE_URL")
else
System.put_env("PARADEDB_DATABASE_URL", prev_env)
end
end)
{:ok, _pid} = start_supervised({Pleroma.Search.ParadeDB.Repo, pool_size: 5})
wait_for_repo_ready!(@docker_startup_timeout_ms)
previous_table = Pleroma.Config.get([Pleroma.Search.ParadeDB, :table], nil)
suffix = Ecto.UUID.generate() |> String.replace("-", "")
table = "pleroma_search_documents_it_#{suffix}"
Pleroma.Config.put([Pleroma.Search.ParadeDB, :table], table)
on_exit(fn ->
if is_nil(previous_table) do
Pleroma.Config.delete([Pleroma.Search.ParadeDB, :table])
else
Pleroma.Config.put([Pleroma.Search.ParadeDB, :table], previous_table)
end
end)
{:ok, Map.merge(context, %{url: url, table: table})}
end
test "indexes and searches against real ParadeDB", %{table: table} do
on_exit(fn ->
_ =
Ecto.Adapters.SQL.query(
Pleroma.Search.ParadeDB.Repo,
"DROP TABLE IF EXISTS #{table}"
)
end)
:ok = ParadeDB.drop_index()
assert :ok = ParadeDB.create_index()
assert {:ok, %{rows: [["0.24.3"]]}} =
Ecto.Adapters.SQL.query(
Pleroma.Search.ParadeDB.Repo,
"SELECT extversion FROM pg_extension WHERE extname = 'pg_search'"
)
user = insert(:user)
{:ok, a1} =
CommonAPI.post(user, %{status: "paradedb integration first", visibility: "public"})
:timer.sleep(1100)
{:ok, a2} =
CommonAPI.post(user, %{status: "paradedb integration second", visibility: "public"})
{:ok, private} =
CommonAPI.post(user, %{status: "paradedb integration private", visibility: "private"})
a1 = Activity.get_by_id_with_object(a1.id)
a2 = Activity.get_by_id_with_object(a2.id)
private = Activity.get_by_id_with_object(private.id)
a1 = %{
a1
| object: %{
a1.object
| data:
Map.merge(a1.object.data, %{
"summary" => "paradedb subject phrase",
"attachment" => [
%{
"name" => "remote-image.jpg",
"summary" => "paradedb attachment description"
}
]
})
}
}
assert :ok = ParadeDB.add_to_index(a1)
assert :ok = ParadeDB.add_to_index(a2)
assert :ok = ParadeDB.add_to_index(private)
results = ParadeDB.search(nil, "paradedb integration", limit: 40, offset: 0)
ids = Enum.map(results, & &1.id)
assert a1.id in ids
assert a2.id in ids
refute private.id in ids
assert List.first(ids) == a2.id
assert Enum.any?(ParadeDB.search(nil, "subject phrase"), &(&1.id == a1.id))
assert Enum.any?(ParadeDB.search(nil, "attachment description"), &(&1.id == a1.id))
previous_fuzzy_distance = Pleroma.Config.get([Pleroma.Search.ParadeDB, :fuzzy_distance], 0)
Pleroma.Config.put([Pleroma.Search.ParadeDB, :fuzzy_distance], 1)
on_exit(fn ->
Pleroma.Config.put([Pleroma.Search.ParadeDB, :fuzzy_distance], previous_fuzzy_distance)
end)
fuzzy_results = ParadeDB.search(nil, "paradedb integraion", limit: 40, offset: 0)
fuzzy_ids = Enum.map(fuzzy_results, & &1.id)
assert a1.id in fuzzy_ids
assert a2.id in fuzzy_ids
refute private.id in fuzzy_ids
# Removing an object should remove it from the ParadeDB index.
assert :ok = ParadeDB.remove_from_index(a2.object)
results_after_delete = ParadeDB.search(nil, "paradedb integration", limit: 40, offset: 0)
ids_after_delete = Enum.map(results_after_delete, & &1.id)
assert a1.id in ids_after_delete
refute a2.id in ids_after_delete
end
defp ensure_docker_available do
if is_nil(System.find_executable("docker")) do
{:skip, "Docker not installed"}
else
case docker(["info"]) do
{_out, 0} -> :ok
{out, _} -> {:skip, "Docker not available: #{String.trim(out)}"}
end
end
end
defp wait_for_port!(container, timeout_ms) do
deadline = System.monotonic_time(:millisecond) + timeout_ms
wait = fn wait ->
case docker(["port", container, "5432/tcp"]) do
{out, 0} when byte_size(out) > 0 ->
out
|> String.split("\n", trim: true)
|> List.first()
|> parse_host_port!()
_ ->
if System.monotonic_time(:millisecond) > deadline do
raise "Timed out waiting for docker port mapping"
else
:timer.sleep(250)
wait.(wait)
end
end
end
wait.(wait)
end
defp parse_host_port!(mapping) do
mapping
|> String.trim()
|> String.split(":")
|> List.last()
|> String.to_integer()
end
defp wait_for_pg_ready!(container, timeout_ms) do
deadline = System.monotonic_time(:millisecond) + timeout_ms
wait = fn wait ->
case docker([
"exec",
container,
"pg_isready",
"-U",
@db_user,
"-d",
@db_name
]) do
{_out, 0} ->
:ok
_ ->
if System.monotonic_time(:millisecond) > deadline do
raise "Timed out waiting for ParadeDB Postgres to become ready"
else
:timer.sleep(250)
wait.(wait)
end
end
end
wait.(wait)
end
defp wait_for_tcp_ready!(host, port, timeout_ms) do
deadline = System.monotonic_time(:millisecond) + timeout_ms
wait = fn wait ->
case :gen_tcp.connect(String.to_charlist(host), port, [:binary, active: false], 500) do
{:ok, socket} ->
:gen_tcp.close(socket)
:ok
{:error, _} ->
if System.monotonic_time(:millisecond) > deadline do
raise "Timed out waiting for TCP port #{port} to accept connections"
else
:timer.sleep(250)
wait.(wait)
end
end
end
wait.(wait)
end
defp wait_for_repo_ready!(timeout_ms) do
deadline = System.monotonic_time(:millisecond) + timeout_ms
wait = fn wait ->
case Ecto.Adapters.SQL.query(Pleroma.Search.ParadeDB.Repo, "SELECT 1") do
{:ok, _} ->
:ok
_ ->
if System.monotonic_time(:millisecond) > deadline do
raise "Timed out waiting for ParadeDB Repo"
else
:timer.sleep(250)
wait.(wait)
end
end
end
wait.(wait)
end
defp docker(args) do
System.cmd("docker", args, stderr_to_stdout: true)
end
end

Some files were not shown because too many files have changed in this diff Show more