Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
125a49a1a0 | ||
|
|
5e34517e73 | ||
|
|
85fdff606d | ||
|
|
00b2685cf4 | ||
|
|
7c6fe80f1b |
No files matched your search
@@ -15,5 +15,5 @@ jobs:
|
|||||||
- name: Build
|
- name: Build
|
||||||
run: dotnet build PrivaPub.sln -c Release
|
run: dotnet build PrivaPub.sln -c Release
|
||||||
|
|
||||||
- name: Test
|
- name: Test (unit and integration, on a throwaway mongod)
|
||||||
run: dotnet test PrivaPub.sln -c Release --no-build
|
run: tools/ci/with-test-mongod.sh dotnet test PrivaPub.sln -c Release --no-build
|
||||||
@@ -28,8 +28,8 @@ jobs:
|
|||||||
echo "BUILD_REF=${GITHUB_REF_NAME:-master}" >> "$GITHUB_ENV"
|
echo "BUILD_REF=${GITHUB_REF_NAME:-master}" >> "$GITHUB_ENV"
|
||||||
echo "BUILD_TIME=$(date -u +%Y-%m-%dT%H:%M:%SZ)" >> "$GITHUB_ENV"
|
echo "BUILD_TIME=$(date -u +%Y-%m-%dT%H:%M:%SZ)" >> "$GITHUB_ENV"
|
||||||
|
|
||||||
- name: Test
|
- name: Test (unit and integration, on a throwaway mongod)
|
||||||
run: dotnet test PrivaPub.sln -c Release
|
run: tools/ci/with-test-mongod.sh dotnet test PrivaPub.sln -c Release
|
||||||
|
|
||||||
- name: Publish
|
- name: Publish
|
||||||
run: |
|
run: |
|
||||||
@@ -90,6 +90,8 @@ jobs:
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
- name: Verify what is being served
|
- name: Verify what is being served
|
||||||
|
env:
|
||||||
|
SMOKE_TOKEN: ${{ secrets.PRIVAPUB_SMOKE_TOKEN }}
|
||||||
run: |
|
run: |
|
||||||
served=$(curl -fsS "$PUBLIC_URL/build.json" | python3 -c "import json,sys; print(json.load(sys.stdin).get('commit',''))")
|
served=$(curl -fsS "$PUBLIC_URL/build.json" | python3 -c "import json,sys; print(json.load(sys.stdin).get('commit',''))")
|
||||||
[ "$served" = "$BUILD_COMMIT" ] || { echo "::error::served build is '$served', expected '$BUILD_COMMIT'"; exit 1; }
|
[ "$served" = "$BUILD_COMMIT" ] || { echo "::error::served build is '$served', expected '$BUILD_COMMIT'"; exit 1; }
|
||||||
@@ -104,5 +106,6 @@ jobs:
|
|||||||
code=$(curl -s -o /dev/null -w '%{http_code}' -X POST -H 'Content-Type: application/activity+json' \
|
code=$(curl -s -o /dev/null -w '%{http_code}' -X POST -H 'Content-Type: application/activity+json' \
|
||||||
--data '{"type":"Follow","actor":"https://example.org/users/x","object":"'"$PUBLIC_URL"'/peasants/privapub"}' "$PUBLIC_URL/human-centipede")
|
--data '{"type":"Follow","actor":"https://example.org/users/x","object":"'"$PUBLIC_URL"'/peasants/privapub"}' "$PUBLIC_URL/human-centipede")
|
||||||
[ "$code" = "401" ] || { echo "::error::an unsigned inbox POST answered $code"; exit 1; }
|
[ "$code" = "401" ] || { echo "::error::an unsigned inbox POST answered $code"; exit 1; }
|
||||||
tools/smoke/mastodon-api.sh "$PUBLIC_URL"
|
tools/smoke/mastodon-api.sh "$PUBLIC_URL" "$SMOKE_TOKEN"
|
||||||
|
[ -n "$SMOKE_TOKEN" ] || echo "::warning::PRIVAPUB_SMOKE_TOKEN is not set, so the signed-in API was not checked"
|
||||||
echo "::notice::serving $served"
|
echo "::notice::serving $served"
|
||||||
@@ -124,6 +124,7 @@ dotnet build PrivaPub.sln -c Release
|
|||||||
dotnet test PrivaPub.sln # unit tests; integration tests skip
|
dotnet test PrivaPub.sln # unit tests; integration tests skip
|
||||||
PRIVAPUB_TEST_MONGOD=1 dotnet test PrivaPub.sln # all of them, against mongod on 127.0.0.1:27017
|
PRIVAPUB_TEST_MONGOD=1 dotnet test PrivaPub.sln # all of them, against mongod on 127.0.0.1:27017
|
||||||
# (PRIVAPUB_TEST_MONGO overrides; a fresh database per run, dropped after)
|
# (PRIVAPUB_TEST_MONGO overrides; a fresh database per run, dropped after)
|
||||||
|
tools/ci/with-test-mongod.sh dotnet test PrivaPub.sln # all of them, on a throwaway mongod, as CI runs them
|
||||||
cd PrivaPub && ASPNETCORE_ENVIRONMENT=Development \
|
cd PrivaPub && ASPNETCORE_ENVIRONMENT=Development \
|
||||||
Kestrel__Endpoints__Http__Url=http://127.0.0.1:6970 Kestrel__Endpoints__Http__Protocols=Http1AndHttp2 \
|
Kestrel__Endpoints__Http__Url=http://127.0.0.1:6970 Kestrel__Endpoints__Http__Protocols=Http1AndHttp2 \
|
||||||
AppConfiguration__BackendBaseAddress=http://127.0.0.1:6970 MongoSettings__Database=PrivaPubTest \
|
AppConfiguration__BackendBaseAddress=http://127.0.0.1:6970 MongoSettings__Database=PrivaPubTest \
|
||||||
@@ -198,7 +199,10 @@ cd /var/www/privapub.thepra.dev && sudo -u www-data ASPNETCORE_ENVIRONMENT=Produ
|
|||||||
3. Read parameters through `Params` (query, form and JSON merged Rails-style), never MVC binding. A value type read
|
3. Read parameters through `Params` (query, form and JSON merged Rails-style), never MVC binding. A value type read
|
||||||
from a conditional must say `(int?)null`, not `default`: that bug once made every list one item long.
|
from a conditional must say `(int?)null`, not `default`: that bug once made every list one item long.
|
||||||
4. Answer with `Json(...)` (snake_case, explicit nulls) or `Error(status, message)`; page lists with `Page` and `Link`.
|
4. Answer with `Json(...)` (snake_case, explicit nulls) or `Error(status, message)`; page lists with `Page` and `Link`.
|
||||||
5. Unsupported features answer empty lists or 422 with a message, never 404 or 500, so clients degrade.
|
5. Unsupported features answer empty lists or 422 with a message, never 404 or 500, so clients degrade. Any `/api`
|
||||||
|
error that leaves without a body (a challenge, a 404 from routing, a 429) gets Mastodon's `{"error": ...}` from
|
||||||
|
`UseMastodonErrorBodies`. `NeverFiveHundredTests` walks every route with junk ids, anonymously, as a persona and
|
||||||
|
with a junk token.
|
||||||
6. Advertise `4.2.0 (compatible; PrivaPub)` until grouped notifications exist.
|
6. Advertise `4.2.0 (compatible; PrivaPub)` until grouped notifications exist.
|
||||||
7. **What a Mastodon `Status` cannot say goes in `Status.privapub`** (`PrivaPubStatus`): object type, title, excerpt,
|
7. **What a Mastodon `Status` cannot say goes in `Status.privapub`** (`PrivaPubStatus`): object type, title, excerpt,
|
||||||
cover, the author's source, link, video, audio and event details, and up/down votes. Every media URL in it goes
|
cover, the author's source, link, video, audio and event details, and up/down votes. Every media URL in it goes
|
||||||
@@ -316,18 +320,37 @@ cd /var/www/privapub.thepra.dev && sudo -u www-data ASPNETCORE_ENVIRONMENT=Produ
|
|||||||
stored as an edit date on every remote post. Write `(T?)null`. It has shipped four times (`MastodonParams.Bool/Int`,
|
stored as an edit date on every remote post. Write `(T?)null`. It has shipped four times (`MastodonParams.Bool/Int`,
|
||||||
`NoteParser.Int`, `NoteParser.Time`).
|
`NoteParser.Int`, `NoteParser.Time`).
|
||||||
- ActivityPub output is built with `System.Text.Json.Nodes` in `ActivityPubRenderer`, not typed models. Inbound
|
- ActivityPub output is built with `System.Text.Json.Nodes` in `ActivityPubRenderer`, not typed models. Inbound
|
||||||
documents are read through `InboxService.Id`/`Value`, which handle string, object and array.
|
documents are read through `ActivityJson.Id`/`Value`, which handle string, object and array.
|
||||||
- Libraries chosen for the roadmap: HtmlSanitizer, Markdig (`DisableHtml`), NSign (RFC 9421 inbound), OpenIddict +
|
- Libraries chosen for the roadmap: HtmlSanitizer, Markdig (`DisableHtml`), NSign (RFC 9421 inbound), OpenIddict +
|
||||||
OpenIddict.MongoDb, NetVips, Blurhash.Core, FFMpegCore. No ImageSharp (licence key enforced), no MassTransit.
|
OpenIddict.MongoDb, NetVips, Blurhash.Core, FFMpegCore. No ImageSharp (licence key enforced), no MassTransit.
|
||||||
|
|
||||||
## Testing
|
## Testing
|
||||||
|
|
||||||
`PrivaPub.Tests` (xUnit v3). Unit tests need nothing; tests marked `Category=Integration` need a mongod and skip
|
`PrivaPub.Tests` (xUnit v3). Unit tests need nothing; tests marked `Category=Integration` need a mongod and skip
|
||||||
without `PRIVAPUB_TEST_MONGOD=1`. CI runs the unit tests only (the box's mongods are production).
|
without `PRIVAPUB_TEST_MONGOD=1`. CI (`build.yml` and `deploy.yml`) runs all of them through
|
||||||
|
`tools/ci/with-test-mongod.sh`, which starts a throwaway mongod on a random localhost port and deletes it afterwards.
|
||||||
|
The box's own mongods are production, so `MongoFixture` refuses port 27022, a data directory under `/var/lib/privapub`,
|
||||||
|
and in CI anything but the wrapper's mongod. With `PRIVAPUB_TEST_REQUIRE_MONGOD=1`, which the wrapper sets, a missing
|
||||||
|
mongod fails the run instead of silently skipping half the tests.
|
||||||
|
|
||||||
- `Support/Peer` is an in-process HTTP server answering on two origins (`127.0.0.1` and `localhost`), so origin rules
|
- `Support/Peer` is an in-process HTTP server answering on two origins (`127.0.0.1` and `localhost`), so origin rules
|
||||||
can be tested; `Support/RemoteActor` signs real deliveries with its own key.
|
can be tested; `Support/RemoteActor` signs real deliveries with its own key.
|
||||||
- Inbox scenarios go through `InboxService.Receive` with a signed request, not through the private handlers.
|
- Inbox scenarios go through `InboxReceiver.Receive` with a signed request, not through the private handlers.
|
||||||
|
- `Support/Host/PrivaPubHost` is the whole server under test (`WebApplicationFactory<Program>`, environment `Testing`,
|
||||||
|
configured only through `UseSetting`, on the fixture's database). `PrivaPubHost.Shared()` boots it once per run;
|
||||||
|
`SecureModeHost` is the same with `Federation:SecureMode`. Background workers are removed, so a test runs the jobs it
|
||||||
|
queued with `host.Run(j => ...)` or `host.RunInbox(activityId)`. `Accounts` signs a root up, adds personas, and gets a
|
||||||
|
Mastodon token through the real `/oauth` code flow; `RemoteActor.SignedPost`/`SignedGet` sign `HttpRequestMessage`s
|
||||||
|
for the real `/peasants` routes. Each client gets its own `X-Test-Client` address, so rate limits don't collide.
|
||||||
|
- All test classes share one database and run in parallel, so a test touches only rows it made:
|
||||||
|
- random names and GUIDs;
|
||||||
|
- `Harness.Outgoing` sees only deliveries queued since that harness started, because Peer ports are reused;
|
||||||
|
- a worker gets a scoped `new JobQueue(j => ...)` so it never leases another test's jobs;
|
||||||
|
- domain blocks are set with `DomainBlocks.Load`, never written to the database.
|
||||||
|
|
||||||
|
A test that must change something database-wide (drop indexes, run a migration over every post, let deliveries to
|
||||||
|
`localhost` fail and trip its breaker) goes in `[Xunit.Collection(nameof(Exclusive))]`, which runs alone, and
|
||||||
|
cleans up after itself. Pure logic belongs in an unconditional unit test, not in a Mongo-gated class.
|
||||||
|
|
||||||
Beyond the tests, verify by building, running locally, and exercising:
|
Beyond the tests, verify by building, running locally, and exercising:
|
||||||
- the client API (sign up, create an avatar, a group, a post);
|
- the client API (sign up, create an avatar, a group, a post);
|
||||||
@@ -337,7 +360,7 @@ Interop is checked against real servers, starting with the workstation's own pas
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
DOTNET=~/.dotnet/dotnet tools/pasture/run.sh up # podman: PrivaPub + the latest GoToSocial + Mongo, behind Caddy
|
DOTNET=~/.dotnet/dotnet tools/pasture/run.sh up # podman: PrivaPub + the latest GoToSocial + Mongo, behind Caddy
|
||||||
tools/pasture/interop.sh # 25 checks, each side driven through its own Mastodon API
|
tools/pasture/interop.sh # 33 checks, each side driven through its own Mastodon API
|
||||||
tools/pasture/run.sh down
|
tools/pasture/run.sh down
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
|
|
||||||
|
using PrivaPub.Federation.Moderation;
|
||||||
|
using PrivaPub.Models.Federation;
|
||||||
|
|
||||||
|
namespace PrivaPub.Tests.Federation
|
||||||
|
{
|
||||||
|
public class DomainBlocksTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void A_block_covers_subdomains_but_not_lookalikes()
|
||||||
|
{
|
||||||
|
var blocks = new DomainBlocks(NullLogger<DomainBlocks>.Instance);
|
||||||
|
blocks.Load(new[] { new DomainBlock { Domain = "evil.example", Severity = DomainBlockSeverity.Silence } });
|
||||||
|
|
||||||
|
Assert.NotNull(blocks.Find("evil.example"));
|
||||||
|
Assert.NotNull(blocks.Find("A.Evil.Example."));
|
||||||
|
Assert.Null(blocks.Find("notevil.example"));
|
||||||
|
Assert.False(blocks.IsSuspended("evil.example"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void A_suspension_is_found_from_any_subdomain()
|
||||||
|
{
|
||||||
|
var blocks = new DomainBlocks(NullLogger<DomainBlocks>.Instance);
|
||||||
|
blocks.Load(new[]
|
||||||
|
{
|
||||||
|
new DomainBlock { Domain = " Spam.Example. ", Severity = DomainBlockSeverity.Suspend },
|
||||||
|
new DomainBlock { Domain = "", Severity = DomainBlockSeverity.Suspend }
|
||||||
|
});
|
||||||
|
|
||||||
|
Assert.True(blocks.IsSuspended("deep.inside.spam.example"));
|
||||||
|
Assert.False(blocks.IsSuspended("example"));
|
||||||
|
Assert.Null(blocks.Find(default));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -269,10 +269,7 @@ namespace PrivaPub.Tests.Federation
|
|||||||
var token = TestContext.Current.CancellationToken;
|
var token = TestContext.Current.CancellationToken;
|
||||||
var alice = await LocalAvatar("alice");
|
var alice = await LocalAvatar("alice");
|
||||||
var bob = new RemoteActor(_peer, "bob", _peer.B);
|
var bob = new RemoteActor(_peer, "bob", _peer.B);
|
||||||
await DB.Default.SaveAsync(new DomainBlock { Domain = "localhost", Severity = DomainBlockSeverity.Suspend }, token);
|
_blocks.Load(new[] { new DomainBlock { Domain = "localhost", Severity = DomainBlockSeverity.Suspend } });
|
||||||
try
|
|
||||||
{
|
|
||||||
await _blocks.Reload(token);
|
|
||||||
var before = _peer.Requests.Count;
|
var before = _peer.Requests.Count;
|
||||||
|
|
||||||
var result = await Deliver(bob, $"/peasants/{alice.UserName}/mouth", DirectCreate(bob, alice.Uri));
|
var result = await Deliver(bob, $"/peasants/{alice.UserName}/mouth", DirectCreate(bob, alice.Uri));
|
||||||
@@ -281,27 +278,6 @@ namespace PrivaPub.Tests.Federation
|
|||||||
Assert.Equal(before, _peer.Requests.Count);
|
Assert.Equal(before, _peer.Requests.Count);
|
||||||
Assert.False(await DB.Default.Find<Post>().Match(p => p.ActorURI == bob.Id).ExecuteAnyAsync(token));
|
Assert.False(await DB.Default.Find<Post>().Match(p => p.ActorURI == bob.Id).ExecuteAnyAsync(token));
|
||||||
}
|
}
|
||||||
finally
|
|
||||||
{
|
|
||||||
await DB.Default.DeleteAsync<DomainBlock>(b => b.Domain == "localhost");
|
|
||||||
await _blocks.Reload(token);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void A_block_covers_subdomains_but_not_lookalikes()
|
|
||||||
{
|
|
||||||
var blocks = new DomainBlocks(NullLogger<DomainBlocks>.Instance);
|
|
||||||
typeof(DomainBlocks).GetField("_blocks", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance)!
|
|
||||||
.SetValue(blocks, new Dictionary<string, DomainBlock> { ["evil.example"] = new() { Domain = "evil.example", Severity = DomainBlockSeverity.Silence } });
|
|
||||||
typeof(DomainBlocks).GetField("_loadedAt", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance)!
|
|
||||||
.SetValue(blocks, DateTime.UtcNow);
|
|
||||||
|
|
||||||
Assert.NotNull(blocks.Find("evil.example"));
|
|
||||||
Assert.NotNull(blocks.Find("A.Evil.Example."));
|
|
||||||
Assert.Null(blocks.Find("notevil.example"));
|
|
||||||
Assert.False(blocks.IsSuspended("evil.example"));
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task A_bad_signature_is_a_401()
|
public async Task A_bad_signature_is_a_401()
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
using PrivaPub.Federation.Objects;
|
||||||
|
|
||||||
|
using System.Text.Json.Nodes;
|
||||||
|
|
||||||
|
namespace PrivaPub.Tests.Federation
|
||||||
|
{
|
||||||
|
public class ObjectRecordsTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void An_object_larger_than_the_cap_keeps_only_its_hash()
|
||||||
|
{
|
||||||
|
var big = new JsonObject { ["content"] = new string('x', ObjectRecords.MaxRawBytes + 1) };
|
||||||
|
|
||||||
|
var (text, hash, bytes, truncated) = ObjectRecords.Capture(big);
|
||||||
|
|
||||||
|
Assert.Null(text);
|
||||||
|
Assert.True(truncated);
|
||||||
|
Assert.True(bytes > ObjectRecords.MaxRawBytes);
|
||||||
|
Assert.Equal(64, hash.Length);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void A_small_object_is_kept_whole_with_its_hash()
|
||||||
|
{
|
||||||
|
var small = new JsonObject { ["type"] = "Note", ["content"] = "hi" };
|
||||||
|
|
||||||
|
var (text, hash, bytes, truncated) = ObjectRecords.Capture(small);
|
||||||
|
|
||||||
|
Assert.Equal(small.ToJsonString(), text);
|
||||||
|
Assert.False(truncated);
|
||||||
|
Assert.Equal(text.Length, bytes);
|
||||||
|
Assert.Equal(64, hash.Length);
|
||||||
|
Assert.Equal(default, ObjectRecords.Capture(default));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -68,18 +68,5 @@ namespace PrivaPub.Tests.Federation
|
|||||||
Assert.Equal("<p>hi again</p>", JsonNode.Parse(Assert.Single(record.Revisions).Raw)!["content"]!.GetValue<string>());
|
Assert.Equal("<p>hi again</p>", JsonNode.Parse(Assert.Single(record.Revisions).Raw)!["content"]!.GetValue<string>());
|
||||||
Assert.True(await DB.Default.Find<Job>().Match(j => j.Kind == JobKind.DescribeInstance && j.Payload == record.Host).ExecuteAnyAsync(token));
|
Assert.True(await DB.Default.Find<Job>().Match(j => j.Kind == JobKind.DescribeInstance && j.Payload == record.Host).ExecuteAnyAsync(token));
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void An_object_larger_than_the_cap_keeps_only_its_hash()
|
|
||||||
{
|
|
||||||
var big = new JsonObject { ["content"] = new string('x', ObjectRecords.MaxRawBytes + 1) };
|
|
||||||
|
|
||||||
var (text, hash, bytes, truncated) = ObjectRecords.Capture(big);
|
|
||||||
|
|
||||||
Assert.Null(text);
|
|
||||||
Assert.True(truncated);
|
|
||||||
Assert.True(bytes > ObjectRecords.MaxRawBytes);
|
|
||||||
Assert.Equal(64, hash.Length);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
using Microsoft.AspNetCore.Mvc.Controllers;
|
||||||
|
using Microsoft.AspNetCore.Mvc.Infrastructure;
|
||||||
|
using Microsoft.AspNetCore.Mvc.RazorPages;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
|
||||||
|
using MongoDB.Entities;
|
||||||
|
|
||||||
|
using PrivaPub.Federation.Inbox;
|
||||||
|
using PrivaPub.Infrastructure.Jobs;
|
||||||
|
using PrivaPub.Models;
|
||||||
|
using PrivaPub.Models.Jobs;
|
||||||
|
using PrivaPub.Models.Post;
|
||||||
|
using PrivaPub.Tests.Support;
|
||||||
|
using PrivaPub.Tests.Support.Host;
|
||||||
|
|
||||||
|
using System.Net;
|
||||||
|
using System.Text.Json.Nodes;
|
||||||
|
|
||||||
|
namespace PrivaPub.Tests.Http
|
||||||
|
{
|
||||||
|
[Trait("Category", "Integration")]
|
||||||
|
public sealed class HostBootTests : IAsyncLifetime
|
||||||
|
{
|
||||||
|
PrivaPubHost _host;
|
||||||
|
|
||||||
|
public async ValueTask InitializeAsync()
|
||||||
|
{
|
||||||
|
Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip);
|
||||||
|
_host = await PrivaPubHost.Shared();
|
||||||
|
}
|
||||||
|
|
||||||
|
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task The_host_shares_the_fixture_database()
|
||||||
|
{
|
||||||
|
Assert.True(await DB.Default.Find<AppConfiguration>().ExecuteAnyAsync(TestContext.Current.CancellationToken));
|
||||||
|
using var client = _host.Client();
|
||||||
|
Assert.Equal(HttpStatusCode.OK, (await client.GetAsync("/build.json", TestContext.Current.CancellationToken)).StatusCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task The_harness_handles_the_same_activities_as_the_server()
|
||||||
|
{
|
||||||
|
await using var harness = await Harness.Start();
|
||||||
|
var registered = _host.Services.GetServices<IActivityHandler>().Select(h => h.GetType()).OrderBy(t => t.Name);
|
||||||
|
var harnessed = harness.Handlers.Select(h => h.GetType()).OrderBy(t => t.Name);
|
||||||
|
|
||||||
|
Assert.Equal(registered, harnessed);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Every_job_kind_has_exactly_one_handler()
|
||||||
|
{
|
||||||
|
var handlers = _host.Services.GetServices<IJobHandler>().GroupBy(h => h.Kind).ToDictionary(g => g.Key, g => g.Count());
|
||||||
|
|
||||||
|
foreach (var kind in Enum.GetValues<JobKind>())
|
||||||
|
Assert.True(handlers.GetValueOrDefault(kind) == 1, $"{kind} has {handlers.GetValueOrDefault(kind)} handlers");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Every_controller_and_page_model_can_be_made()
|
||||||
|
{
|
||||||
|
using var scope = _host.Services.CreateScope();
|
||||||
|
var controllers = _host.Get<IActionDescriptorCollectionProvider>().ActionDescriptors.Items
|
||||||
|
.OfType<ControllerActionDescriptor>()
|
||||||
|
.Select(d => d.ControllerTypeInfo.AsType())
|
||||||
|
.Distinct()
|
||||||
|
.ToList();
|
||||||
|
var pages = typeof(Program).Assembly.GetTypes().Where(t => t.IsSubclassOf(typeof(PageModel)) && !t.IsAbstract).ToList();
|
||||||
|
|
||||||
|
Assert.NotEmpty(controllers);
|
||||||
|
Assert.NotEmpty(pages);
|
||||||
|
foreach (var type in controllers.Concat(pages))
|
||||||
|
Assert.NotNull(ActivatorUtilities.CreateInstance(scope.ServiceProvider, type));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void The_service_graph_is_valid()
|
||||||
|
{
|
||||||
|
var services = new ServiceCollection();
|
||||||
|
foreach (var descriptor in _host.Registered)
|
||||||
|
((ICollection<ServiceDescriptor>)services).Add(descriptor);
|
||||||
|
|
||||||
|
using var provider = services.BuildServiceProvider(new ServiceProviderOptions { ValidateOnBuild = true, ValidateScopes = true });
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Swagger_is_not_served_outside_development()
|
||||||
|
{
|
||||||
|
using var client = _host.Client();
|
||||||
|
|
||||||
|
Assert.Equal(HttpStatusCode.NotFound, (await client.GetAsync("/swagger/index.html", TestContext.Current.CancellationToken)).StatusCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task A_persona_gets_a_mastodon_token_through_the_real_code_flow()
|
||||||
|
{
|
||||||
|
var root = await _host.SignUp();
|
||||||
|
var persona = await _host.Persona(root, "boot");
|
||||||
|
|
||||||
|
var token = await _host.MastodonToken(persona);
|
||||||
|
|
||||||
|
using var client = _host.As(token);
|
||||||
|
var account = await client.GetStringAsync("/api/v1/accounts/verify_credentials", TestContext.Current.CancellationToken);
|
||||||
|
Assert.Contains($"\"id\":\"{persona.Id}\"", account);
|
||||||
|
Assert.DoesNotContain(root.Id, account);
|
||||||
|
Assert.DoesNotContain(root.UserName, account);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task A_signed_delivery_through_the_real_route_is_queued_and_stored()
|
||||||
|
{
|
||||||
|
var token = TestContext.Current.CancellationToken;
|
||||||
|
await using var peer = await Peer.Start();
|
||||||
|
var persona = await _host.Persona(await _host.SignUp(), "mouth");
|
||||||
|
var bob = new RemoteActor(peer, "bob");
|
||||||
|
var noteId = $"{peer.A}/notes/{Guid.NewGuid():N}";
|
||||||
|
var activity = new JsonObject
|
||||||
|
{
|
||||||
|
["id"] = noteId + "/activity",
|
||||||
|
["type"] = "Create",
|
||||||
|
["actor"] = bob.Id,
|
||||||
|
["to"] = new JsonArray($"{PrivaPubHost.Base}/peasants/{persona.UserName}"),
|
||||||
|
["object"] = new JsonObject
|
||||||
|
{
|
||||||
|
["id"] = noteId,
|
||||||
|
["type"] = "Note",
|
||||||
|
["attributedTo"] = bob.Id,
|
||||||
|
["to"] = new JsonArray($"{PrivaPubHost.Base}/peasants/{persona.UserName}"),
|
||||||
|
["content"] = "<p>hello through the front door</p>",
|
||||||
|
["published"] = DateTime.UtcNow.ToString("O")
|
||||||
|
}
|
||||||
|
};
|
||||||
|
using var client = _host.Client();
|
||||||
|
|
||||||
|
var response = await client.SendAsync(bob.SignedPost($"/peasants/{persona.UserName}/mouth", activity), token);
|
||||||
|
|
||||||
|
Assert.Equal(HttpStatusCode.Accepted, response.StatusCode);
|
||||||
|
Assert.Equal(1, await _host.RunInbox(noteId + "/activity", token));
|
||||||
|
var stored = await DB.Default.Find<Post>().Match(p => p.ObjectURI == noteId).ExecuteFirstAsync(token);
|
||||||
|
Assert.Equal(PostVisibility.Direct, stored.Visibility);
|
||||||
|
Assert.Contains("front door", stored.ContentHtml);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
using Microsoft.AspNetCore.Http;
|
||||||
|
using Microsoft.AspNetCore.Routing;
|
||||||
|
using Microsoft.AspNetCore.Routing.Patterns;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
|
||||||
|
using PrivaPub.Tests.Support;
|
||||||
|
using PrivaPub.Tests.Support.Host;
|
||||||
|
|
||||||
|
using System.Net;
|
||||||
|
using System.Net.Http.Headers;
|
||||||
|
using System.Text;
|
||||||
|
using System.Text.Json.Nodes;
|
||||||
|
|
||||||
|
namespace PrivaPub.Tests.Http
|
||||||
|
{
|
||||||
|
[Trait("Category", "Integration")]
|
||||||
|
public sealed class NeverFiveHundredTests : IAsyncLifetime
|
||||||
|
{
|
||||||
|
static readonly string[] Junk = { "x", "000000000000000000000000", "%2e%2e", new string('a', 5000), "5f0c1a2b3c4d5e6f70819203" };
|
||||||
|
|
||||||
|
PrivaPubHost _host;
|
||||||
|
|
||||||
|
public async ValueTask InitializeAsync()
|
||||||
|
{
|
||||||
|
Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip);
|
||||||
|
_host = await PrivaPubHost.Shared();
|
||||||
|
}
|
||||||
|
|
||||||
|
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
|
||||||
|
|
||||||
|
public static TheoryData<string> Credentials() => new() { "anonymous", "persona", "junk" };
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[MemberData(nameof(Credentials))]
|
||||||
|
public async Task No_route_answers_500_to_junk(string credentials)
|
||||||
|
{
|
||||||
|
var token = TestContext.Current.CancellationToken;
|
||||||
|
var bearer = credentials switch
|
||||||
|
{
|
||||||
|
"persona" => await _host.MastodonToken(await _host.Persona(await _host.SignUp(), "junk")),
|
||||||
|
"junk" => "not-a-token",
|
||||||
|
_ => default
|
||||||
|
};
|
||||||
|
var jwt = credentials == "persona" ? (await _host.SignUp()).Jwt : bearer;
|
||||||
|
var failures = new List<string>();
|
||||||
|
|
||||||
|
foreach (var (method, template) in Routes())
|
||||||
|
foreach (var value in Junk)
|
||||||
|
{
|
||||||
|
var path = Fill(template, value);
|
||||||
|
using var request = new HttpRequestMessage(new HttpMethod(method), path);
|
||||||
|
request.Headers.Add(PrivaPubHost.ClientHeader, $"10.9.{Random.Shared.Next(256)}.{Random.Shared.Next(1, 255)}");
|
||||||
|
var auth = path.StartsWith("/api/", StringComparison.Ordinal) || path.StartsWith("/oauth/", StringComparison.Ordinal) ? bearer : jwt;
|
||||||
|
if (auth != default)
|
||||||
|
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", auth);
|
||||||
|
if (method is "POST" or "PUT" or "PATCH")
|
||||||
|
request.Content = new StringContent("{}", Encoding.UTF8, "application/json");
|
||||||
|
using var client = _host.Client();
|
||||||
|
HttpResponseMessage response;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
response = await client.SendAsync(request, token);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
failures.Add($"{method} {Short(path)} threw {ex.GetType().Name}: {ex.Message}");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
using (response)
|
||||||
|
{
|
||||||
|
if ((int)response.StatusCode >= 500)
|
||||||
|
failures.Add($"{method} {Short(path)} answered {(int)response.StatusCode}");
|
||||||
|
else if (path.StartsWith("/api/", StringComparison.Ordinal) && (int)response.StatusCode >= 400 && !await IsMastodonError(response))
|
||||||
|
failures.Add($"{method} {Short(path)} answered {(int)response.StatusCode} without a Mastodon error body");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Assert.True(failures.Count == 0, $"{failures.Count} problems as {credentials}:\n" + string.Join("\n", failures.Distinct().Take(80)));
|
||||||
|
}
|
||||||
|
|
||||||
|
IEnumerable<(string Method, string Template)> Routes()
|
||||||
|
{
|
||||||
|
var endpoints = _host.Services.GetRequiredService<EndpointDataSource>().Endpoints.OfType<RouteEndpoint>();
|
||||||
|
foreach (var endpoint in endpoints)
|
||||||
|
{
|
||||||
|
var methods = endpoint.Metadata.GetMetadata<IHttpMethodMetadata>()?.HttpMethods;
|
||||||
|
var template = "/" + endpoint.RoutePattern.RawText?.TrimStart('/');
|
||||||
|
foreach (var method in methods is { Count: > 0 } ? methods : new[] { "GET", "POST" })
|
||||||
|
yield return (method, template);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static string Fill(string template, string value)
|
||||||
|
{
|
||||||
|
var pattern = RoutePatternFactory.Parse(template);
|
||||||
|
var builder = new StringBuilder();
|
||||||
|
foreach (var segment in pattern.PathSegments)
|
||||||
|
{
|
||||||
|
builder.Append('/');
|
||||||
|
foreach (var part in segment.Parts)
|
||||||
|
builder.Append(part switch
|
||||||
|
{
|
||||||
|
RoutePatternLiteralPart literal => literal.Content,
|
||||||
|
RoutePatternParameterPart => value,
|
||||||
|
RoutePatternSeparatorPart separator => separator.Content,
|
||||||
|
_ => string.Empty
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return builder.Length == 0 ? "/" : builder.ToString();
|
||||||
|
}
|
||||||
|
|
||||||
|
static async Task<bool> IsMastodonError(HttpResponseMessage response)
|
||||||
|
{
|
||||||
|
var body = await response.Content.ReadAsStringAsync();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return JsonNode.Parse(body) is JsonObject error && error["error"] is JsonValue;
|
||||||
|
}
|
||||||
|
catch (System.Text.Json.JsonException)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static string Short(string path) => path.Length > 120 ? path[..120] + "…" : path;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
using Microsoft.Extensions.Caching.Memory;
|
||||||
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
|
|
||||||
|
using MongoDB.Entities;
|
||||||
|
|
||||||
|
using PrivaPub.Federation.Actors;
|
||||||
|
using PrivaPub.Federation.Outbox;
|
||||||
|
using PrivaPub.Infrastructure.Jobs;
|
||||||
|
using PrivaPub.Models;
|
||||||
|
using PrivaPub.Models.Jobs;
|
||||||
|
using PrivaPub.Models.User;
|
||||||
|
using PrivaPub.StaticServices;
|
||||||
|
using PrivaPub.Tests.Support;
|
||||||
|
|
||||||
|
using System.Text.Json.Nodes;
|
||||||
|
|
||||||
|
namespace PrivaPub.Tests.Infrastructure
|
||||||
|
{
|
||||||
|
[Xunit.Collection(nameof(Exclusive))]
|
||||||
|
[Trait("Category", "Integration")]
|
||||||
|
public sealed class DeliveryWorkerTests : IAsyncLifetime
|
||||||
|
{
|
||||||
|
static readonly string[] PeerHosts = { "localhost", "127.0.0.1" };
|
||||||
|
|
||||||
|
Peer _peer;
|
||||||
|
|
||||||
|
public async ValueTask InitializeAsync()
|
||||||
|
{
|
||||||
|
Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip);
|
||||||
|
_peer = await Peer.Start();
|
||||||
|
await DB.Default.DeleteAsync<RemoteInstance>(i => PeerHosts.Contains(i.Host));
|
||||||
|
}
|
||||||
|
|
||||||
|
public async ValueTask DisposeAsync()
|
||||||
|
{
|
||||||
|
if (_peer == default)
|
||||||
|
return;
|
||||||
|
await _peer.DisposeAsync();
|
||||||
|
await DB.Default.DeleteAsync<RemoteInstance>(i => PeerHosts.Contains(i.Host));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task A_dead_host_does_not_hold_up_deliveries_to_live_ones()
|
||||||
|
{
|
||||||
|
var token = TestContext.Current.CancellationToken;
|
||||||
|
var (privateKey, publicKey) = Keys.NewKeyPair();
|
||||||
|
var avatar = new Avatar { UserName = $"sender{Guid.NewGuid():N}"[..20], PrivateKey = privateKey, PublicKey = publicKey };
|
||||||
|
await DB.Default.SaveAsync(avatar, token);
|
||||||
|
var local = new LocalActorService(new DbEntities(), new StaticOptions<AppConfiguration>(new AppConfiguration { BackendBaseAddress = "https://privapub.test" }));
|
||||||
|
var sender = local.FromAvatar(avatar);
|
||||||
|
var run = $"https://privapub.test/a/{Guid.NewGuid():N}/";
|
||||||
|
var queue = new JobQueue(j => j.DedupeKey.StartsWith(run));
|
||||||
|
var delivery = new DeliveryService(new DbEntities(), queue);
|
||||||
|
_peer.Answer("/dead/inbox", 503, TimeSpan.FromMilliseconds(300));
|
||||||
|
_peer.Answer("/live/inbox", 202);
|
||||||
|
|
||||||
|
var deadInboxes = Enumerable.Range(0, 60).Select(i => $"{_peer.B}/dead/inbox?{i}");
|
||||||
|
await delivery.Enqueue(sender, deadInboxes, new JsonObject { ["id"] = run + "dead", ["type"] = "Create" }, token);
|
||||||
|
await delivery.Enqueue(sender, new[] { $"{_peer.A}/live/inbox" }, new JsonObject { ["id"] = run + "live", ["type"] = "Create" }, token);
|
||||||
|
|
||||||
|
var handler = new DeliveryJobHandler(local, Peer.Http(), new HostCircuitBreaker(new MemoryCache(new MemoryCacheOptions())), NullLogger<DeliveryJobHandler>.Instance);
|
||||||
|
using var worker = new JobWorker(queue, new IJobHandler[] { handler }, NullLogger<JobWorker>.Instance);
|
||||||
|
await worker.StartAsync(token);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await Until(() => Task.FromResult(_peer.Requests.Any(r => r.Path == "/live/inbox")), token);
|
||||||
|
var deadSoFar = _peer.Requests.Count(r => r.Path == "/dead/inbox");
|
||||||
|
var live = Assert.Single(_peer.Requests, r => r.Path == "/live/inbox");
|
||||||
|
Assert.Contains($"keyId=\"{sender.KeyId}\"", live.Signature);
|
||||||
|
Assert.True(deadSoFar < 30, $"{deadSoFar} dead deliveries were tried before the live one");
|
||||||
|
|
||||||
|
await Until(() => DB.Default.Find<Job>().Match(j => j.DedupeKey.StartsWith(run + "dead") && j.State == JobState.Pending && j.Attempts > 0).ExecuteAnyAsync(token), token);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
await worker.StopAsync(CancellationToken.None);
|
||||||
|
}
|
||||||
|
|
||||||
|
var retried = await DB.Default.Find<Job>().Match(j => j.DedupeKey.StartsWith(run + "dead") && j.Attempts > 0 && j.State == JobState.Pending).ExecuteFirstAsync(token);
|
||||||
|
Assert.Equal("503", retried.LastError?.Split(' ')[0]);
|
||||||
|
Assert.True(retried.RunAt > DateTime.UtcNow);
|
||||||
|
Assert.True((await DB.Default.Find<RemoteInstance>().Match(i => i.Host == "localhost").ExecuteFirstAsync(token))?.ConsecutiveFailures > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
static async Task Until(Func<Task<bool>> condition, CancellationToken token)
|
||||||
|
{
|
||||||
|
var deadline = DateTime.UtcNow.AddSeconds(30);
|
||||||
|
while (!await condition())
|
||||||
|
{
|
||||||
|
Assert.True(DateTime.UtcNow < deadline, "timed out");
|
||||||
|
await Task.Delay(50, token);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
using Microsoft.Extensions.Caching.Memory;
|
||||||
|
|
||||||
|
using MongoDB.Entities;
|
||||||
|
|
||||||
|
using PrivaPub.Infrastructure.Jobs;
|
||||||
|
using PrivaPub.Models.Jobs;
|
||||||
|
using PrivaPub.Tests.Support;
|
||||||
|
|
||||||
|
namespace PrivaPub.Tests.Infrastructure
|
||||||
|
{
|
||||||
|
[Trait("Category", "Integration")]
|
||||||
|
public sealed class HostCircuitBreakerTests : IAsyncLifetime
|
||||||
|
{
|
||||||
|
readonly string _host = $"breaker{Guid.NewGuid():N}.example";
|
||||||
|
|
||||||
|
public ValueTask InitializeAsync()
|
||||||
|
{
|
||||||
|
Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip);
|
||||||
|
return ValueTask.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async ValueTask DisposeAsync()
|
||||||
|
{
|
||||||
|
if (MongoFixture.Enabled)
|
||||||
|
await DB.Default.DeleteAsync<RemoteInstance>(i => i.Host == _host);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task A_host_is_quarantined_only_past_the_threshold_and_freed_by_a_success()
|
||||||
|
{
|
||||||
|
var token = TestContext.Current.CancellationToken;
|
||||||
|
var breaker = new HostCircuitBreaker(new MemoryCache(new MemoryCacheOptions()));
|
||||||
|
|
||||||
|
for (var i = 1; i < HostCircuitBreaker.Threshold; i++)
|
||||||
|
await breaker.Failed(_host, "503", token);
|
||||||
|
Assert.Null(await breaker.UnavailableUntil(_host, token));
|
||||||
|
|
||||||
|
await breaker.Failed(_host, "503", token);
|
||||||
|
var until = await breaker.UnavailableUntil(_host, token);
|
||||||
|
Assert.NotNull(until);
|
||||||
|
Assert.InRange(until.Value, DateTime.UtcNow.AddMinutes(55), DateTime.UtcNow.AddMinutes(65));
|
||||||
|
var instance = await DB.Default.Find<RemoteInstance>().Match(i => i.Host == _host).ExecuteFirstAsync(token);
|
||||||
|
Assert.Equal(HostCircuitBreaker.Threshold, instance.ConsecutiveFailures);
|
||||||
|
Assert.Equal("503", instance.LastError);
|
||||||
|
|
||||||
|
await breaker.Succeeded(_host, token);
|
||||||
|
Assert.Null(await breaker.UnavailableUntil(_host, token));
|
||||||
|
instance = await DB.Default.Find<RemoteInstance>().Match(i => i.Host == _host).ExecuteFirstAsync(token);
|
||||||
|
Assert.Equal(0, instance.ConsecutiveFailures);
|
||||||
|
Assert.NotNull(instance.LastSuccessAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task A_healthy_host_is_written_at_most_once_an_hour()
|
||||||
|
{
|
||||||
|
var token = TestContext.Current.CancellationToken;
|
||||||
|
var breaker = new HostCircuitBreaker(new MemoryCache(new MemoryCacheOptions()));
|
||||||
|
|
||||||
|
await breaker.Succeeded(_host, token);
|
||||||
|
var first = (await DB.Default.Find<RemoteInstance>().Match(i => i.Host == _host).ExecuteFirstAsync(token)).LastSuccessAt;
|
||||||
|
await breaker.Succeeded(_host, token);
|
||||||
|
var second = (await DB.Default.Find<RemoteInstance>().Match(i => i.Host == _host).ExecuteFirstAsync(token)).LastSuccessAt;
|
||||||
|
|
||||||
|
Assert.Equal(first, second);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@ using PrivaPub.Tests.Support;
|
|||||||
|
|
||||||
namespace PrivaPub.Tests.Infrastructure
|
namespace PrivaPub.Tests.Infrastructure
|
||||||
{
|
{
|
||||||
|
[Xunit.Collection(nameof(Exclusive))]
|
||||||
[Trait("Category", "Integration")]
|
[Trait("Category", "Integration")]
|
||||||
public class IndexTests
|
public class IndexTests
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,19 +1,9 @@
|
|||||||
using Microsoft.Extensions.Caching.Memory;
|
|
||||||
using Microsoft.Extensions.Logging.Abstractions;
|
|
||||||
|
|
||||||
using MongoDB.Entities;
|
using MongoDB.Entities;
|
||||||
|
|
||||||
using PrivaPub.Federation.Actors;
|
|
||||||
using PrivaPub.Federation.Outbox;
|
|
||||||
using PrivaPub.Infrastructure.Jobs;
|
using PrivaPub.Infrastructure.Jobs;
|
||||||
using PrivaPub.Models;
|
|
||||||
using PrivaPub.Models.Jobs;
|
using PrivaPub.Models.Jobs;
|
||||||
using PrivaPub.Models.User;
|
|
||||||
using PrivaPub.StaticServices;
|
|
||||||
using PrivaPub.Tests.Support;
|
using PrivaPub.Tests.Support;
|
||||||
|
|
||||||
using System.Text.Json.Nodes;
|
|
||||||
|
|
||||||
namespace PrivaPub.Tests.Infrastructure
|
namespace PrivaPub.Tests.Infrastructure
|
||||||
{
|
{
|
||||||
public class BackoffTests
|
public class BackoffTests
|
||||||
@@ -38,19 +28,13 @@ namespace PrivaPub.Tests.Infrastructure
|
|||||||
[Trait("Category", "Integration")]
|
[Trait("Category", "Integration")]
|
||||||
public sealed class JobQueueTests : IAsyncLifetime
|
public sealed class JobQueueTests : IAsyncLifetime
|
||||||
{
|
{
|
||||||
Peer _peer;
|
public ValueTask InitializeAsync()
|
||||||
|
|
||||||
public async ValueTask InitializeAsync()
|
|
||||||
{
|
{
|
||||||
Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip);
|
Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip);
|
||||||
_peer = await Peer.Start();
|
return ValueTask.CompletedTask;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async ValueTask DisposeAsync()
|
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
|
||||||
{
|
|
||||||
if (_peer != default)
|
|
||||||
await _peer.DisposeAsync();
|
|
||||||
}
|
|
||||||
|
|
||||||
static Job NewJob(string host = default, string dedupe = default) => new()
|
static Job NewJob(string host = default, string dedupe = default) => new()
|
||||||
{
|
{
|
||||||
@@ -102,44 +86,9 @@ namespace PrivaPub.Tests.Infrastructure
|
|||||||
var job = new Job { Kind = (JobKind)98, State = JobState.Running, LeasedUntil = DateTime.UtcNow.AddMinutes(-1) };
|
var job = new Job { Kind = (JobKind)98, State = JobState.Running, LeasedUntil = DateTime.UtcNow.AddMinutes(-1) };
|
||||||
await DB.Default.SaveAsync(job, token);
|
await DB.Default.SaveAsync(job, token);
|
||||||
|
|
||||||
await new JobQueue().Reap(token);
|
await new JobQueue(j => j.ID == job.ID).Reap(token);
|
||||||
|
|
||||||
Assert.Equal(JobState.Pending, (await DB.Default.Find<Job>().OneAsync(job.ID, token)).State);
|
Assert.Equal(JobState.Pending, (await DB.Default.Find<Job>().OneAsync(job.ID, token)).State);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task A_dead_host_does_not_hold_up_deliveries_to_live_ones()
|
|
||||||
{
|
|
||||||
var token = TestContext.Current.CancellationToken;
|
|
||||||
var (privateKey, publicKey) = Keys.NewKeyPair();
|
|
||||||
var avatar = new Avatar { UserName = $"sender{Guid.NewGuid():N}"[..20], PrivateKey = privateKey, PublicKey = publicKey };
|
|
||||||
await DB.Default.SaveAsync(avatar, token);
|
|
||||||
var local = new LocalActorService(new DbEntities(), new StaticOptions<AppConfiguration>(new AppConfiguration { BackendBaseAddress = "https://privapub.test" }));
|
|
||||||
var sender = local.FromAvatar(avatar);
|
|
||||||
var queue = new JobQueue();
|
|
||||||
var delivery = new DeliveryService(new DbEntities(), queue);
|
|
||||||
_peer.Answer("/dead/inbox", 503, TimeSpan.FromMilliseconds(300));
|
|
||||||
_peer.Answer("/live/inbox", 202);
|
|
||||||
|
|
||||||
var deadInboxes = Enumerable.Range(0, 60).Select(i => $"{_peer.B}/dead/inbox?{i}");
|
|
||||||
await delivery.Enqueue(sender, deadInboxes, new JsonObject { ["id"] = $"https://privapub.test/a/{Guid.NewGuid():N}", ["type"] = "Create" }, token);
|
|
||||||
await delivery.Enqueue(sender, new[] { $"{_peer.A}/live/inbox" }, new JsonObject { ["id"] = $"https://privapub.test/a/{Guid.NewGuid():N}", ["type"] = "Create" }, token);
|
|
||||||
|
|
||||||
var handler = new DeliveryJobHandler(local, Peer.Http(), new HostCircuitBreaker(new MemoryCache(new MemoryCacheOptions())), NullLogger<DeliveryJobHandler>.Instance);
|
|
||||||
using var worker = new JobWorker(queue, new IJobHandler[] { handler }, NullLogger<JobWorker>.Instance);
|
|
||||||
await worker.StartAsync(token);
|
|
||||||
var deadline = DateTime.UtcNow.AddSeconds(5);
|
|
||||||
while (DateTime.UtcNow < deadline && !_peer.Requests.Any(r => r.Path == "/live/inbox"))
|
|
||||||
await Task.Delay(50, token);
|
|
||||||
var live = Assert.Single(_peer.Requests, r => r.Path == "/live/inbox");
|
|
||||||
Assert.True(_peer.Requests.Count(r => r.Path == "/dead/inbox") < 30);
|
|
||||||
while (DateTime.UtcNow < deadline && !await DB.Default.Find<RemoteInstance>().Match(i => i.Host == "localhost").ExecuteAnyAsync(token))
|
|
||||||
await Task.Delay(50, token);
|
|
||||||
await worker.StopAsync(token);
|
|
||||||
|
|
||||||
Assert.Contains($"keyId=\"{sender.KeyId}\"", live.Signature);
|
|
||||||
var instance = await DB.Default.Find<RemoteInstance>().Match(i => i.Host == "localhost").ExecuteFirstAsync(token);
|
|
||||||
Assert.True(instance.ConsecutiveFailures > 0);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -6,6 +6,7 @@ using PrivaPub.Tests.Support;
|
|||||||
|
|
||||||
namespace PrivaPub.Tests.Infrastructure
|
namespace PrivaPub.Tests.Infrastructure
|
||||||
{
|
{
|
||||||
|
[Xunit.Collection(nameof(Exclusive))]
|
||||||
[Trait("Category", "Integration")]
|
[Trait("Category", "Integration")]
|
||||||
public class MigrationTests
|
public class MigrationTests
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.9" />
|
||||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.10.1" />
|
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.10.1" />
|
||||||
<PackageReference Include="xunit.v3" Version="3.2.2" />
|
<PackageReference Include="xunit.v3" Version="3.2.2" />
|
||||||
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.5" />
|
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.5" />
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
namespace PrivaPub.Tests.Support
|
||||||
|
{
|
||||||
|
[CollectionDefinition(nameof(Exclusive), DisableParallelization = true)]
|
||||||
|
public sealed class Exclusive
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -34,6 +34,8 @@ namespace PrivaPub.Tests.Support
|
|||||||
public const string Host = "privapub.test";
|
public const string Host = "privapub.test";
|
||||||
public const string Base = "https://" + Host;
|
public const string Base = "https://" + Host;
|
||||||
|
|
||||||
|
readonly DateTime _started = Millisecond(DateTime.UtcNow);
|
||||||
|
|
||||||
Harness(Peer peer)
|
Harness(Peer peer)
|
||||||
{
|
{
|
||||||
Peer = peer;
|
Peer = peer;
|
||||||
@@ -50,7 +52,7 @@ namespace PrivaPub.Tests.Support
|
|||||||
Outbox = new OutboxPublisher(Db, Local, Delivery);
|
Outbox = new OutboxPublisher(Db, Local, Delivery);
|
||||||
Quotes = new QuoteService(Db, Remote, RemotePosts, Local, Delivery, Outbox);
|
Quotes = new QuoteService(Db, Remote, RemotePosts, Local, Delivery, Outbox);
|
||||||
Receiver = new InboxReceiver(Local, Remote, Queue, new NoBlocks(), NullLogger<InboxReceiver>.Instance);
|
Receiver = new InboxReceiver(Local, Remote, Queue, new NoBlocks(), NullLogger<InboxReceiver>.Instance);
|
||||||
Processor = new InboxProcessor(Remote, new IActivityHandler[]
|
Handlers = new IActivityHandler[]
|
||||||
{
|
{
|
||||||
new FollowHandler(Db, Local, Remote, Delivery),
|
new FollowHandler(Db, Local, Remote, Delivery),
|
||||||
new AcceptHandler(Db, Local, Quotes),
|
new AcceptHandler(Db, Local, Quotes),
|
||||||
@@ -66,7 +68,8 @@ namespace PrivaPub.Tests.Support
|
|||||||
new DeleteHandler(Db, Local, Remote, Delivery, Groups, Quotes),
|
new DeleteHandler(Db, Local, Remote, Delivery, Groups, Quotes),
|
||||||
new UpdateHandler(Db, Local, Remote, Groups, Records, Quotes),
|
new UpdateHandler(Db, Local, Remote, Groups, Records, Quotes),
|
||||||
new FlagHandler(Db, Local)
|
new FlagHandler(Db, Local)
|
||||||
}, NullLogger<InboxProcessor>.Instance);
|
};
|
||||||
|
Processor = new InboxProcessor(Remote, Handlers, NullLogger<InboxProcessor>.Instance);
|
||||||
Follows = new FollowService(Db, Local, Remote, Delivery, new KeyLocalizer<GenericRes>(), NullLogger<FollowService>.Instance);
|
Follows = new FollowService(Db, Local, Remote, Delivery, new KeyLocalizer<GenericRes>(), NullLogger<FollowService>.Instance);
|
||||||
Content = new ContentRenderer(Local, Remote);
|
Content = new ContentRenderer(Local, Remote);
|
||||||
Media = new MediaService(new StaticOptions<MediaOptions>(new MediaOptions { Root = Path.Combine(Path.GetTempPath(), $"privapub-media-{Guid.NewGuid():N}") }),
|
Media = new MediaService(new StaticOptions<MediaOptions>(new MediaOptions { Root = Path.Combine(Path.GetTempPath(), $"privapub-media-{Guid.NewGuid():N}") }),
|
||||||
@@ -86,6 +89,7 @@ namespace PrivaPub.Tests.Support
|
|||||||
public DeliveryService Delivery { get; }
|
public DeliveryService Delivery { get; }
|
||||||
public InboxReceiver Receiver { get; }
|
public InboxReceiver Receiver { get; }
|
||||||
public InboxProcessor Processor { get; }
|
public InboxProcessor Processor { get; }
|
||||||
|
public IReadOnlyList<IActivityHandler> Handlers { get; }
|
||||||
public FollowService Follows { get; }
|
public FollowService Follows { get; }
|
||||||
public ContentRenderer Content { get; }
|
public ContentRenderer Content { get; }
|
||||||
public OutboxPublisher Outbox { get; }
|
public OutboxPublisher Outbox { get; }
|
||||||
@@ -136,13 +140,15 @@ namespace PrivaPub.Tests.Support
|
|||||||
}
|
}
|
||||||
|
|
||||||
public async Task<List<JsonObject>> Outgoing(string inbox) =>
|
public async Task<List<JsonObject>> Outgoing(string inbox) =>
|
||||||
(await DB.Default.Find<Job>().Match(j => j.Kind == JobKind.Deliver).ExecuteAsync())
|
(await DB.Default.Find<Job>().Match(j => j.Kind == JobKind.Deliver && j.CreatedAt >= _started).ExecuteAsync())
|
||||||
.Select(j => JsonSerializer.Deserialize<DeliveryPayload>(j.Payload))
|
.Select(j => JsonSerializer.Deserialize<DeliveryPayload>(j.Payload))
|
||||||
.Where(p => p.Inbox == inbox)
|
.Where(p => p.Inbox == inbox)
|
||||||
.Select(p => JsonNode.Parse(p.Body)!.AsObject())
|
.Select(p => JsonNode.Parse(p.Body)!.AsObject())
|
||||||
.ToList();
|
.ToList();
|
||||||
|
|
||||||
public async ValueTask DisposeAsync() => await Peer.DisposeAsync();
|
public async ValueTask DisposeAsync() => await Peer.DisposeAsync();
|
||||||
|
|
||||||
|
static DateTime Millisecond(DateTime time) => new(time.Ticks - time.Ticks % TimeSpan.TicksPerMillisecond, DateTimeKind.Utc);
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed class KeyLocalizer<T> : IStringLocalizer<T>
|
public sealed class KeyLocalizer<T> : IStringLocalizer<T>
|
||||||
|
|||||||
@@ -0,0 +1,117 @@
|
|||||||
|
using PrivaPub.Infrastructure.Cli;
|
||||||
|
|
||||||
|
using System.Net;
|
||||||
|
using System.Net.Http.Headers;
|
||||||
|
using System.Net.Http.Json;
|
||||||
|
using System.Text.Json.Nodes;
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
|
|
||||||
|
namespace PrivaPub.Tests.Support.Host
|
||||||
|
{
|
||||||
|
public sealed record Root(string Id, string UserName, string Password, string Jwt);
|
||||||
|
|
||||||
|
public sealed record Persona(string Id, string UserName, Root Root);
|
||||||
|
|
||||||
|
public static partial class Accounts
|
||||||
|
{
|
||||||
|
public const string Password = "Test-Pass-1!";
|
||||||
|
const string OutOfBand = "urn:ietf:wg:oauth:2.0:oob";
|
||||||
|
|
||||||
|
public static async Task<Root> SignUp(this PrivaPubHost host, string name = "root")
|
||||||
|
{
|
||||||
|
var userName = $"{name}{Guid.NewGuid():N}"[..24];
|
||||||
|
using var client = host.Client();
|
||||||
|
var response = await client.PostAsJsonAsync("/clientapi/user/signup", new { userName, password = Password });
|
||||||
|
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||||
|
var jwt = (await response.Content.ReadFromJsonAsync<JsonObject>())!;
|
||||||
|
return new Root(jwt["userId"]!.GetValue<string>(), userName, Password, jwt["token"]!.GetValue<string>());
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async Task<Root> LogIn(this PrivaPubHost host, Root root)
|
||||||
|
{
|
||||||
|
using var client = host.Client();
|
||||||
|
var response = await client.PostAsJsonAsync("/clientapi/user/login", new { userName = root.UserName, password = root.Password });
|
||||||
|
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||||
|
var jwt = (await response.Content.ReadFromJsonAsync<JsonObject>())!;
|
||||||
|
return root with { Jwt = jwt["token"]!.GetValue<string>() };
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async Task<Root> Admin(this PrivaPubHost host)
|
||||||
|
{
|
||||||
|
var root = await host.SignUp("admin");
|
||||||
|
Assert.Equal(0, await AdminCommands.Run(new[] { "promote", root.UserName }));
|
||||||
|
return await host.LogIn(root);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async Task<Persona> Persona(this PrivaPubHost host, Root root, string name = "persona")
|
||||||
|
{
|
||||||
|
var userName = $"{name}{Guid.NewGuid():N}"[..20];
|
||||||
|
using var client = host.As(root.Jwt);
|
||||||
|
var response = await client.PostAsJsonAsync("/clientapi/avatar/private/insert", new { userName, name, biography = "testing" });
|
||||||
|
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||||
|
var avatar = (await response.Content.ReadFromJsonAsync<JsonObject>())!;
|
||||||
|
return new Persona(avatar["id"]!.GetValue<string>(), userName, root);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async Task<string> MastodonToken(this PrivaPubHost host, Persona persona, string scopes = "read write follow")
|
||||||
|
{
|
||||||
|
using var client = host.Client(cookies: true);
|
||||||
|
var app = await Form(client, "/api/v1/apps", ("client_name", "privapub-tests"), ("redirect_uris", OutOfBand), ("scopes", scopes));
|
||||||
|
var clientId = app["client_id"]!.GetValue<string>();
|
||||||
|
var clientSecret = app["client_secret"]!.GetValue<string>();
|
||||||
|
var query = $"client_id={Uri.EscapeDataString(clientId)}&redirect_uri={Uri.EscapeDataString(OutOfBand)}&response_type=code&scope={Uri.EscapeDataString(scopes)}";
|
||||||
|
|
||||||
|
var code = await Authorize(client, persona, query);
|
||||||
|
var token = await Form(client, "/oauth/token", ("grant_type", "authorization_code"), ("code", code), ("client_id", clientId),
|
||||||
|
("client_secret", clientSecret), ("redirect_uri", OutOfBand));
|
||||||
|
return token["access_token"]!.GetValue<string>();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async Task<string> Authorize(HttpClient client, Persona persona, string query, string decision = "allow")
|
||||||
|
{
|
||||||
|
var returnUrl = "/oauth/authorize?" + query;
|
||||||
|
var login = await client.GetStringAsync("/oauth/login?returnUrl=" + Uri.EscapeDataString(returnUrl));
|
||||||
|
var antiforgery = AntiforgeryToken().Match(login).Groups[1].Value;
|
||||||
|
var signedIn = await client.PostAsync("/oauth/login", new FormUrlEncodedContent(new Dictionary<string, string>
|
||||||
|
{
|
||||||
|
["returnUrl"] = returnUrl,
|
||||||
|
["__RequestVerificationToken"] = antiforgery,
|
||||||
|
["userName"] = persona.Root.UserName,
|
||||||
|
["password"] = persona.Root.Password
|
||||||
|
}));
|
||||||
|
Assert.Equal(HttpStatusCode.Redirect, signedIn.StatusCode);
|
||||||
|
|
||||||
|
var choose = await client.GetStringAsync(returnUrl + "&signed_in=1");
|
||||||
|
var fields = HiddenInput().Matches(choose).Select(m => new KeyValuePair<string, string>(m.Groups[1].Value, WebUtility.HtmlDecode(m.Groups[2].Value))).ToList();
|
||||||
|
fields.Add(new("avatarId", persona.Id));
|
||||||
|
fields.Add(new("decision", decision));
|
||||||
|
var answer = await client.PostAsync("/oauth/authorize", new FormUrlEncodedContent(fields));
|
||||||
|
var page = await answer.Content.ReadAsStringAsync();
|
||||||
|
return Code().Match(page) is { Success: true } match ? match.Groups[1].Value : default;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static HttpClient As(this PrivaPubHost host, string bearer)
|
||||||
|
{
|
||||||
|
var client = host.Client();
|
||||||
|
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", bearer);
|
||||||
|
return client;
|
||||||
|
}
|
||||||
|
|
||||||
|
static async Task<JsonObject> Form(HttpClient client, string path, params (string Key, string Value)[] fields)
|
||||||
|
{
|
||||||
|
var response = await client.PostAsync(path, new FormUrlEncodedContent(fields.Select(f => new KeyValuePair<string, string>(f.Key, f.Value))));
|
||||||
|
var body = await response.Content.ReadAsStringAsync();
|
||||||
|
Assert.True(response.IsSuccessStatusCode, $"{path} answered {(int)response.StatusCode}: {body}");
|
||||||
|
return JsonNode.Parse(body)!.AsObject();
|
||||||
|
}
|
||||||
|
|
||||||
|
[GeneratedRegex("name=\"__RequestVerificationToken\" type=\"hidden\" value=\"([^\"]*)\"")]
|
||||||
|
private static partial Regex AntiforgeryToken();
|
||||||
|
|
||||||
|
[GeneratedRegex("<input type=\"hidden\" name=\"([^\"]*)\" value=\"([^\"]*)\"")]
|
||||||
|
private static partial Regex HiddenInput();
|
||||||
|
|
||||||
|
[GeneratedRegex("<code>([^<]*)</code>")]
|
||||||
|
private static partial Regex Code();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
|
||||||
|
using MongoDB.Entities;
|
||||||
|
|
||||||
|
using PrivaPub.Federation.Outbox;
|
||||||
|
using PrivaPub.Infrastructure.Jobs;
|
||||||
|
using PrivaPub.Models.Jobs;
|
||||||
|
|
||||||
|
using System.Linq.Expressions;
|
||||||
|
using System.Text.Json;
|
||||||
|
using System.Text.Json.Nodes;
|
||||||
|
|
||||||
|
namespace PrivaPub.Tests.Support.Host
|
||||||
|
{
|
||||||
|
public static class Jobs
|
||||||
|
{
|
||||||
|
public static async Task<int> Run(this PrivaPubHost host, Expression<Func<Job, bool>> which, CancellationToken token = default)
|
||||||
|
{
|
||||||
|
var handlers = host.Services.GetServices<IJobHandler>().ToDictionary(h => h.Kind);
|
||||||
|
var queue = host.Get<IJobQueue>();
|
||||||
|
var ran = 0;
|
||||||
|
for (var round = 0; round < 50; round++)
|
||||||
|
{
|
||||||
|
var pending = await DB.Default.Find<Job>().Match(which).Match(j => j.State == JobState.Pending).ExecuteAsync(token);
|
||||||
|
if (pending.Count == 0)
|
||||||
|
return ran;
|
||||||
|
foreach (var candidate in pending)
|
||||||
|
{
|
||||||
|
var job = await DB.Default.UpdateAndGet<Job>()
|
||||||
|
.Match(j => j.ID == candidate.ID && j.State == JobState.Pending)
|
||||||
|
.Modify(j => j.State, JobState.Running)
|
||||||
|
.Modify(j => j.LeasedUntil, DateTime.UtcNow + JobQueue.LeaseTime)
|
||||||
|
.Modify(b => b.Inc(j => j.Attempts, 1))
|
||||||
|
.ExecuteAsync(token);
|
||||||
|
if (job == default)
|
||||||
|
continue;
|
||||||
|
var handler = handlers[job.Kind];
|
||||||
|
JobOutcome outcome;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
outcome = await handler.Handle(job, token);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
outcome = JobOutcome.Dead(ex.GetType().Name + ": " + ex.Message);
|
||||||
|
}
|
||||||
|
await queue.Finish(job, outcome, handler.MaxAttempts, token);
|
||||||
|
ran++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ran;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static Task<int> RunInbox(this PrivaPubHost host, string activityId, CancellationToken token = default) =>
|
||||||
|
host.Run(j => j.Kind == JobKind.ProcessInbox && j.DedupeKey == "inbox|" + activityId, token);
|
||||||
|
|
||||||
|
public static async Task<List<JsonObject>> Deliveries(string inbox, DateTime since, CancellationToken token = default) =>
|
||||||
|
(await DB.Default.Find<Job>().Match(j => j.Kind == JobKind.Deliver && j.CreatedAt >= since).ExecuteAsync(token))
|
||||||
|
.Select(j => JsonSerializer.Deserialize<DeliveryPayload>(j.Payload))
|
||||||
|
.Where(p => p.Inbox == inbox)
|
||||||
|
.Select(p => JsonNode.Parse(p.Body)!.AsObject())
|
||||||
|
.ToList();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
using Microsoft.AspNetCore.Builder;
|
||||||
|
using Microsoft.AspNetCore.DataProtection;
|
||||||
|
using Microsoft.AspNetCore.Hosting;
|
||||||
|
using Microsoft.AspNetCore.Http;
|
||||||
|
using Microsoft.AspNetCore.Mvc.Testing;
|
||||||
|
using Microsoft.AspNetCore.TestHost;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Microsoft.Extensions.Hosting;
|
||||||
|
|
||||||
|
using PrivaPub.Api.Mastodon.Auth;
|
||||||
|
using PrivaPub.Domain.Media;
|
||||||
|
using PrivaPub.Infrastructure.Jobs;
|
||||||
|
|
||||||
|
using System.Net;
|
||||||
|
|
||||||
|
namespace PrivaPub.Tests.Support.Host
|
||||||
|
{
|
||||||
|
public class PrivaPubHost : WebApplicationFactory<Program>
|
||||||
|
{
|
||||||
|
public const string Host = "privapub.test";
|
||||||
|
public const string Base = "https://" + Host;
|
||||||
|
public const string ClientHeader = "X-Test-Client";
|
||||||
|
|
||||||
|
static readonly SemaphoreSlim Boot = new(1, 1);
|
||||||
|
static readonly Type[] Unwanted = { typeof(JobWorker), typeof(MediaJanitor), typeof(OAuthPruner) };
|
||||||
|
static PrivaPubHost _shared;
|
||||||
|
|
||||||
|
readonly string _mediaRoot = Path.Combine(Path.GetTempPath(), $"privapub-tests-{Guid.NewGuid():N}");
|
||||||
|
|
||||||
|
public IReadOnlyList<ServiceDescriptor> Registered { get; private set; }
|
||||||
|
|
||||||
|
public static async Task<PrivaPubHost> Shared()
|
||||||
|
{
|
||||||
|
await Boot.WaitAsync();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (_shared == default)
|
||||||
|
{
|
||||||
|
var host = new PrivaPubHost();
|
||||||
|
_ = host.Services;
|
||||||
|
_shared = host;
|
||||||
|
}
|
||||||
|
return _shared;
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
Boot.Release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected virtual IEnumerable<KeyValuePair<string, string>> Settings() => new Dictionary<string, string>
|
||||||
|
{
|
||||||
|
["MongoSettings:ConnectionString"] = MongoFixture.Connection,
|
||||||
|
["MongoSettings:Database"] = MongoFixture.Database,
|
||||||
|
["MongoSettings:LogsDatabase"] = "logs",
|
||||||
|
["AppConfiguration:Version"] = "0.0.0-test",
|
||||||
|
["AppConfiguration:BackendBaseAddress"] = Base,
|
||||||
|
["AppConfiguration:MaxAllowedUploadFiles"] = "3",
|
||||||
|
["AppConfiguration:MaxAllowedFileSize"] = "2097152",
|
||||||
|
["AppConfiguration:SupportedLanguages:0"] = "en",
|
||||||
|
["AppConfiguration:SupportedLanguages:1"] = "it",
|
||||||
|
["AppConfiguration:HashingOptions:Iterations"] = "1000",
|
||||||
|
["AppConfiguration:Jwt:Key"] = "privapub-tests-only-signing-key-0123456789abcdef0123456789abcdef",
|
||||||
|
["AppConfiguration:Jwt:Issuer"] = Base,
|
||||||
|
["AppConfiguration:Jwt:Audience"] = Base,
|
||||||
|
["AppConfiguration:Jwt:HoursTimeout"] = "1",
|
||||||
|
["AppConfiguration:EmailConfiguration:SmtpServer"] = "127.0.0.1",
|
||||||
|
["AppConfiguration:EmailConfiguration:SmtpPort"] = "9",
|
||||||
|
["AppConfiguration:EmailConfiguration:UseSSL"] = "false",
|
||||||
|
["AppConfiguration:EmailConfiguration:SmtpUsername"] = "nobody@privapub.test",
|
||||||
|
["AppConfiguration:EmailConfiguration:SmtpPassword"] = "none",
|
||||||
|
["Federation:AllowPrivateNetworks"] = "true",
|
||||||
|
["Federation:AllowPlainHttp"] = "true",
|
||||||
|
["Federation:FetchLinkPreviews"] = "false",
|
||||||
|
["Media:Root"] = _mediaRoot,
|
||||||
|
["Logging:LogLevel:Default"] = "Warning",
|
||||||
|
["Serilog:MinimumLevel:Default"] = Environment.GetEnvironmentVariable("PRIVAPUB_TEST_LOGS") == "1" ? "Information" : "Fatal"
|
||||||
|
};
|
||||||
|
|
||||||
|
protected override void ConfigureWebHost(IWebHostBuilder builder)
|
||||||
|
{
|
||||||
|
builder.UseEnvironment("Testing");
|
||||||
|
foreach (var (key, value) in Settings())
|
||||||
|
builder.UseSetting(key, value);
|
||||||
|
builder.ConfigureTestServices(services =>
|
||||||
|
{
|
||||||
|
foreach (var hosted in services.Where(s => s.ServiceType == typeof(IHostedService) && Unwanted.Contains(s.ImplementationType)).ToList())
|
||||||
|
services.Remove(hosted);
|
||||||
|
services.AddDataProtection().UseEphemeralDataProtectionProvider();
|
||||||
|
services.AddSingleton<IStartupFilter, ClientAddressFilter>();
|
||||||
|
Registered = services.ToList();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override void ConfigureClient(HttpClient client)
|
||||||
|
{
|
||||||
|
client.BaseAddress = new Uri(Base + "/");
|
||||||
|
client.DefaultRequestHeaders.Add(ClientHeader, $"10.{Random.Shared.Next(256)}.{Random.Shared.Next(256)}.{Random.Shared.Next(1, 255)}");
|
||||||
|
}
|
||||||
|
|
||||||
|
public HttpClient Client(bool cookies = false) => CreateClient(new WebApplicationFactoryClientOptions
|
||||||
|
{
|
||||||
|
BaseAddress = new Uri(Base + "/"),
|
||||||
|
AllowAutoRedirect = false,
|
||||||
|
HandleCookies = cookies
|
||||||
|
});
|
||||||
|
|
||||||
|
public T Get<T>() where T : notnull => Services.GetRequiredService<T>();
|
||||||
|
|
||||||
|
protected override void Dispose(bool disposing)
|
||||||
|
{
|
||||||
|
base.Dispose(disposing);
|
||||||
|
if (disposing && Directory.Exists(_mediaRoot))
|
||||||
|
Directory.Delete(_mediaRoot, recursive: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
sealed class ClientAddressFilter : IStartupFilter
|
||||||
|
{
|
||||||
|
public Action<IApplicationBuilder> Configure(Action<IApplicationBuilder> next) => app =>
|
||||||
|
{
|
||||||
|
app.Use(async (context, call) =>
|
||||||
|
{
|
||||||
|
if (IPAddress.TryParse(context.Request.Headers[ClientHeader].ToString(), out var address))
|
||||||
|
context.Connection.RemoteIpAddress = address;
|
||||||
|
await call(context);
|
||||||
|
});
|
||||||
|
next(app);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class SecureModeHost : PrivaPubHost
|
||||||
|
{
|
||||||
|
static readonly SemaphoreSlim Boot = new(1, 1);
|
||||||
|
static SecureModeHost _shared;
|
||||||
|
|
||||||
|
public static new async Task<SecureModeHost> Shared()
|
||||||
|
{
|
||||||
|
await PrivaPubHost.Shared();
|
||||||
|
await Boot.WaitAsync();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (_shared == default)
|
||||||
|
{
|
||||||
|
var host = new SecureModeHost();
|
||||||
|
_ = host.Services;
|
||||||
|
_shared = host;
|
||||||
|
}
|
||||||
|
return _shared;
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
Boot.Release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override IEnumerable<KeyValuePair<string, string>> Settings() =>
|
||||||
|
base.Settings().Append(new KeyValuePair<string, string>("Federation:SecureMode", "true"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,15 +13,24 @@ namespace PrivaPub.Tests.Support
|
|||||||
public sealed class MongoFixture : IAsyncLifetime
|
public sealed class MongoFixture : IAsyncLifetime
|
||||||
{
|
{
|
||||||
public const string Skip = "set PRIVAPUB_TEST_MONGOD=1 (and optionally PRIVAPUB_TEST_MONGO) to run the tests that need a mongod";
|
public const string Skip = "set PRIVAPUB_TEST_MONGOD=1 (and optionally PRIVAPUB_TEST_MONGO) to run the tests that need a mongod";
|
||||||
|
const int ProductionPort = 27022;
|
||||||
|
const int DefaultPort = 27017;
|
||||||
|
|
||||||
public static bool Enabled => Environment.GetEnvironmentVariable("PRIVAPUB_TEST_MONGOD") == "1";
|
public static bool Enabled => Environment.GetEnvironmentVariable("PRIVAPUB_TEST_MONGOD") == "1";
|
||||||
|
static bool Required => Environment.GetEnvironmentVariable("PRIVAPUB_TEST_REQUIRE_MONGOD") == "1";
|
||||||
|
static bool InCi => !string.IsNullOrEmpty(Environment.GetEnvironmentVariable("CI")) || !string.IsNullOrEmpty(Environment.GetEnvironmentVariable("GITEA_ACTIONS"));
|
||||||
|
|
||||||
public string Database { get; } = $"PrivaPubTests_{Guid.NewGuid():N}";
|
public static string Connection { get; } = Environment.GetEnvironmentVariable("PRIVAPUB_TEST_MONGO") ?? $"mongodb://127.0.0.1:{DefaultPort}";
|
||||||
|
public static string Database { get; } = $"PrivaPubTests_{Guid.NewGuid():N}";
|
||||||
|
|
||||||
public async ValueTask InitializeAsync()
|
public async ValueTask InitializeAsync()
|
||||||
{
|
{
|
||||||
|
if (Required && !Enabled)
|
||||||
|
throw new InvalidOperationException("PRIVAPUB_TEST_REQUIRE_MONGOD=1 but PRIVAPUB_TEST_MONGOD is not 1: the integration tests would be skipped");
|
||||||
if (!Enabled)
|
if (!Enabled)
|
||||||
return;
|
return;
|
||||||
|
var settings = MongoClientSettings.FromConnectionString(Connection);
|
||||||
|
Refuse(settings);
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
BsonSerializer.RegisterSerializer(new GuidSerializer(GuidRepresentation.Standard));
|
BsonSerializer.RegisterSerializer(new GuidSerializer(GuidRepresentation.Standard));
|
||||||
@@ -29,9 +38,10 @@ namespace PrivaPub.Tests.Support
|
|||||||
catch (BsonSerializationException)
|
catch (BsonSerializationException)
|
||||||
{
|
{
|
||||||
}
|
}
|
||||||
var connection = Environment.GetEnvironmentVariable("PRIVAPUB_TEST_MONGO") ?? "mongodb://127.0.0.1:27017";
|
await DB.InitAsync(Database, settings);
|
||||||
await DB.InitAsync(Database, MongoClientSettings.FromConnectionString(connection));
|
await RefuseProductionData();
|
||||||
EntityMaps.Warm();
|
EntityMaps.Warm();
|
||||||
|
await DB.Default.MigrateAsync<Program>();
|
||||||
await Indexes.Create();
|
await Indexes.Create();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -40,5 +50,32 @@ namespace PrivaPub.Tests.Support
|
|||||||
if (Enabled)
|
if (Enabled)
|
||||||
await DB.Default.Database().Client.DropDatabaseAsync(Database);
|
await DB.Default.Database().Client.DropDatabaseAsync(Database);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static void Refuse(MongoClientSettings settings)
|
||||||
|
{
|
||||||
|
var ports = settings.Servers.Select(s => s.Port).ToList();
|
||||||
|
if (ports.Contains(ProductionPort))
|
||||||
|
throw new InvalidOperationException($"the tests never run against port {ProductionPort}: that is production's mongod");
|
||||||
|
if (InCi && (Environment.GetEnvironmentVariable("PRIVAPUB_TEST_MONGO") == default || ports.Contains(DefaultPort)))
|
||||||
|
throw new InvalidOperationException("in CI the tests run only against a throwaway mongod: use tools/ci/with-test-mongod.sh");
|
||||||
|
}
|
||||||
|
|
||||||
|
static async Task RefuseProductionData()
|
||||||
|
{
|
||||||
|
BsonDocument options;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
options = await DB.Default.Database().Client.GetDatabase("admin").RunCommandAsync<BsonDocument>(new BsonDocument("getCmdLineOpts", 1));
|
||||||
|
}
|
||||||
|
catch (MongoCommandException)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var dbPath = options.GetValue("parsed", new BsonDocument()).AsBsonDocument
|
||||||
|
.GetValue("storage", new BsonDocument()).AsBsonDocument
|
||||||
|
.GetValue("dbPath", BsonNull.Value);
|
||||||
|
if (dbPath.IsString && dbPath.AsString.StartsWith("/var/lib/privapub", StringComparison.Ordinal))
|
||||||
|
throw new InvalidOperationException($"the mongod at {Connection} keeps its data in {dbPath.AsString}: that is production's");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -17,7 +17,7 @@ namespace PrivaPub.Tests.Support
|
|||||||
public sealed class Peer : IAsyncDisposable
|
public sealed class Peer : IAsyncDisposable
|
||||||
{
|
{
|
||||||
readonly WebApplication _app;
|
readonly WebApplication _app;
|
||||||
readonly ConcurrentDictionary<string, string> _documents = new();
|
readonly ConcurrentDictionary<string, (string Text, string ContentType)> _documents = new();
|
||||||
readonly ConcurrentDictionary<string, (int Status, TimeSpan Delay)> _answers = new();
|
readonly ConcurrentDictionary<string, (int Status, TimeSpan Delay)> _answers = new();
|
||||||
readonly ConcurrentDictionary<string, (byte[] Bytes, string ContentType)> _files = new();
|
readonly ConcurrentDictionary<string, (byte[] Bytes, string ContentType)> _files = new();
|
||||||
|
|
||||||
@@ -40,12 +40,15 @@ namespace PrivaPub.Tests.Support
|
|||||||
Peer peer = default;
|
Peer peer = default;
|
||||||
app.Run(async context =>
|
app.Run(async context =>
|
||||||
{
|
{
|
||||||
peer.Requests.Enqueue(new(context.Request.Method, context.Request.Path, context.Request.Headers["Signature"].ToString()));
|
var body = context.Request.ContentLength > 0 || context.Request.Headers.TransferEncoding.Count > 0
|
||||||
|
? await new StreamReader(context.Request.Body).ReadToEndAsync()
|
||||||
|
: default;
|
||||||
|
peer.Requests.Enqueue(new(context.Request.Method, context.Request.Path, context.Request.Headers["Signature"].ToString(), body,
|
||||||
|
context.Request.Headers.ToDictionary(h => h.Key, h => h.Value.ToString(), StringComparer.OrdinalIgnoreCase)));
|
||||||
var key = context.Request.Path.Value;
|
var key = context.Request.Path.Value;
|
||||||
if (peer._files.TryGetValue(key, out var file))
|
if (peer._files.TryGetValue(key, out var file))
|
||||||
{
|
{
|
||||||
context.Response.ContentType = file.ContentType;
|
await Results.Bytes(file.Bytes, file.ContentType, enableRangeProcessing: true).ExecuteAsync(context);
|
||||||
await context.Response.Body.WriteAsync(file.Bytes);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (peer._answers.TryGetValue(key, out var answer))
|
if (peer._answers.TryGetValue(key, out var answer))
|
||||||
@@ -60,15 +63,17 @@ namespace PrivaPub.Tests.Support
|
|||||||
context.Response.StatusCode = StatusCodes.Status404NotFound;
|
context.Response.StatusCode = StatusCodes.Status404NotFound;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
context.Response.ContentType = "application/activity+json";
|
context.Response.ContentType = document.ContentType;
|
||||||
await context.Response.WriteAsync(document.Replace("{A}", peer.A).Replace("{B}", peer.B));
|
await context.Response.WriteAsync(document.Text.Replace("{A}", peer.A).Replace("{B}", peer.B));
|
||||||
});
|
});
|
||||||
await app.StartAsync();
|
await app.StartAsync();
|
||||||
peer = new Peer(app, new Uri(app.Urls.First()).Port);
|
peer = new Peer(app, new Uri(app.Urls.First()).Port);
|
||||||
return peer;
|
return peer;
|
||||||
}
|
}
|
||||||
|
|
||||||
public void Serve(string path, string json) => _documents[path] = json;
|
public void Serve(string path, string json) => _documents[path] = (json, "application/activity+json");
|
||||||
|
|
||||||
|
public void ServeText(string path, string text, string contentType) => _documents[path] = (text, contentType);
|
||||||
|
|
||||||
public void ServeFile(string path, byte[] bytes, string contentType) => _files[path] = (bytes, contentType);
|
public void ServeFile(string path, byte[] bytes, string contentType) => _files[path] = (bytes, contentType);
|
||||||
|
|
||||||
@@ -95,7 +100,8 @@ namespace PrivaPub.Tests.Support
|
|||||||
public Task Reload(CancellationToken token) => Task.CompletedTask;
|
public Task Reload(CancellationToken token) => Task.CompletedTask;
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed record HttpRequestRecord(string Method, string Path, string Signature);
|
public sealed record HttpRequestRecord(string Method, string Path, string Signature, string Body = default,
|
||||||
|
IReadOnlyDictionary<string, string> Headers = default);
|
||||||
|
|
||||||
public sealed class StaticOptions<T> : IOptionsMonitor<T>
|
public sealed class StaticOptions<T> : IOptionsMonitor<T>
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -44,18 +44,46 @@ namespace PrivaPub.Tests.Support
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
public HttpRequestMessage SignedGet(string path, string host = "privapub.test")
|
||||||
|
{
|
||||||
|
var date = DateTimeOffset.UtcNow.ToString("r", CultureInfo.InvariantCulture);
|
||||||
|
var request = new HttpRequestMessage(HttpMethod.Get, path);
|
||||||
|
request.Headers.TryAddWithoutValidation("Date", date);
|
||||||
|
request.Headers.TryAddWithoutValidation("Accept", "application/activity+json");
|
||||||
|
request.Headers.TryAddWithoutValidation("Signature", Signature($"(request-target): get {path}\nhost: {host}\ndate: {date}", "(request-target) host date"));
|
||||||
|
return request;
|
||||||
|
}
|
||||||
|
|
||||||
|
public HttpRequestMessage SignedPost(string path, JsonNode activity, string host = "privapub.test", string date = default)
|
||||||
|
{
|
||||||
|
var body = Encoding.UTF8.GetBytes(activity.ToJsonString());
|
||||||
|
date ??= DateTimeOffset.UtcNow.ToString("r", CultureInfo.InvariantCulture);
|
||||||
|
var digest = HttpSignatures.Digest(body);
|
||||||
|
var request = new HttpRequestMessage(HttpMethod.Post, path) { Content = new ByteArrayContent(body) };
|
||||||
|
request.Content.Headers.TryAddWithoutValidation("Content-Type", "application/activity+json");
|
||||||
|
request.Headers.TryAddWithoutValidation("Date", date);
|
||||||
|
request.Headers.TryAddWithoutValidation("Digest", digest);
|
||||||
|
request.Headers.TryAddWithoutValidation("Signature",
|
||||||
|
Signature($"(request-target): post {path}\nhost: {host}\ndate: {date}\ndigest: {digest}", "(request-target) host date digest"));
|
||||||
|
return request;
|
||||||
|
}
|
||||||
|
|
||||||
|
string Signature(string signingString, string headers)
|
||||||
|
{
|
||||||
|
var signature = Convert.ToBase64String(_key.SignData(Encoding.UTF8.GetBytes(signingString), HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1));
|
||||||
|
return $"keyId=\"{KeyId}\",algorithm=\"rsa-sha256\",headers=\"{headers}\",signature=\"{signature}\"";
|
||||||
|
}
|
||||||
|
|
||||||
public HttpRequest Get(string host, string path)
|
public HttpRequest Get(string host, string path)
|
||||||
{
|
{
|
||||||
var date = DateTimeOffset.UtcNow.ToString("r", CultureInfo.InvariantCulture);
|
var date = DateTimeOffset.UtcNow.ToString("r", CultureInfo.InvariantCulture);
|
||||||
var signingString = $"(request-target): get {path}\nhost: {host}\ndate: {date}";
|
|
||||||
var signature = Convert.ToBase64String(_key.SignData(Encoding.UTF8.GetBytes(signingString), HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1));
|
|
||||||
var context = new DefaultHttpContext();
|
var context = new DefaultHttpContext();
|
||||||
context.Request.Method = "GET";
|
context.Request.Method = "GET";
|
||||||
context.Request.Host = new HostString(host);
|
context.Request.Host = new HostString(host);
|
||||||
context.Request.Path = path;
|
context.Request.Path = path;
|
||||||
context.Features.Get<IHttpRequestFeature>().RawTarget = path;
|
context.Features.Get<IHttpRequestFeature>().RawTarget = path;
|
||||||
context.Request.Headers["Date"] = date;
|
context.Request.Headers["Date"] = date;
|
||||||
context.Request.Headers["Signature"] = $"keyId=\"{KeyId}\",algorithm=\"rsa-sha256\",headers=\"(request-target) host date\",signature=\"{signature}\"";
|
context.Request.Headers["Signature"] = Signature($"(request-target): get {path}\nhost: {host}\ndate: {date}", "(request-target) host date");
|
||||||
return context.Request;
|
return context.Request;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -64,9 +92,6 @@ namespace PrivaPub.Tests.Support
|
|||||||
var body = Encoding.UTF8.GetBytes(activity.ToJsonString());
|
var body = Encoding.UTF8.GetBytes(activity.ToJsonString());
|
||||||
var date = DateTimeOffset.UtcNow.ToString("r", CultureInfo.InvariantCulture);
|
var date = DateTimeOffset.UtcNow.ToString("r", CultureInfo.InvariantCulture);
|
||||||
var digest = HttpSignatures.Digest(body);
|
var digest = HttpSignatures.Digest(body);
|
||||||
var signingString = $"(request-target): post {path}\nhost: {host}\ndate: {date}\ndigest: {digest}";
|
|
||||||
var signature = Convert.ToBase64String(_key.SignData(Encoding.UTF8.GetBytes(signingString), HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1));
|
|
||||||
|
|
||||||
var context = new DefaultHttpContext();
|
var context = new DefaultHttpContext();
|
||||||
context.Request.Method = "POST";
|
context.Request.Method = "POST";
|
||||||
context.Request.Host = new HostString(host);
|
context.Request.Host = new HostString(host);
|
||||||
@@ -75,7 +100,7 @@ namespace PrivaPub.Tests.Support
|
|||||||
context.Request.Headers["Date"] = date;
|
context.Request.Headers["Date"] = date;
|
||||||
context.Request.Headers["Digest"] = digest;
|
context.Request.Headers["Digest"] = digest;
|
||||||
context.Request.Headers["Content-Type"] = "application/activity+json";
|
context.Request.Headers["Content-Type"] = "application/activity+json";
|
||||||
context.Request.Headers["Signature"] = $"keyId=\"{KeyId}\",algorithm=\"rsa-sha256\",headers=\"(request-target) host date digest\",signature=\"{signature}\"";
|
context.Request.Headers["Signature"] = Signature($"(request-target): post {path}\nhost: {host}\ndate: {date}\ndigest: {digest}", "(request-target) host date digest");
|
||||||
context.Request.Body = new MemoryStream(body);
|
context.Request.Body = new MemoryStream(body);
|
||||||
context.Request.ContentLength = body.Length;
|
context.Request.ContentLength = body.Length;
|
||||||
return context.Request;
|
return context.Request;
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
|
||||||
|
namespace PrivaPub.Api.Mastodon.Infrastructure
|
||||||
|
{
|
||||||
|
public static class MastodonErrors
|
||||||
|
{
|
||||||
|
public static IApplicationBuilder UseMastodonErrorBodies(this IApplicationBuilder app) =>
|
||||||
|
app.UseWhen(context => context.Request.Path.StartsWithSegments("/api"), api => api.UseStatusCodePages(async pages =>
|
||||||
|
{
|
||||||
|
var response = pages.HttpContext.Response;
|
||||||
|
response.ContentType = "application/json; charset=utf-8";
|
||||||
|
await response.WriteAsync(JsonSerializer.Serialize(new { error = Message(response.StatusCode) }, MastodonJson.Options));
|
||||||
|
}));
|
||||||
|
|
||||||
|
public static string Message(int status) => status switch
|
||||||
|
{
|
||||||
|
StatusCodes.Status401Unauthorized => "The access token is invalid",
|
||||||
|
StatusCodes.Status403Forbidden => "This action is outside the authorized scopes",
|
||||||
|
StatusCodes.Status404NotFound => "Record not found",
|
||||||
|
StatusCodes.Status405MethodNotAllowed => "Method not allowed",
|
||||||
|
StatusCodes.Status413PayloadTooLarge => "Payload too large",
|
||||||
|
StatusCodes.Status415UnsupportedMediaType => "Unsupported media type",
|
||||||
|
StatusCodes.Status422UnprocessableEntity => "Validation failed",
|
||||||
|
StatusCodes.Status429TooManyRequests => "Too many requests",
|
||||||
|
>= 500 => "Something went wrong.",
|
||||||
|
_ => "Bad request"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -44,9 +44,11 @@ namespace PrivaPub.Federation.Moderation
|
|||||||
|
|
||||||
public bool IsSuspended(string host) => Find(host)?.Severity == DomainBlockSeverity.Suspend;
|
public bool IsSuspended(string host) => Find(host)?.Severity == DomainBlockSeverity.Suspend;
|
||||||
|
|
||||||
public async Task Reload(CancellationToken token)
|
public async Task Reload(CancellationToken token) =>
|
||||||
|
Load(await DB.Default.Find<DomainBlock>().ExecuteAsync(token));
|
||||||
|
|
||||||
|
public void Load(IEnumerable<DomainBlock> blocks)
|
||||||
{
|
{
|
||||||
var blocks = await DB.Default.Find<DomainBlock>().ExecuteAsync(token);
|
|
||||||
_blocks = blocks.Where(b => !string.IsNullOrEmpty(b.Domain))
|
_blocks = blocks.Where(b => !string.IsNullOrEmpty(b.Domain))
|
||||||
.GroupBy(b => Normalise(b.Domain))
|
.GroupBy(b => Normalise(b.Domain))
|
||||||
.ToDictionary(g => g.Key, g => g.First());
|
.ToDictionary(g => g.Key, g => g.First());
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ using MongoDB.Entities;
|
|||||||
using PrivaPub.Models.Jobs;
|
using PrivaPub.Models.Jobs;
|
||||||
|
|
||||||
using System.Collections.Concurrent;
|
using System.Collections.Concurrent;
|
||||||
|
using System.Linq.Expressions;
|
||||||
|
|
||||||
namespace PrivaPub.Infrastructure.Jobs
|
namespace PrivaPub.Infrastructure.Jobs
|
||||||
{
|
{
|
||||||
@@ -39,6 +40,13 @@ namespace PrivaPub.Infrastructure.Jobs
|
|||||||
|
|
||||||
readonly string _owner = $"{Environment.MachineName}:{Environment.ProcessId}";
|
readonly string _owner = $"{Environment.MachineName}:{Environment.ProcessId}";
|
||||||
readonly ConcurrentDictionary<JobKind, SemaphoreSlim> _signals = new();
|
readonly ConcurrentDictionary<JobKind, SemaphoreSlim> _signals = new();
|
||||||
|
readonly FilterDefinition<Job> _scope = Builders<Job>.Filter.Empty;
|
||||||
|
|
||||||
|
public JobQueue()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public JobQueue(Expression<Func<Job, bool>> scope) => _scope = Builders<Job>.Filter.Where(scope);
|
||||||
|
|
||||||
public async Task<bool> Enqueue(JobKind kind, string payload, string host, string dedupeKey, CancellationToken token) =>
|
public async Task<bool> Enqueue(JobKind kind, string payload, string host, string dedupeKey, CancellationToken token) =>
|
||||||
await EnqueueMany(new[] { new Job { Kind = kind, Payload = payload, Host = host, DedupeKey = dedupeKey } }, token) == 1;
|
await EnqueueMany(new[] { new Job { Kind = kind, Payload = payload, Host = host, DedupeKey = dedupeKey } }, token) == 1;
|
||||||
@@ -66,7 +74,7 @@ namespace PrivaPub.Infrastructure.Jobs
|
|||||||
var now = DateTime.UtcNow;
|
var now = DateTime.UtcNow;
|
||||||
var busy = busyHosts?.ToList() ?? new List<string>();
|
var busy = busyHosts?.ToList() ?? new List<string>();
|
||||||
return await DB.Default.UpdateAndGet<Job>()
|
return await DB.Default.UpdateAndGet<Job>()
|
||||||
.Match(j => j.Kind == kind && j.State == JobState.Pending && j.RunAt <= now && !busy.Contains(j.Host))
|
.Match(f => f.Where(j => j.Kind == kind && j.State == JobState.Pending && j.RunAt <= now && !busy.Contains(j.Host)) & _scope)
|
||||||
.Modify(j => j.State, JobState.Running)
|
.Modify(j => j.State, JobState.Running)
|
||||||
.Modify(j => j.LeasedUntil, now + LeaseTime)
|
.Modify(j => j.LeasedUntil, now + LeaseTime)
|
||||||
.Modify(j => j.LeaseOwner, _owner)
|
.Modify(j => j.LeaseOwner, _owner)
|
||||||
@@ -106,7 +114,7 @@ namespace PrivaPub.Infrastructure.Jobs
|
|||||||
{
|
{
|
||||||
var now = DateTime.UtcNow;
|
var now = DateTime.UtcNow;
|
||||||
var result = await DB.Default.Update<Job>()
|
var result = await DB.Default.Update<Job>()
|
||||||
.Match(j => j.State == JobState.Running && j.LeasedUntil < now)
|
.Match(f => f.Where(j => j.State == JobState.Running && j.LeasedUntil < now) & _scope)
|
||||||
.Modify(j => j.State, JobState.Pending)
|
.Modify(j => j.State, JobState.Pending)
|
||||||
.Modify(j => j.RunAt, now)
|
.Modify(j => j.RunAt, now)
|
||||||
.Modify(j => j.LeasedUntil, null)
|
.Modify(j => j.LeasedUntil, null)
|
||||||
|
|||||||
+3
-1
@@ -12,6 +12,7 @@ using Serilog;
|
|||||||
using PrivaPub.Data;
|
using PrivaPub.Data;
|
||||||
using PrivaPub.Extensions;
|
using PrivaPub.Extensions;
|
||||||
using PrivaPub.Api.Mastodon.Auth;
|
using PrivaPub.Api.Mastodon.Auth;
|
||||||
|
using PrivaPub.Api.Mastodon.Infrastructure;
|
||||||
using PrivaPub.Infrastructure;
|
using PrivaPub.Infrastructure;
|
||||||
using PrivaPub.Infrastructure.Cli;
|
using PrivaPub.Infrastructure.Cli;
|
||||||
using PrivaPub.Infrastructure.Data;
|
using PrivaPub.Infrastructure.Data;
|
||||||
@@ -78,7 +79,7 @@ try
|
|||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
BsonSerializer.RegisterSerializer(new GuidSerializer(GuidRepresentation.Standard));
|
BsonSerializer.TryRegisterSerializer(new GuidSerializer(GuidRepresentation.Standard));
|
||||||
var mongoSettings = builder.Configuration.GetSection(nameof(MongoSettings)).Get<MongoSettings>();
|
var mongoSettings = builder.Configuration.GetSection(nameof(MongoSettings)).Get<MongoSettings>();
|
||||||
await DB.InitAsync(mongoSettings.Database, MongoClientSettings.FromConnectionString(mongoSettings.ConnectionString));
|
await DB.InitAsync(mongoSettings.Database, MongoClientSettings.FromConnectionString(mongoSettings.ConnectionString));
|
||||||
EntityMaps.Warm();
|
EntityMaps.Warm();
|
||||||
@@ -128,6 +129,7 @@ try
|
|||||||
|
|
||||||
app.UseHttpsRedirection();
|
app.UseHttpsRedirection();
|
||||||
app.UseCors("DefaultCORS");
|
app.UseCors("DefaultCORS");
|
||||||
|
app.UseMastodonErrorBodies();
|
||||||
|
|
||||||
app.UseStaticFiles();
|
app.UseStaticFiles();
|
||||||
var mediaRoot = app.Services.GetRequiredService<PrivaPub.Domain.Media.IMediaService>().Root;
|
var mediaRoot = app.Services.GetRequiredService<PrivaPub.Domain.Media.IMediaService>().Root;
|
||||||
|
|||||||
@@ -14,8 +14,11 @@ namespace PrivaPub.Services
|
|||||||
ILogger<JwtEvents> _logger { get; set; }
|
ILogger<JwtEvents> _logger { get; set; }
|
||||||
const string contentType = "application/json";
|
const string contentType = "application/json";
|
||||||
|
|
||||||
public override async Task AuthenticationFailed(AuthenticationFailedContext context)
|
public override async Task Challenge(JwtBearerChallengeContext context)
|
||||||
{
|
{
|
||||||
|
context.HandleResponse();
|
||||||
|
context.Response.StatusCode = StatusCodes.Status401Unauthorized;
|
||||||
|
context.Response.Headers.WWWAuthenticate = context.AuthenticateFailure == default ? "Bearer" : "Bearer error=\"invalid_token\"";
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var localizer = context.HttpContext.RequestServices.GetRequiredService<IStringLocalizer<GenericRes>>();
|
var localizer = context.HttpContext.RequestServices.GetRequiredService<IStringLocalizer<GenericRes>>();
|
||||||
@@ -26,7 +29,7 @@ namespace PrivaPub.Services
|
|||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
_logger = context.HttpContext.RequestServices.GetRequiredService<ILogger<JwtEvents>>();
|
_logger = context.HttpContext.RequestServices.GetRequiredService<ILogger<JwtEvents>>();
|
||||||
_logger.LogError(ex, "Error at AuthenticationFailed()");
|
_logger.LogError(ex, "Error at Challenge()");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -42,7 +45,7 @@ namespace PrivaPub.Services
|
|||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
_logger = context.HttpContext.RequestServices.GetRequiredService<ILogger<JwtEvents>>();
|
_logger = context.HttpContext.RequestServices.GetRequiredService<ILogger<JwtEvents>>();
|
||||||
_logger.LogError(ex, "Error at AuthenticationFailed()");
|
_logger.LogError(ex, "Error at Forbidden()");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ Written 2026-10-01 from the original 2023 code, the decePubClient UI, a federati
|
|||||||
- [x] P4 Groups and privacy features: v1.6.0, deployed 2026-10-01; communities, circles and local-only located posts verified by tests. v1.6.1 adds the pasture (`tools/pasture/`): live interop with GoToSocial 0.22.1 passes all 25 checks, three runs in a row. Lemmy and a live Mastodon circle member are not run yet; the pasture has GoToSocial only
|
- [x] P4 Groups and privacy features: v1.6.0, deployed 2026-10-01; communities, circles and local-only located posts verified by tests. v1.6.1 adds the pasture (`tools/pasture/`): live interop with GoToSocial 0.22.1 passes all 25 checks, three runs in a row. Lemmy and a live Mastodon circle member are not run yet; the pasture has GoToSocial only
|
||||||
- [x] P5 Lose nothing: v1.7.0 to v1.9.1, deployed 2026-10-01; parsing, typed details, provenance, downvotes, tombstones, federated blocks, all checked live against GoToSocial. Book reviews and forum threads keep their raw form only (typed in P7 and P8)
|
- [x] P5 Lose nothing: v1.7.0 to v1.9.1, deployed 2026-10-01; parsing, typed details, provenance, downvotes, tombstones, federated blocks, all checked live against GoToSocial. Book reviews and forum threads keep their raw form only (typed in P7 and P8)
|
||||||
- [x] P6 Emoji, polls, quotes, reactions, cards, players: v1.10.0 to v1.15.0, deployed 2026-10-01; polls, link cards, ranged video streaming and quote policies checked live against GoToSocial
|
- [x] P6 Emoji, polls, quotes, reactions, cards, players: v1.10.0 to v1.15.0, deployed 2026-10-01; polls, link cards, ranged video streaming and quote policies checked live against GoToSocial
|
||||||
|
- [ ] T/M Test sweep, the full pasture and the interaction ledger (see "Sweep and statistics" below)
|
||||||
- [ ] P7 Threads, communities, moderation, the social graph
|
- [ ] P7 Threads, communities, moderation, the social graph
|
||||||
- [ ] P8 Signatures, discovery, the long tail
|
- [ ] P8 Signatures, discovery, the long tail
|
||||||
|
|
||||||
@@ -131,6 +132,20 @@ and circles (see Owner decisions).
|
|||||||
| Who may quote a persona (default) | **Anyone, automatically**, as on Mastodon, for public and unlisted posts only. Each persona can change its default (followers only, or nobody) and each post can be changed; a granted quote can be revoked. Followers-only posts and DMs can never be quoted. |
|
| Who may quote a persona (default) | **Anyone, automatically**, as on Mastodon, for public and unlisted posts only. Each persona can change its default (followers only, or nobody) and each post can be changed; a granted quote can be revoked. Followers-only posts and DMs can never be quoted. |
|
||||||
| Quote permission address | `/peasants/{name}/parrot-licences/{id}` |
|
| Quote permission address | `/peasants/{name}/parrot-licences/{id}` |
|
||||||
|
|
||||||
|
### Owner decisions on statistics (2026-10-03)
|
||||||
|
|
||||||
|
| Question | Decision |
|
||||||
|
|---|---|
|
||||||
|
| Fediverse statistics | **Recorded now, published later as per-server aggregates only.** The following are each kept for 90 days as an event that names the remote *server*, never a remote account or a local persona: every inbox answer, every processed activity, every delivery attempt and every outbound request. Each day folds into per-server counters kept indefinitely, plus weekly server snapshots. A future public page, `/stargazing`, shows per-server aggregates for educational use, never per account and never per persona. |
|
||||||
|
| Remote accounts in statistics | **Never stored.** Distinct accounts per server per day are counted with a keyed hash. Its key is made for that day, kept only until the day's rollup and then destroyed, so the hashes can be neither reversed nor linked across days. |
|
||||||
|
| Local side in statistics | **Only the kind of local actor** (person, group, application), **and only on public and unlisted traffic.** DMs, followers-only and circle traffic are one "private" class, never broken out per server in public. Circles are never named, whether as a kind or as a reason. Fetches of our own documents are counted per day, never per server. |
|
||||||
|
| Reading-driven traffic | **Counted per day, never logged per event:** the media proxy, lookups a client asks for, and the client API per endpoint group (admin only). Client app names are not recorded. |
|
||||||
|
| Describing servers | **Every server we exchange activities with is described weekly**, from its NodeInfo (including the user counts it publishes) and its Mastodon instance API, never its contact account. These requests are unsigned, because they are not ActivityPub documents. Never on read. |
|
||||||
|
| Server locations | **City and network (ASN) from the offline DB-IP Lite databases** (CC BY 4.0, attributed), downloaded monthly outside the app. The location comes from the address we connected to; an inbound sender's address is never recorded, and no address is stored. In public: city and network only for servers reporting at least 10 users and not behind a CDN; the country otherwise; only the CDN's name for CDN-fronted servers. The admin sees everything. |
|
||||||
|
| Crawler | **Off by default** (`Statistics:Crawler:Enabled`). When on, it identifies as `PrivaPub-Stargazer/<version> (+https://privapub.thepra.dev/stargazing)`, where `/stargazing` explains it and how to opt out. It honours robots.txt (an unreachable robots.txt means "keep out") and domain blocks. It visits one server a minute, each at most weekly, and at most 5000 servers. It reads only robots.txt, NodeInfo, the instance API and the peers list, never accounts, posts or directories. Crawled servers stay marked as crawled. |
|
||||||
|
| A remote account deletes itself | **Its posts are kept but hidden everywhere** (`Post.AuthorGone`): from timelines, profiles, search and lookups by id. Its follows and timeline rows go, as before. |
|
||||||
|
| Signed-in smoke check in production | **An undiscoverable persona**, whose read-only token is the Gitea secret `PRIVAPUB_SMOKE_TOKEN`; the deploy checks `verify_credentials`, home and notifications with it. The owner creates both. |
|
||||||
|
|
||||||
## Libraries (researched; no maintained .NET ActivityPub library exists, so Letterbook and Iceshrimp.NET both wrote their own)
|
## Libraries (researched; no maintained .NET ActivityPub library exists, so Letterbook and Iceshrimp.NET both wrote their own)
|
||||||
|
|
||||||
| Area | Choice |
|
| Area | Choice |
|
||||||
@@ -521,6 +536,25 @@ it, raw where it doesn't.
|
|||||||
- Pixelfed `place` (display only, never re-federated);
|
- Pixelfed `place` (display only, never re-federated);
|
||||||
- Bluesky bridging per persona.
|
- Bluesky bridging per persona.
|
||||||
|
|
||||||
|
### Sweep and statistics (T and M, planned 2026-10-03, between P6 and P7)
|
||||||
|
|
||||||
|
Two tracks, interleaved so that the test host exists before the ledger, and the ledger starts collecting early, because
|
||||||
|
statistics gain value with every day recorded.
|
||||||
|
|
||||||
|
| Step | Content | Tag |
|
||||||
|
|---|---|---|
|
||||||
|
| T1–T4 | Tests stop sharing state they don't own. CI runs every test against a throwaway mongod. The whole server runs under test (`WebApplicationFactory`). Nothing answers 500. | v1.15.1 |
|
||||||
|
| M1–M6 | The interaction ledger: inbox answers, handler verdicts, delivery attempts, outbound requests, served and client traffic. Provenance fixes (fetched records, stored extensions, group-wrapped Update/Delete). | v1.16.0 |
|
||||||
|
| T5–T8 | Coverage sweep over HTTP: OAuth, `/clientapi`, the Mastodon API, federation GETs, inbox gaps, jobs, migrations, pages. | v1.17.0 |
|
||||||
|
| M7–M10 | Daily rollups (`InstanceDay`, `ServerDay`). Every touched server described weekly (NodeInfo usage, instance API, snapshots). Geolocation (DB-IP Lite city and ASN). Admin statistics API under `/clientapi/admin/statistics`. | v1.18.0 |
|
||||||
|
| T9–T14 | The pasture as plugins. GoToSocial gaps, then Mastodon, Misskey/Sharkey, Akkoma and Lemmy 1.0, each run also checking that peer's statistics. | v1.18.x |
|
||||||
|
| M11 | The opt-in crawler (`PrivaPub-Stargazer`) and the `/stargazing` explainer. | v1.19.0 |
|
||||||
|
|
||||||
|
Later, and not part of these steps: the public `/stargazing` statistics. It is anonymous, cached and rate-limited, and
|
||||||
|
covers per-server software, self-published counts, location as projected by the rule above, availability buckets and
|
||||||
|
the public activity mix. Per-server activity is shown only as order-of-magnitude buckets, and only for servers that
|
||||||
|
report at least 10 users; smaller ones fold into one "small servers" aggregate.
|
||||||
|
|
||||||
### Cut or deferred (deliberately)
|
### Cut or deferred (deliberately)
|
||||||
- **Cut:**
|
- **Cut:**
|
||||||
- Link-preview cards *fetched from the linked page* by default. Cards built from the object, or from FEP-8967
|
- Link-preview cards *fetched from the linked page* by default. Cards built from the object, or from FEP-8967
|
||||||
|
|||||||
Executable
+57
@@ -0,0 +1,57 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Runs a command (normally `dotnet test`) with a throwaway mongod: a fresh data directory on a random localhost port,
|
||||||
|
# stopped and deleted when the command ends. The box's own mongods (27017 shared, 27022 PrivaPub's) are never touched.
|
||||||
|
# usage: tools/ci/with-test-mongod.sh dotnet test PrivaPub.sln -c Release
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
root="${RUNNER_TEMP:-${TMPDIR:-/tmp}}/privapub-test-mongod"
|
||||||
|
mkdir -p "$root"
|
||||||
|
|
||||||
|
# Leftovers of a cancelled run: anything here older than two hours.
|
||||||
|
find "$root" -mindepth 1 -maxdepth 1 -type d -mmin +120 -print0 2>/dev/null | while IFS= read -r -d '' old; do
|
||||||
|
[ -f "$old/pid" ] && kill "$(cat "$old/pid")" 2>/dev/null || true
|
||||||
|
[ -f "$old/container" ] && podman rm -f "$(cat "$old/container")" >/dev/null 2>&1 || true
|
||||||
|
rm -rf "$old"
|
||||||
|
done
|
||||||
|
|
||||||
|
listening() { (exec 3<>"/dev/tcp/127.0.0.1/$1") 2>/dev/null; }
|
||||||
|
|
||||||
|
port=""
|
||||||
|
for _ in $(seq 1 20); do
|
||||||
|
candidate=$(python3 -c 'import socket; s=socket.socket(); s.bind(("127.0.0.1", 0)); print(s.getsockname()[1]); s.close()')
|
||||||
|
case "$candidate" in 27017|27022) continue ;; esac
|
||||||
|
listening "$candidate" && continue
|
||||||
|
port=$candidate; break
|
||||||
|
done
|
||||||
|
[ -n "$port" ] || { echo "::error::no free port for the test mongod"; exit 1; }
|
||||||
|
|
||||||
|
dir=$(mktemp -d "$root/run-XXXXXX")
|
||||||
|
cleanup() {
|
||||||
|
status=$?
|
||||||
|
[ -f "$dir/pid" ] && kill "$(cat "$dir/pid")" 2>/dev/null || true
|
||||||
|
[ -f "$dir/container" ] && podman rm -f "$(cat "$dir/container")" >/dev/null 2>&1 || true
|
||||||
|
for _ in $(seq 1 20); do listening "$port" || break; sleep 0.5; done
|
||||||
|
rm -rf "$dir"
|
||||||
|
exit $status
|
||||||
|
}
|
||||||
|
trap cleanup EXIT INT TERM
|
||||||
|
|
||||||
|
if command -v mongod >/dev/null 2>&1; then
|
||||||
|
mkdir -p "$dir/db"
|
||||||
|
timeout 7200 mongod --dbpath "$dir/db" --port "$port" --bind_ip 127.0.0.1 --noauth \
|
||||||
|
--wiredTigerCacheSizeGB 0.25 --quiet --logpath "$dir/mongod.log" &
|
||||||
|
echo $! > "$dir/pid"
|
||||||
|
how="mongod $(mongod --version | head -1)"
|
||||||
|
elif command -v podman >/dev/null 2>&1; then
|
||||||
|
podman run -d --rm -p "127.0.0.1:$port:27017" docker.io/library/mongo:8 --quiet > "$dir/container"
|
||||||
|
how="podman mongo:8"
|
||||||
|
else
|
||||||
|
echo "::error::neither mongod nor podman is available for the test mongod"; exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
for _ in $(seq 1 60); do listening "$port" && break; sleep 0.5; done
|
||||||
|
listening "$port" || { echo "::error::the test mongod did not start"; cat "$dir/mongod.log" 2>/dev/null | tail -20; exit 1; }
|
||||||
|
echo "test mongod: $how on 127.0.0.1:$port"
|
||||||
|
|
||||||
|
export PRIVAPUB_TEST_MONGOD=1 PRIVAPUB_TEST_REQUIRE_MONGOD=1 PRIVAPUB_TEST_MONGO="mongodb://127.0.0.1:$port"
|
||||||
|
"$@"
|
||||||
Reference in new issue
Block a user