节点在线、应用在线、配置在线使用令牌查询
大石头 authored at 2021-12-16 19:49:30
7.51 KiB
Stardust
using System;
using System.Net.Http;
using System.Net.Http.Json;
using System.Text.RegularExpressions;
using System.Threading.Tasks;
using NewLife;
using NewLife.Security;
using Stardust.Data;
using Stardust.Data.Monitors;
using Xunit;

namespace ServerTest.Controllers;

/// <summary>浏览器埋点端点测试。覆盖配置下发、短期令牌签发与使用、以及令牌不得越权</summary>
[Collection(StarTestServerCollection.Name)]
public class BrowserEndpointsTests : StarTestBase
{
    public BrowserEndpointsTests(StarTestServer fixture) : base(fixture) { }

    private App CreateApp(out AppTracer tracer)
    {
        var name = "bws_" + Rand.NextString(8);
        var app = new App { Name = name, Secret = "secret-" + Rand.NextString(8), Enable = true };
        app.Insert();

        tracer = new AppTracer { Name = name, AppId = app.Id, Enable = true };
        tracer.Insert();

        return app;
    }

    private static String ExtractToken(String body)
    {
        var m = Regex.Match(body, "\"token\"\\s*:\\s*\"([^\"]+)\"");
        return m.Success ? m.Groups[1].Value : null;
    }

    /// <summary>以浏览器身份申请短期令牌。Origin 用于通过来源校验</summary>
    private async Task<(String Token, String Body)> IssueTokenAsync(String appId, String origin = "https://demo.example.com")
    {
        var client = _server.CreateClient();
        var req = new HttpRequestMessage(HttpMethod.Post, "/stardust/browser-token")
        {
            Content = JsonContent.Create(new { AppId = appId }),
        };
        if (!origin.IsNullOrEmpty()) req.Headers.Add("Origin", origin);

        var resp = await client.SendAsync(req);
        var body = await resp.Content.ReadAsStringAsync();

        return (ExtractToken(body), body);
    }

    [Fact(DisplayName = "配置端点返回前端所需的同源配置")]
    public async Task Config_ReturnsDefaults()
    {
        var app = CreateApp(out var tracer);
        try
        {
            var client = _server.CreateClient();
            var body = await client.GetStringAsync("/stardust/browser-config?appId=" + app.Name);

            Assert.Contains("\"tokenUrl\"", body);
            Assert.Contains("/stardust/browser-token", body);
            Assert.Contains(app.Name, body);
            Assert.Contains("\"sampleRate\"", body);
        }
        finally
        {
            tracer.Delete();
            app.Delete();
        }
    }

    [Fact(DisplayName = "配置端点对未知应用报错")]
    public async Task Config_UnknownApp_Rejected()
    {
        var client = _server.CreateClient();
        var body = await client.GetStringAsync("/stardust/browser-config?appId=not_exists_" + Rand.NextString(6));

        Assert.Contains("\"code\"", body);
    }

    [Fact(DisplayName = "缺少来源信息的令牌申请被拒绝")]
    public async Task Token_WithoutOrigin_Rejected()
    {
        var app = CreateApp(out var tracer);
        try
        {
            var (token, body) = await IssueTokenAsync(app.Name, null);

            Assert.True(token.IsNullOrEmpty(), "缺少来源时不应签发令牌:" + body);
            Assert.Contains("\"code\"", body);
        }
        finally
        {
            tracer.Delete();
            app.Delete();
        }
    }

    [Fact(DisplayName = "签发的短期令牌可用于埋点上报")]
    public async Task Token_ReportTrace_Success()
    {
        var app = CreateApp(out var tracer);
        try
        {
            var (token, body) = await IssueTokenAsync(app.Name);
            Assert.False(token.IsNullOrEmpty(), "应签发令牌:" + body);
            Assert.StartsWith("bw_", token);

            var client = _server.CreateClient();
            client.DefaultRequestHeaders.Add("X-Token", token);
            var resp = await client.PostAsync("/Trace/Report", JsonContent.Create(new { AppId = app.Name, ClientId = "browser-test" }));
            var reportBody = await resp.Content.ReadAsStringAsync();

            Assert.DoesNotContain("\"message\"", reportBody);
            Assert.Contains("\"code\":0", reportBody);
        }
        finally
        {
            tracer.Delete();
            app.Delete();
        }
    }

    [Fact(DisplayName = "短期令牌可经查询串上报,覆盖浏览器直连全流程")]
    public async Task Token_ReportTrace_ByQueryString_Success()
    {
        var app = CreateApp(out var tracer);
        try
        {
            var origin = "https://demo.example.com";
            var client = _server.CreateClient();

            // 1) 跨域预检:浏览器跨域直连星尘前必须先通过 OPTIONS
            var preflight = new HttpRequestMessage(HttpMethod.Options, "/stardust/browser-token");
            preflight.Headers.Add("Origin", origin);
            preflight.Headers.Add("Access-Control-Request-Method", "POST");
            preflight.Headers.Add("Access-Control-Request-Headers", "content-type");
            var preResp = await client.SendAsync(preflight);
            Assert.True(preResp.Headers.TryGetValues("Access-Control-Allow-Methods", out var methods), "预检响应缺少 Access-Control-Allow-Methods");
            Assert.Contains("POST", String.Join(",", methods));

            // 2) 带来源申请短期令牌
            var (token, body) = await IssueTokenAsync(app.Name, origin);
            Assert.False(token.IsNullOrEmpty(), "应签发令牌:" + body);

            // 3) 前端 SDK 通过查询串携带令牌上报
            var resp = await client.PostAsync("/Trace/Report?Token=" + Uri.EscapeDataString(token),
                JsonContent.Create(new { AppId = app.Name, ClientId = "browser-sdk" }));
            var reportBody = await resp.Content.ReadAsStringAsync();

            Assert.Contains("\"code\":0", reportBody);
        }
        finally
        {
            tracer.Delete();
            app.Delete();
        }
    }

    [Fact(DisplayName = "短期令牌不能冒名向其它应用上报")]
    public async Task Token_CannotReportAsOtherApp()
    {
        var a = CreateApp(out var tracerA);
        var b = CreateApp(out var tracerB);
        try
        {
            var (token, _) = await IssueTokenAsync(a.Name);
            Assert.False(token.IsNullOrEmpty());

            var client = _server.CreateClient();
            client.DefaultRequestHeaders.Add("X-Token", token);
            var resp = await client.PostAsync("/Trace/Report", JsonContent.Create(new { AppId = b.Name, ClientId = "browser-test" }));
            var body = await resp.Content.ReadAsStringAsync();

            Assert.Contains("\"code\"", body);
        }
        finally
        {
            tracerB.Delete();
            b.Delete();
            tracerA.Delete();
            a.Delete();
        }
    }

    [Fact(DisplayName = "短期令牌不能当作应用令牌访问其它接口")]
    public async Task Token_CannotBeUsedAsAppToken()
    {
        var app = CreateApp(out var tracer);
        try
        {
            var (token, _) = await IssueTokenAsync(app.Name);
            Assert.False(token.IsNullOrEmpty());

            var client = _server.CreateClient();
            client.DefaultRequestHeaders.Authorization = new("Bearer", token);
            var resp = await client.PostAsync("app/ping", JsonContent.Create(new { Name = app.Name, Version = "1.0.0", MachineName = "test-machine" }));
            var body = await resp.Content.ReadAsStringAsync();

            // 不透明令牌不是 JWT,心跳接口必须拒绝
            Assert.Contains("\"code\"", body);
        }
        finally
        {
            tracer.Delete();
            app.Delete();
        }
    }
}