Where a server is, on the public instance API
/api/privapub/v1/instances/:host gains `geo`, the public projection of a server's place already decided for public server locations (PublicGeo.Project): city, coordinates and network for servers reporting at least 10 users and not behind a CDN, the country otherwise, the CDN's name for a CDN-fronted one, with DB-IP's attribution. `?host[]=` answers up to 40 servers at once, and this server describes itself: its host's address located once a day (SelfLocation), or Statistics:Geo:Self when the owner sets it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
This commit is contained in:
1 parent
2cd75176a4
commit
d9fb5c582f
8 files changed
+429
-15
No files matched your search
@@ -0,0 +1,204 @@
|
||||
using MongoDB.Entities;
|
||||
|
||||
using PrivaPub.Models.Jobs;
|
||||
using PrivaPub.Tests.Support;
|
||||
using PrivaPub.Tests.Support.Host;
|
||||
|
||||
using System.Net;
|
||||
using System.Text.Json.Nodes;
|
||||
|
||||
namespace PrivaPub.Tests.Http
|
||||
{
|
||||
// Where a server is, on the public instance API: the projection decided for public server locations (city and network
|
||||
// only for servers reporting at least 10 users and not behind a CDN, the country otherwise, the CDN's name for a
|
||||
// CDN-fronted one), the batch form, and this server itself.
|
||||
[Trait("Category", "Integration")]
|
||||
public sealed class InstanceLocationTests : IAsyncLifetime
|
||||
{
|
||||
PrivaPubHost _host;
|
||||
|
||||
public async ValueTask InitializeAsync()
|
||||
{
|
||||
Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip);
|
||||
_host = await PrivaPubHost.Shared();
|
||||
}
|
||||
|
||||
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
|
||||
|
||||
static CancellationToken Token => TestContext.Current.CancellationToken;
|
||||
|
||||
static async Task<string> Instance(int users, InstanceGeo geo, string name = "located")
|
||||
{
|
||||
var host = $"{name}{Guid.NewGuid():N}.example";
|
||||
await DB.Default.SaveAsync(new RemoteInstance { Host = host, Software = "mastodon", UsersTotal = users, Geo = geo }, Token);
|
||||
return host;
|
||||
}
|
||||
|
||||
static InstanceGeo Milan(string cdn = default) => new()
|
||||
{
|
||||
Country = "IT",
|
||||
City = "Milan",
|
||||
Latitude = 45.5,
|
||||
Longitude = 9.2,
|
||||
Asn = 12345,
|
||||
AsnOrg = "Example Hosting",
|
||||
Cdn = cdn,
|
||||
Ipv6 = true,
|
||||
Source = "DB-IP Lite 2026-10",
|
||||
LocatedAt = DateTime.UtcNow.AddDays(-2)
|
||||
};
|
||||
|
||||
async Task<JsonNode> Geo(string host) => (await _host.Client().Get($"/api/privapub/v1/instances/{host}")).Ok().Body["geo"];
|
||||
|
||||
[Fact]
|
||||
public async Task A_server_with_enough_users_shows_its_city_coordinates_and_network()
|
||||
{
|
||||
var geo = await Geo(await Instance(50, Milan()));
|
||||
|
||||
Assert.Equal("city", geo.Text("precision"));
|
||||
Assert.Equal(("IT", "Milan", "Example Hosting"), (geo.Text("country"), geo.Text("city"), geo.Text("network")));
|
||||
Assert.Equal(45.5, geo["latitude"]!.GetValue<double>());
|
||||
Assert.Equal(9.2, geo["longitude"]!.GetValue<double>());
|
||||
Assert.Equal("DB-IP Lite 2026-10", geo.Text("source"));
|
||||
Assert.NotNull(geo.Text("located_at"));
|
||||
Assert.Null(geo["asn"]);
|
||||
Assert.Null(geo["ipv6"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_small_server_shows_only_its_country()
|
||||
{
|
||||
var geo = await Geo(await Instance(3, Milan()));
|
||||
|
||||
Assert.Equal("country", geo.Text("precision"));
|
||||
Assert.Equal("IT", geo.Text("country"));
|
||||
Assert.Null(geo["city"]);
|
||||
Assert.Null(geo["latitude"]);
|
||||
Assert.Null(geo["longitude"]);
|
||||
Assert.Null(geo["network"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_cdn_fronted_server_shows_only_its_cdn()
|
||||
{
|
||||
var geo = await Geo(await Instance(5000, Milan(cdn: "Cloudflare")));
|
||||
|
||||
Assert.Equal("cdn", geo.Text("precision"));
|
||||
Assert.Equal("Cloudflare", geo.Text("cdn"));
|
||||
Assert.Null(geo["country"]);
|
||||
Assert.Null(geo["latitude"]);
|
||||
Assert.Null(geo["network"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task An_unlocated_server_has_no_place()
|
||||
{
|
||||
Assert.Null(await Geo(await Instance(50, default)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task The_provenance_of_a_post_carries_the_same_projection()
|
||||
{
|
||||
var host = await Instance(3, Milan());
|
||||
var described = (await _host.Client().Get($"/api/privapub/v1/instances/{host}")).Ok().Body;
|
||||
|
||||
Assert.Equal("country", described["geo"].Text("precision"));
|
||||
Assert.Equal(described["geo"]!.ToJsonString(), (await Geo(host))!.ToJsonString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Several_servers_answer_at_once_in_the_order_asked_without_the_unknown_ones()
|
||||
{
|
||||
var big = await Instance(50, Milan(), "big");
|
||||
var small = await Instance(3, Milan(), "small");
|
||||
var unknown = $"unknown{Guid.NewGuid():N}.example";
|
||||
|
||||
var answer = (await _host.Client().Get($"/api/privapub/v1/instances?host[]={small}&host[]={unknown}&host[]={big.ToUpperInvariant()}&host[]={small}")).Ok();
|
||||
var hosts = answer.Body!.AsArray().Select(i => i.Text("host")).ToList();
|
||||
|
||||
Assert.Equal(new[] { small, big }, hosts);
|
||||
Assert.Equal("country", answer.Body![0]["geo"].Text("precision"));
|
||||
Assert.Equal("city", answer.Body![1]["geo"].Text("precision"));
|
||||
Assert.Empty((await _host.Client().Get("/api/privapub/v1/instances")).Ok().Body!.AsArray());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task At_most_forty_servers_answer_at_once()
|
||||
{
|
||||
var hosts = new List<string>();
|
||||
for (var i = 0; i < 42; i++)
|
||||
hosts.Add(await Instance(1, default, "many"));
|
||||
|
||||
var answer = (await _host.Client().Get("/api/privapub/v1/instances?" + string.Join("&", hosts.Select(h => "host[]=" + h)))).Ok();
|
||||
|
||||
Assert.Equal(hosts.Take(40), answer.Body!.AsArray().Select(i => i.Text("host")));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task This_server_describes_itself()
|
||||
{
|
||||
var self = (await _host.Client().Get($"/api/privapub/v1/instances/{PrivaPubHost.Host}")).Ok().Body;
|
||||
|
||||
Assert.Equal(PrivaPubHost.Host, self.Text("host"));
|
||||
Assert.Equal("privapub", self.Text("software"));
|
||||
Assert.Equal("activitypub", Assert.Single(self["protocols"]!.AsArray())!.GetValue<string>());
|
||||
Assert.Null(self["geo"]);
|
||||
var batch = (await _host.Client().Get($"/api/privapub/v1/instances?host[]={PrivaPubHost.Host}")).Ok().Body!.AsArray();
|
||||
Assert.Equal("privapub", Assert.Single(batch).Text("software"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task The_owner_may_place_this_server()
|
||||
{
|
||||
var placed = await SelfPlacedHost.Shared();
|
||||
|
||||
var geo = (await placed.Client().Get($"/api/privapub/v1/instances/{PrivaPubHost.Host}")).Ok().Body["geo"];
|
||||
|
||||
Assert.Equal("city", geo.Text("precision"));
|
||||
Assert.Equal(("IT", "Milan", "configured"), (geo.Text("country"), geo.Text("city"), geo.Text("source")));
|
||||
Assert.Equal(45.5, geo["latitude"]!.GetValue<double>());
|
||||
Assert.Equal(9.2, geo["longitude"]!.GetValue<double>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Junk_hosts_are_not_an_error()
|
||||
{
|
||||
Assert.Equal(HttpStatusCode.NotFound, (await _host.Client().Get($"/api/privapub/v1/instances/{new string('a', 300)}")).Status);
|
||||
Assert.Empty((await _host.Client().Get($"/api/privapub/v1/instances?host[]=&host[]={new string('a', 300)}")).Ok().Body!.AsArray());
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class SelfPlacedHost : PrivaPubHost
|
||||
{
|
||||
static readonly SemaphoreSlim Boot = new(1, 1);
|
||||
static SelfPlacedHost _shared;
|
||||
|
||||
public static new async Task<SelfPlacedHost> Shared()
|
||||
{
|
||||
await PrivaPubHost.Shared();
|
||||
await Boot.WaitAsync();
|
||||
try
|
||||
{
|
||||
if (_shared == default)
|
||||
{
|
||||
var host = new SelfPlacedHost();
|
||||
_ = host.Services;
|
||||
_shared = host;
|
||||
}
|
||||
return _shared;
|
||||
}
|
||||
finally
|
||||
{
|
||||
Boot.Release();
|
||||
}
|
||||
}
|
||||
|
||||
protected override IEnumerable<KeyValuePair<string, string>> Settings() => base.Settings().Concat(new Dictionary<string, string>
|
||||
{
|
||||
["Statistics:Geo:Self:Latitude"] = "45.4642",
|
||||
["Statistics:Geo:Self:Longitude"] = "9.19",
|
||||
["Statistics:Geo:Self:City"] = "Milan",
|
||||
["Statistics:Geo:Self:Country"] = "IT"
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user