209 lines
11 KiB
C#
209 lines
11 KiB
C#
using System.Net;
|
||
using System.Net.Http.Json;
|
||
using System.Text;
|
||
using System.Text.Json;
|
||
using Microsoft.AspNetCore.Hosting;
|
||
using Microsoft.AspNetCore.Mvc.Testing;
|
||
using Microsoft.AspNetCore.TestHost;
|
||
using Microsoft.Extensions.DependencyInjection;
|
||
|
||
namespace Calls.Api.Tests;
|
||
|
||
/// <summary>
|
||
/// HTTP API с подменённым Supabase: два пользователя, у каждого свой custom-workspace (RLS).
|
||
/// Проверка 2 спецификации на уровне API: пользователь другого workspace в комнату не попадает.
|
||
/// </summary>
|
||
public sealed class ApiTests : IClassFixture<ApiTests.App>
|
||
{
|
||
public const string Anna = "tok-anna", Carl = "tok-carl";
|
||
public const string AnnaUid = "11111111-1111-1111-1111-111111111111", CarlUid = "22222222-2222-2222-2222-222222222222";
|
||
private readonly App _app;
|
||
|
||
public ApiTests(App app) => _app = app;
|
||
|
||
/// <summary>Фальшивый Supabase: /auth/v1/user по токену, /rest/v1/workspaces с «RLS».</summary>
|
||
public sealed class FakeSupabase : HttpMessageHandler
|
||
{
|
||
private static readonly Dictionary<string, (string Uid, string Email, string Name, string[] Ws)> Users = new()
|
||
{
|
||
[Anna] = (AnnaUid, "anna@test", "Анна", ["wsteam0000001"]),
|
||
[Carl] = (CarlUid, "carl@test", "Карл", ["wsother000001"]),
|
||
};
|
||
|
||
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage req, CancellationToken ct)
|
||
{
|
||
var bearer = req.Headers.Authorization?.Parameter ?? "";
|
||
var key = Users.Keys.FirstOrDefault(k => bearer.EndsWith("." + k));
|
||
if (key == null) return Task.FromResult(new HttpResponseMessage(HttpStatusCode.Unauthorized));
|
||
var u = Users[key];
|
||
var path = req.RequestUri!.AbsolutePath;
|
||
string body;
|
||
if (path == "/auth/v1/user")
|
||
body = JsonSerializer.Serialize(new { id = u.Uid, aud = "authenticated", email = u.Email, user_metadata = new { name = u.Name } });
|
||
else if (path == "/rest/v1/workspaces")
|
||
{
|
||
var q = System.Web.HttpUtility.ParseQueryString(req.RequestUri.Query);
|
||
var id = q["id"]?.Replace("eq.", "");
|
||
var rows = u.Ws.Where(w => id == null || w == id)
|
||
.Select(w => new { id = w, kind = "custom", title = "Команда " + w[^1], owner_uid = u.Uid, members = Array.Empty<string>() });
|
||
body = JsonSerializer.Serialize(rows);
|
||
}
|
||
else return Task.FromResult(new HttpResponseMessage(HttpStatusCode.NotFound));
|
||
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(body, Encoding.UTF8, "application/json") });
|
||
}
|
||
}
|
||
|
||
public sealed class App : WebApplicationFactory<Program>
|
||
{
|
||
public readonly string Dir = Path.Combine(Path.GetTempPath(), "calls-api-" + Guid.NewGuid().ToString("N"));
|
||
|
||
protected override void ConfigureWebHost(IWebHostBuilder b)
|
||
{
|
||
b.UseSetting("Storage:DataDir", Dir);
|
||
b.UseSetting("Jitsi:AppSecret", StoreTests.Secret);
|
||
b.UseSetting("Jitsi:Domain", "meet.test");
|
||
b.UseSetting("Calls:LinkSecret", "test-link-secret-0123456789abcdef0123456789");
|
||
b.UseSetting("Calls:PublicUrl", "https://calls.test");
|
||
b.UseSetting("Supabase:Url", "https://supabase.test");
|
||
b.UseSetting("Supabase:PublishableKey", "sb_publishable_test");
|
||
b.UseSetting("Crm:Origins", "https://crm.test");
|
||
b.UseSetting("CALLS_BACKGROUND", "false");
|
||
b.UseSetting("ADMIN_TOKEN", "admin-token-0123456789abcdef");
|
||
b.ConfigureTestServices(s => s.AddHttpClient("supabase").ConfigurePrimaryHttpMessageHandler(() => new FakeSupabase()));
|
||
}
|
||
}
|
||
|
||
/// <summary>Похожий на Supabase JWT: exp через час, aud = authenticated; подпись фальшивому Supabase не важна.</summary>
|
||
public static string Jwt(string who)
|
||
{
|
||
static string B(string s) => Convert.ToBase64String(Encoding.UTF8.GetBytes(s)).TrimEnd('=').Replace('+', '-').Replace('/', '_');
|
||
var exp = DateTimeOffset.UtcNow.AddHours(1).ToUnixTimeSeconds();
|
||
return B("{\"alg\":\"HS256\"}") + "." + B($"{{\"exp\":{exp},\"aud\":\"authenticated\"}}") + "." + who;
|
||
}
|
||
|
||
private HttpClient Client(string? who, string? ws)
|
||
{
|
||
var c = _app.CreateClient();
|
||
if (who != null) c.DefaultRequestHeaders.Authorization = new("Bearer", Jwt(who));
|
||
if (ws != null) c.DefaultRequestHeaders.Add("X-Calls-Workspace", ws);
|
||
return c;
|
||
}
|
||
|
||
private static async Task<JsonElement> Json(HttpResponseMessage r) => JsonDocument.Parse(await r.Content.ReadAsStringAsync()).RootElement;
|
||
|
||
private async Task<JsonElement> PassFor(string session)
|
||
{
|
||
var r = await _app.CreateClient().PostAsJsonAsync("/api/call/pass", new { session });
|
||
Assert.Equal(HttpStatusCode.OK, r.StatusCode);
|
||
return await Json(r);
|
||
}
|
||
|
||
[Fact]
|
||
public async Task Member_JoinsOwnWorkspaceRoom_AsModerator()
|
||
{
|
||
var r = await Client(Anna, "wsteam0000001").PostAsync("/api/join", null);
|
||
Assert.Equal(HttpStatusCode.OK, r.StatusCode);
|
||
var join = await Json(r);
|
||
Assert.StartsWith("https://calls.test/call?code=", join.GetProperty("url").GetString());
|
||
var ex = await _app.CreateClient().PostAsJsonAsync("/api/call/exchange", new { code = join.GetProperty("code").GetString() });
|
||
Assert.Equal(HttpStatusCode.OK, ex.StatusCode);
|
||
var pass = await PassFor((await Json(ex)).GetProperty("session").GetString()!);
|
||
Assert.Equal("member", pass.GetProperty("kind").GetString());
|
||
var claims = StoreTests.VerifyHs256(pass.GetProperty("jwt").GetString()!);
|
||
Assert.True(claims.GetProperty("context").GetProperty("user").GetProperty("moderator").GetBoolean());
|
||
Assert.Equal("Анна", claims.GetProperty("context").GetProperty("user").GetProperty("name").GetString());
|
||
// код одноразовый
|
||
var again = await _app.CreateClient().PostAsJsonAsync("/api/call/exchange", new { code = join.GetProperty("code").GetString() });
|
||
Assert.Equal(HttpStatusCode.Gone, again.StatusCode);
|
||
}
|
||
|
||
[Theory]
|
||
[InlineData("wsteam0000001")] // custom-workspace Анны
|
||
[InlineData("personal:" + AnnaUid)] // личный Анны
|
||
public async Task OtherUser_CannotEnterSomeoneElsesWorkspace(string ws)
|
||
{
|
||
var c = Client(Carl, ws);
|
||
Assert.Equal(HttpStatusCode.Forbidden, (await c.PostAsync("/api/join", null)).StatusCode);
|
||
Assert.Equal(HttpStatusCode.Forbidden, (await c.GetAsync("/api/room")).StatusCode);
|
||
Assert.Equal(HttpStatusCode.Forbidden, (await c.PostAsync("/api/room/guest-link", null)).StatusCode);
|
||
}
|
||
|
||
[Fact]
|
||
public async Task Shared_IsOpenToAllCrmUsers_SameRoom()
|
||
{
|
||
async Task<string> RoomOf(string who)
|
||
{
|
||
var j = await Json(await Client(who, "shared").PostAsync("/api/join", null));
|
||
var ex = await Json(await _app.CreateClient().PostAsJsonAsync("/api/call/exchange", new { code = j.GetProperty("code").GetString() }));
|
||
return (await PassFor(ex.GetProperty("session").GetString()!)).GetProperty("room").GetString()!;
|
||
}
|
||
Assert.Equal(await RoomOf(Anna), await RoomOf(Carl));
|
||
}
|
||
|
||
[Fact]
|
||
public async Task NoOrBadToken_401()
|
||
{
|
||
Assert.Equal(HttpStatusCode.Unauthorized, (await Client(null, "shared").PostAsync("/api/join", null)).StatusCode);
|
||
var c = _app.CreateClient();
|
||
c.DefaultRequestHeaders.Authorization = new("Bearer", Jwt("tok-nobody"));
|
||
c.DefaultRequestHeaders.Add("X-Calls-Workspace", "shared");
|
||
Assert.Equal(HttpStatusCode.Unauthorized, (await c.PostAsync("/api/join", null)).StatusCode);
|
||
}
|
||
|
||
[Fact]
|
||
public async Task Workspaces_ListMineOnly()
|
||
{
|
||
var j = await Json(await Client(Anna, null).GetAsync("/api/workspaces"));
|
||
var ids = j.GetProperty("workspaces").EnumerateArray().Select(w => w.GetProperty("id").GetString()!).ToArray();
|
||
Assert.Equal(["personal:" + AnnaUid, "shared", "wsteam0000001"], ids);
|
||
}
|
||
|
||
[Fact]
|
||
public async Task Guest_ByLink_NotModerator_RegenerateKillsOldLink()
|
||
{
|
||
var room = await Json(await Client(Anna, "wsteam0000001").GetAsync("/api/room"));
|
||
var url = room.GetProperty("guestLink").GetProperty("url").GetString()!;
|
||
Assert.StartsWith("https://calls.test/g/", url);
|
||
var token = url[(url.LastIndexOf('/') + 1)..];
|
||
var anon = _app.CreateClient();
|
||
Assert.Equal(HttpStatusCode.OK, (await anon.GetAsync($"/api/guest/{token}")).StatusCode);
|
||
Assert.Equal(HttpStatusCode.BadRequest, (await anon.PostAsJsonAsync($"/api/guest/{token}/join", new { name = " " })).StatusCode);
|
||
var g = await Json(await anon.PostAsJsonAsync($"/api/guest/{token}/join", new { name = "Гість" }));
|
||
var pass = await PassFor(g.GetProperty("session").GetString()!);
|
||
Assert.Equal("guest", pass.GetProperty("kind").GetString());
|
||
Assert.False(StoreTests.VerifyHs256(pass.GetProperty("jwt").GetString()!).GetProperty("context").GetProperty("user").GetProperty("moderator").GetBoolean());
|
||
|
||
var regen = await Json(await Client(Anna, "wsteam0000001").PostAsync("/api/room/guest-link", null));
|
||
var newUrl = regen.GetProperty("guestLink").GetProperty("url").GetString()!;
|
||
Assert.NotEqual(url, newUrl);
|
||
Assert.Equal("Анна", regen.GetProperty("guestLink").GetProperty("updatedByName").GetString());
|
||
Assert.Equal(HttpStatusCode.NotFound, (await anon.GetAsync($"/api/guest/{token}")).StatusCode);
|
||
Assert.Equal(HttpStatusCode.NotFound, (await anon.PostAsJsonAsync($"/api/guest/{token}/join", new { name = "Гість" })).StatusCode);
|
||
var newToken = newUrl[(newUrl.LastIndexOf('/') + 1)..];
|
||
Assert.Equal(HttpStatusCode.OK, (await anon.PostAsJsonAsync($"/api/guest/{newToken}/join", new { name = "Гість" })).StatusCode);
|
||
}
|
||
|
||
[Fact]
|
||
public async Task Admin_TestLink_OnlyWithToken_OnlyTestWorkspaces()
|
||
{
|
||
var c = _app.CreateClient();
|
||
Assert.Equal(HttpStatusCode.NotFound, (await c.PostAsJsonAsync("/api/admin/test-link", new { workspace = "test-a" })).StatusCode);
|
||
c.DefaultRequestHeaders.Add("X-Admin-Token", "admin-token-0123456789abcdef");
|
||
Assert.Equal(HttpStatusCode.BadRequest, (await c.PostAsJsonAsync("/api/admin/test-link", new { workspace = "shared" })).StatusCode);
|
||
var r = await c.PostAsJsonAsync("/api/admin/test-link", new { workspace = "test-a" });
|
||
Assert.Equal(HttpStatusCode.OK, r.StatusCode);
|
||
Assert.StartsWith("https://calls.test/g/", (await Json(r)).GetProperty("guestLink").GetProperty("url").GetString());
|
||
Assert.Equal(HttpStatusCode.OK, (await c.GetAsync("/api/admin/calls")).StatusCode);
|
||
}
|
||
|
||
[Fact]
|
||
public async Task Headers_FrameAncestorsCrmOnly_NoXFrameOptions()
|
||
{
|
||
var r = await _app.CreateClient().GetAsync("/api/config");
|
||
var csp = r.Headers.GetValues("Content-Security-Policy").Single();
|
||
Assert.Contains("frame-ancestors https://crm.test;", csp);
|
||
Assert.Contains("frame-src https://meet.test", csp);
|
||
Assert.False(r.Headers.Contains("X-Frame-Options"));
|
||
}
|
||
}
|