Модуль: служебные /api/admin/test-link и /api/admin/calls (только с ADMIN_TOKEN)

This commit is contained in:
crm-calls agent
2026-10-15 06:41:53 +00:00
parent 976fdfac21
commit e6c31c90e1
4 changed files with 50 additions and 1 deletions

View File

@@ -68,6 +68,7 @@ public sealed class ApiTests : IClassFixture<ApiTests.App>
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()));
}
}
@@ -182,6 +183,19 @@ public sealed class ApiTests : IClassFixture<ApiTests.App>
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()
{

View File

@@ -113,7 +113,7 @@ app.Use(async (ctx, next) =>
// ---------------------------------------------------------------- авторизация CRM для /api/*
// Bearer = access_token Supabase из CRM. Workspace — заголовок X-Calls-Workspace (обязателен там, где нужен).
var open = new[] { "/api/verify", "/api/config", "/api/call/", "/api/guest/" };
var open = new[] { "/api/verify", "/api/config", "/api/call/", "/api/guest/", "/api/admin/" };
app.Use(async (ctx, next) =>
{
var path = ctx.Request.Path.Value ?? "";
@@ -238,6 +238,36 @@ app.MapPost("/api/guest/{token}/join", (string token, GuestReq req, Store store)
return Results.Json(new { session = s.Token, kind = s.Kind, name = s.Name });
});
// ---------------------------------------------------------------- служебное (только с ADMIN_TOKEN из env)
// Тестовые комнаты test-* (не workspace CRM) и состояние обработки записей — для проверок без аккаунта CRM.
var adminToken = cfg["ADMIN_TOKEN"] ?? "";
bool Admin(HttpContext ctx) => adminToken.Length >= 24 && System.Security.Cryptography.CryptographicOperations.FixedTimeEquals(
System.Text.Encoding.UTF8.GetBytes(ctx.Request.Headers["X-Admin-Token"].ToString()), System.Text.Encoding.UTF8.GetBytes(adminToken));
app.MapPost("/api/admin/test-link", (HttpContext ctx, AdminLinkReq req, Store store) =>
{
if (!Admin(ctx)) return Results.Json(new { error = "не знайдено" }, statusCode: 404);
var ws = req.workspace ?? "";
if (!System.Text.RegularExpressions.Regex.IsMatch(ws, "^test-[a-z0-9-]{1,40}$"))
return Results.Json(new { error = "лише тестові воркспейси test-…" }, statusCode: 400);
var bot = new CrmUser("admin-test", null, "Тест", 0);
store.Room(ws, bot.Id);
var link = req.regenerate == true ? store.RegenerateGuestLink(ws, bot) : store.GetGuestLink(ws, bot);
return Results.Json(new { workspace = ws, guestLink = LinkJson(link) });
});
app.MapGet("/api/admin/calls", (HttpContext ctx, CallsRepo calls) =>
{
if (!Admin(ctx)) return Results.Json(new { error = "не знайдено" }, statusCode: 404);
return Results.Json(new { calls = calls.Recent(30).Select(c => new
{
c.Id, c.Room, c.WorkspaceId, c.Status, c.Error, c.Attempts,
startedAt = DateTimeOffset.FromUnixTimeSeconds(c.StartedAt).ToString("O"),
seconds = (c.EndedAt ?? c.LastSeenAt) - c.StartedAt,
participants = c.Participants, hasAudioUrl = c.AudioUrl != null, c.PageId,
}) });
});
app.MapFallback("/api/{**rest}", () => Results.Json(new { error = "не знайдено" }, statusCode: 404));
// ---------------------------------------------------------------- фронтенд (SPA: /, /call, /g/<токен>)
@@ -261,5 +291,6 @@ record VerifyReq(string? access_token);
record CodeReq(string? code);
record SessionReq(string? session);
record GuestReq(string? name);
record AdminLinkReq(string? workspace, bool? regenerate);
public partial class Program;

View File

@@ -28,6 +28,8 @@ public sealed class CallsRepo(Db db, TimeProvider? time = null)
public List<CallRow> DuePending(long now) =>
Query("SELECT * FROM calls WHERE status IN ('ended','processing','waiting','error') AND next_attempt_at <= $t ORDER BY started_at", ("$t", now));
public List<CallRow> Recent(int n) => Query("SELECT * FROM calls ORDER BY started_at DESC LIMIT $n", ("$n", n));
public CallRow? Get(string id) => Query("SELECT * FROM calls WHERE id=$i", ("$i", id)).FirstOrDefault();
public CallRow Create(string room, string workspaceId)

View File

@@ -45,6 +45,8 @@ services:
RECORDINGS_PREFIX: '${RECORDINGS_PREFIX:-calls/}'
WIKI_URL: '${WIKI_URL:-https://wiki-crm.tes.gd}'
WIKI_CALLS_FOLDER: '${WIKI_CALLS_FOLDER:-Дзвінки}'
# Служебные /api/admin/* (тестовые комнаты test-*, состояние обработки). Пусто — выключены.
ADMIN_TOKEN: '${ADMIN_TOKEN:-}'
volumes:
- calls-data:/data
- '${RECORDINGS_HOST_DIR:-/data/crm-calls/recordings}:/recordings'