Модуль: служебные /api/admin/test-link и /api/admin/calls (только с ADMIN_TOKEN)
This commit is contained in:
@@ -68,6 +68,7 @@ public sealed class ApiTests : IClassFixture<ApiTests.App>
|
|||||||
b.UseSetting("Supabase:PublishableKey", "sb_publishable_test");
|
b.UseSetting("Supabase:PublishableKey", "sb_publishable_test");
|
||||||
b.UseSetting("Crm:Origins", "https://crm.test");
|
b.UseSetting("Crm:Origins", "https://crm.test");
|
||||||
b.UseSetting("CALLS_BACKGROUND", "false");
|
b.UseSetting("CALLS_BACKGROUND", "false");
|
||||||
|
b.UseSetting("ADMIN_TOKEN", "admin-token-0123456789abcdef");
|
||||||
b.ConfigureTestServices(s => s.AddHttpClient("supabase").ConfigurePrimaryHttpMessageHandler(() => new FakeSupabase()));
|
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);
|
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]
|
[Fact]
|
||||||
public async Task Headers_FrameAncestorsCrmOnly_NoXFrameOptions()
|
public async Task Headers_FrameAncestorsCrmOnly_NoXFrameOptions()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -113,7 +113,7 @@ app.Use(async (ctx, next) =>
|
|||||||
|
|
||||||
// ---------------------------------------------------------------- авторизация CRM для /api/*
|
// ---------------------------------------------------------------- авторизация CRM для /api/*
|
||||||
// Bearer = access_token Supabase из CRM. Workspace — заголовок X-Calls-Workspace (обязателен там, где нужен).
|
// 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) =>
|
app.Use(async (ctx, next) =>
|
||||||
{
|
{
|
||||||
var path = ctx.Request.Path.Value ?? "";
|
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 });
|
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));
|
app.MapFallback("/api/{**rest}", () => Results.Json(new { error = "не знайдено" }, statusCode: 404));
|
||||||
|
|
||||||
// ---------------------------------------------------------------- фронтенд (SPA: /, /call, /g/<токен>)
|
// ---------------------------------------------------------------- фронтенд (SPA: /, /call, /g/<токен>)
|
||||||
@@ -261,5 +291,6 @@ record VerifyReq(string? access_token);
|
|||||||
record CodeReq(string? code);
|
record CodeReq(string? code);
|
||||||
record SessionReq(string? session);
|
record SessionReq(string? session);
|
||||||
record GuestReq(string? name);
|
record GuestReq(string? name);
|
||||||
|
record AdminLinkReq(string? workspace, bool? regenerate);
|
||||||
|
|
||||||
public partial class Program;
|
public partial class Program;
|
||||||
|
|||||||
@@ -28,6 +28,8 @@ public sealed class CallsRepo(Db db, TimeProvider? time = null)
|
|||||||
public List<CallRow> DuePending(long now) =>
|
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));
|
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? Get(string id) => Query("SELECT * FROM calls WHERE id=$i", ("$i", id)).FirstOrDefault();
|
||||||
|
|
||||||
public CallRow Create(string room, string workspaceId)
|
public CallRow Create(string room, string workspaceId)
|
||||||
|
|||||||
@@ -45,6 +45,8 @@ services:
|
|||||||
RECORDINGS_PREFIX: '${RECORDINGS_PREFIX:-calls/}'
|
RECORDINGS_PREFIX: '${RECORDINGS_PREFIX:-calls/}'
|
||||||
WIKI_URL: '${WIKI_URL:-https://wiki-crm.tes.gd}'
|
WIKI_URL: '${WIKI_URL:-https://wiki-crm.tes.gd}'
|
||||||
WIKI_CALLS_FOLDER: '${WIKI_CALLS_FOLDER:-Дзвінки}'
|
WIKI_CALLS_FOLDER: '${WIKI_CALLS_FOLDER:-Дзвінки}'
|
||||||
|
# Служебные /api/admin/* (тестовые комнаты test-*, состояние обработки). Пусто — выключены.
|
||||||
|
ADMIN_TOKEN: '${ADMIN_TOKEN:-}'
|
||||||
volumes:
|
volumes:
|
||||||
- calls-data:/data
|
- calls-data:/data
|
||||||
- '${RECORDINGS_HOST_DIR:-/data/crm-calls/recordings}:/recordings'
|
- '${RECORDINGS_HOST_DIR:-/data/crm-calls/recordings}:/recordings'
|
||||||
|
|||||||
Reference in New Issue
Block a user