using System.Net.Http.Json;
using System.Text.Json;
using NewLife.Cube;
using Stardust.Data.Deployment;
using Stardust.WebApiTests.Fixtures;
using Xunit;
using Xunit.Abstractions;
namespace Stardust.WebApiTests.Areas;
/// <summary>SSL è¯ä¹¦ PFX å¯†ç æ˜Žæ–‡æš´éœ²ï¼ˆOBS-3)的集æˆå›žå½’:è¯ä¹¦å‡ºå‚四端点组必须è£å‰ªã€‚</summary>
/// <remarks>
/// 覆盖é¢ï¼ˆå¯¹åº”è£å‰ªçº¢çº¿ï¼šæŽ§åˆ¶å™¨å‡ºå‚æŠ•å½±ï¼Œç¦ [IgnoreDataMember]ã€ç¦åŽŸåœ°æ”¹å…±äº«ç¼“å˜å®žä¾‹ï¼‰ï¼š
/// 1. Gateway/GatewayCert 列表 JSON(Search é€ä¼ 实体åºåˆ—化,ListFields åªç®¡ meta ä¸ç®¡ JSON 体);
/// 2. Gateway/GatewayCert 详情 JSON(Detail=FindData 免嗿®µåºåˆ—化);
/// 3. 两个è¯ä¹¦æŽ§åˆ¶å™¨çš„ export json(ExportData→SearchData→Search 免嗿®µå¯¼å‡ºï¼‰ï¼›
/// 4. Deployment/SslCertificate 列表/详情 JSON(åŒå®¿ä¸»ç›®å‰è¿ž ListFields 都未è£ï¼‰ï¼›
/// 5. Deployment/SslCertificate meta.list å—æ®µé›†ï¼ˆListFields è¡¥è£åŽä¸å¾—å« PfxPassword);
/// 6. 写路径守护:列表/详情脱æ•ä¸å¾—å½±å“ PUT ä¿å˜ï¼ˆä¸å¸¦ pfxPassword ä¸ä¸¢åŽŸå€¼ï¼Œå¸¦æ–°å€¼å¯æ›´æ–°ï¼‰ã€‚
/// </remarks>
[Collection(WebApiCollection.Name)]
public class CertPfxPasswordSecurityTests
{
/// <summary>çº¢åŸºçº¿æ ‡è®°ä¸²ï¼šå”¯ä¸€åˆ°ä¸ä¼šå‡ºçŽ°åœ¨å…¶å®ƒå—æ®µæˆ–全局å“åº”é‡Œçš„å¯†ç æ˜Žæ–‡ã€‚</summary>
private const String Secret = "pfx-secret-08a3f6";
/// <summary>新密ç å†™å…¥æ ‡è®°ä¸²ï¼šéªŒè¯å†™è·¯å¾„在è£å‰ªåŽä»å¯ç”¨ã€‚</summary>
private const String NewSecret = "pfx-secret-beef01";
private readonly ApiClient _api;
private readonly WebApiTestFactory _factory;
public CertPfxPasswordSecurityTests(WebApiTestFactory factory, ITestOutputHelper output)
{
_factory = factory;
_api = new(factory, output);
}
#region ç§å
/// <summary>æ’入一æ¡å¸¦ PFX å¯†ç æ˜Žæ–‡çš„è¯ä¹¦ï¼Œè¿”回实体(调用方负责 Delete 清ç†ï¼‰ã€‚</summary>
private static SslCertificate SeedCert()
{
var cert = new SslCertificate
{
Domain = $"obs3-{Guid.NewGuid():N}".Substring(0, 22) + ".example.com",
Enable = true,
PfxPassword = Secret,
Provider = "manual",
NotAfter = DateTime.Now.AddYears(1),
};
cert.Insert();
return cert;
}
/// <summary>按主键直查数æ®åº“(Find=直连查询,绕过实体缓å˜/å•对象缓å˜ï¼‰ã€‚</summary>
private static SslCertificate FindInDb(Int32 id) => SslCertificate.Find(SslCertificate._.Id == id);
#endregion
#region Gateway/GatewayCert
[Fact]
public async Task GatewayList_CropsPfxPassword()
{
var cert = SeedCert();
try
{
var resp = await _api.GetAsync($"Gateway/GatewayCert?Q={cert.Domain}");
var raw = await resp.Content.ReadAsStringAsync();
Assert.True(resp.IsSuccessStatusCode, $"[Gateway 列表] HTTP {(Int32)resp.StatusCode}:{raw}");
// 行必须真实返回,防æ¢"空列表"é€ æˆå‡ç»¿
Assert.Contains(cert.Domain, raw);
Assert.DoesNotContain(Secret, raw);
}
finally { cert.Delete(); }
}
[Fact]
public async Task GatewayDetail_CropsPfxPassword()
{
var cert = SeedCert();
try
{
var resp = await _api.GetAsync($"Gateway/GatewayCert/{cert.Id}");
var raw = await resp.Content.ReadAsStringAsync();
Assert.True(resp.IsSuccessStatusCode, $"[Gateway 详情] HTTP {(Int32)resp.StatusCode}:{raw}");
Assert.DoesNotContain(Secret, raw);
// 详情必须ä»èƒ½æŸ¥åˆ°è¯¥è¡Œï¼ˆè„±æ•䏿˜¯åˆ 行)
var opts = new JsonSerializerOptions(JsonSerializerDefaults.Web);
var api = JsonSerializer.Deserialize<ApiResponse<JsonElement>>(raw, opts);
Assert.NotNull(api);
Assert.True(api!.Code == 0, $"[Gateway 详情] Code={api.Code}:{raw}");
Assert.Equal(cert.Id, api.Data.GetProperty("id").GetInt32());
}
finally { cert.Delete(); }
}
[Fact]
public async Task GatewayExport_CropsPfxPassword()
{
var cert = SeedCert();
try
{
var resp = await _api.GetAsync($"Gateway/GatewayCert/export?format=json");
var raw = await resp.Content.ReadAsStringAsync();
Assert.True(resp.IsSuccessStatusCode, $"[Gateway 导出] HTTP {(Int32)resp.StatusCode}:{raw}");
Assert.Contains(cert.Domain, raw);
Assert.DoesNotContain(Secret, raw);
}
finally { cert.Delete(); }
}
/// <summary>守护用例:Gateway meta å·²è£ PfxPassword(现网既有行为),è£å‰ªå®žçްä¸å¾—回退。</summary>
[Fact]
public async Task GatewayMeta_ExcludesPfxPassword()
{
var api = await _api.GetJsonAsync("Gateway/GatewayCert/meta");
Assert.False(MetaListHasField(api.Data, "PfxPassword"), "Gateway meta.list ä¸åº”åŒ…å« PfxPassword å—æ®µ");
}
[Fact]
public async Task GatewayUpdate_WithoutPfxPassword_KeepsSecret()
{
var cert = SeedCert();
try
{
var http = await _factory.CreateAuthenticatedClient();
var resp = await http.PutAsJsonAsync(ApiClient.NormalizeUrl($"Gateway/GatewayCert/{cert.Id}"),
new { domain = cert.Domain, enable = true });
var raw = await resp.Content.ReadAsStringAsync();
Assert.True(resp.IsSuccessStatusCode, $"[Gateway 更新] HTTP {(Int32)resp.StatusCode}:{raw}");
var db = FindInDb(cert.Id);
Assert.NotNull(db);
Assert.Equal(Secret, db.PfxPassword);
}
finally { cert.Delete(); }
}
[Fact]
public async Task GatewayUpdate_WithNewPfxPassword_StoresIt()
{
var cert = SeedCert();
try
{
var http = await _factory.CreateAuthenticatedClient();
var resp = await http.PutAsJsonAsync(ApiClient.NormalizeUrl($"Gateway/GatewayCert/{cert.Id}"),
new { domain = cert.Domain, enable = true, pfxPassword = NewSecret });
var raw = await resp.Content.ReadAsStringAsync();
Assert.True(resp.IsSuccessStatusCode, $"[Gateway 更新密ç ] HTTP {(Int32)resp.StatusCode}:{raw}");
var db = FindInDb(cert.Id);
Assert.NotNull(db);
Assert.Equal(NewSecret, db.PfxPassword);
}
finally { cert.Delete(); }
}
#endregion
#region Deployment/SslCertificate
[Fact]
public async Task DeploymentList_CropsPfxPassword()
{
var cert = SeedCert();
try
{
var resp = await _api.GetAsync($"Deployment/SslCertificate?Q={cert.Domain}");
var raw = await resp.Content.ReadAsStringAsync();
Assert.True(resp.IsSuccessStatusCode, $"[Deployment 列表] HTTP {(Int32)resp.StatusCode}:{raw}");
Assert.Contains(cert.Domain, raw);
Assert.DoesNotContain(Secret, raw);
}
finally { cert.Delete(); }
}
[Fact]
public async Task DeploymentDetail_CropsPfxPassword()
{
var cert = SeedCert();
try
{
var resp = await _api.GetAsync($"Deployment/SslCertificate/{cert.Id}");
var raw = await resp.Content.ReadAsStringAsync();
Assert.True(resp.IsSuccessStatusCode, $"[Deployment 详情] HTTP {(Int32)resp.StatusCode}:{raw}");
Assert.DoesNotContain(Secret, raw);
}
finally { cert.Delete(); }
}
[Fact]
public async Task DeploymentExport_CropsPfxPassword()
{
var cert = SeedCert();
try
{
var resp = await _api.GetAsync($"Deployment/SslCertificate/export?format=json");
var raw = await resp.Content.ReadAsStringAsync();
Assert.True(resp.IsSuccessStatusCode, $"[Deployment 导出] HTTP {(Int32)resp.StatusCode}:{raw}");
Assert.Contains(cert.Domain, raw);
Assert.DoesNotContain(Secret, raw);
}
finally { cert.Delete(); }
}
/// <summary>Deployment meta ç›®å‰è¿ž ListFields éƒ½æœªè£ PfxPassword,补è£åŽ meta.list ä¸å¾—å«è¯¥å—段。</summary>
[Fact]
public async Task DeploymentMeta_ExcludesPfxPassword()
{
var api = await _api.GetJsonAsync("Deployment/SslCertificate/meta");
Assert.False(MetaListHasField(api.Data, "PfxPassword"), "Deployment meta.list ä¸åº”åŒ…å« PfxPassword å—æ®µ");
}
[Fact]
public async Task DeploymentUpdate_WithoutPfxPassword_KeepsSecret()
{
var cert = SeedCert();
try
{
var http = await _factory.CreateAuthenticatedClient();
var resp = await http.PutAsJsonAsync(ApiClient.NormalizeUrl($"Deployment/SslCertificate/{cert.Id}"),
new { domain = cert.Domain, enable = true });
var raw = await resp.Content.ReadAsStringAsync();
Assert.True(resp.IsSuccessStatusCode, $"[Deployment 更新] HTTP {(Int32)resp.StatusCode}:{raw}");
var db = FindInDb(cert.Id);
Assert.NotNull(db);
Assert.Equal(Secret, db.PfxPassword);
}
finally { cert.Delete(); }
}
#endregion
#region 写路径å“应投影(OBS-3 扩围 task-68)
// j-config 04:5x è£å®šï¼šå†™è·¯å¾„å“应è¯ä¹‰=è£å‰ªï¼ˆè°ƒç”¨æ–¹æ— 回显需求;å“应体入日志/DevTools=次生泄æ¼è½ç›˜ï¼›
// DELETE 回显å˜é‡å¯†ç æµå‘éžåˆ›å»ºè€…=最实质)。三写路径动作å“应ä¸å¾—å« PfxPassword 明文,
// 且两端点(Deployment/Gateway åŒå®žä½“åŒåŸºç±»ï¼‰ç»Ÿä¸€è£ä¸ç•™è±å£ã€‚åªè£å“åº”å¿«ç…§ï¼Œä¸æ”¹æŒä¹…化。
/// <summary>æå–写路径å“应 data.id(用于 POST 创建åŽçš„æ¸…ç†ï¼‰ã€‚</summary>
private static Int32 GetIdFromResponse(String raw, String tag)
{
var opts = new JsonSerializerOptions(JsonSerializerDefaults.Web);
var api = JsonSerializer.Deserialize<ApiResponse<JsonElement>>(raw, opts);
Assert.NotNull(api);
Assert.True(api!.Code == 0, $"[{tag}] Code={api.Code}:{raw}");
return api.Data.GetProperty("id").GetInt32();
}
[Fact]
public async Task InsertResponses_CropPfxPassword()
{
var http = await _factory.CreateAuthenticatedClient();
foreach (var url in new[] { "Deployment/SslCertificate", "Gateway/GatewayCert" })
{
var resp = await http.PostAsJsonAsync(ApiClient.NormalizeUrl(url),
new { domain = $"obs3w-{Guid.NewGuid():N}".Substring(0, 22) + ".example.com", enable = true, pfxPassword = Secret });
var raw = await resp.Content.ReadAsStringAsync();
Assert.True(resp.IsSuccessStatusCode, $"[{url} 创建] HTTP {(Int32)resp.StatusCode}:{raw}");
// 行必须真实返回(防"空å“应"å‡ç»¿ï¼‰
Assert.True(GetIdFromResponse(raw, url) > 0, $"[{url} 创建] å“应缺少 data.id:{raw}");
Assert.DoesNotContain(Secret, raw);
FindInDb(GetIdFromResponse(raw, url))?.Delete();
}
}
[Fact]
public async Task UpdateResponses_CropPfxPassword()
{
var cert = SeedCert();
try
{
var http = await _factory.CreateAuthenticatedClient();
foreach (var url in new[] { "Deployment/SslCertificate", "Gateway/GatewayCert" })
{
var resp = await http.PutAsJsonAsync(ApiClient.NormalizeUrl($"{url}/{cert.Id}"),
new { domain = cert.Domain, enable = true, pfxPassword = NewSecret });
var raw = await resp.Content.ReadAsStringAsync();
Assert.True(resp.IsSuccessStatusCode, $"[{url} 更新] HTTP {(Int32)resp.StatusCode}:{raw}");
// æ–°æäº¤å€¼ä¸Žæ—§å˜é‡å€¼éƒ½ä¸å¾—回显(å“应快照è£å‰ªï¼‰ï¼›å†™è·¯å¾„功能ä¸ç ´å(两 URL å„写一次 NewSecret,终值ä¸å˜ï¼‰
Assert.DoesNotContain(NewSecret, raw);
Assert.DoesNotContain(Secret, raw);
}
var db = FindInDb(cert.Id);
Assert.NotNull(db);
Assert.Equal(NewSecret, db.PfxPassword);
}
finally { cert.Delete(); }
}
[Fact]
public async Task DeleteResponses_CropPfxPassword()
{
foreach (var url in new[] { "Deployment/SslCertificate", "Gateway/GatewayCert" })
{
var cert = SeedCert();
try
{
var http = await _factory.CreateAuthenticatedClient();
var resp = await http.DeleteAsync(ApiClient.NormalizeUrl($"{url}/{cert.Id}"));
var raw = await resp.Content.ReadAsStringAsync();
Assert.True(resp.IsSuccessStatusCode, $"[{url} åˆ é™¤] HTTP {(Int32)resp.StatusCode}:{raw}");
// å˜é‡å¯†ç éšåˆ 除回显=最实质泄æ¼ï¼ˆæµå‘éžåˆ›å»ºè€…),ä¸å¾—å‡ºçŽ°ï¼›è¡Œåˆ é™¤è¯ä¹‰ä¸å˜
Assert.DoesNotContain(Secret, raw);
Assert.Null(FindInDb(cert.Id));
}
finally { FindInDb(cert.Id)?.Delete(); }
}
}
#endregion
#region meta form 区段守护(OBS-3 task-68 appendixï¼šé˜²ã€ŒåªæŸ¥ list 区段ã€ç±»æ¼ç½‘å†çŠ¯ï¼‰
/// <summary>守护用例:两控制器 meta form 区段(编辑表å•回显)ä¸å¾—åŒ…å« PfxPassword;新增录入(add 区段)ä¿ç•™ä¸åŠ¨ã€‚</summary>
[Fact]
public async Task MetaFormSections_ExcludePfxPassword()
{
var gw = await _api.GetJsonAsync("Gateway/GatewayCert/meta");
Assert.False(MetaFormHasField(gw.Data, "PfxPassword"), "Gateway meta.form ä¸åº”åŒ…å« PfxPassword å—æ®µ");
var dpl = await _api.GetJsonAsync("Deployment/SslCertificate/meta");
Assert.False(MetaFormHasField(dpl.Data, "PfxPassword"), "Deployment meta.form ä¸åº”åŒ…å« PfxPassword å—æ®µ");
}
#endregion
#region 辅助æ–言
/// <summary>检查 meta å“应 list åŒºæ®µå—æ®µåé›†åˆæ˜¯å¦åŒ…嫿Œ‡å®šå—段(大å°å†™ä¸æ•感)。</summary>
private static Boolean MetaListHasField(JsonElement data, String fieldName)
{
Assert.Equal(JsonValueKind.Object, data.ValueKind);
Assert.True(data.TryGetProperty("list", out var fields), $"meta å“应缺少 list 区段:{data}");
foreach (var field in fields.EnumerateArray())
{
foreach (var prop in field.EnumerateObject())
{
if (String.Equals(prop.Name, "Name", StringComparison.OrdinalIgnoreCase) &&
String.Equals(prop.Value.GetString(), fieldName, StringComparison.OrdinalIgnoreCase))
return true;
}
}
return false;
}
/// <summary>检查 meta å“应 form 区段(编辑表å•ï¼‰å—æ®µåé›†åˆæ˜¯å¦åŒ…嫿Œ‡å®šå—段(大å°å†™ä¸æ•感)。</summary>
private static Boolean MetaFormHasField(JsonElement data, String fieldName)
{
Assert.Equal(JsonValueKind.Object, data.ValueKind);
Assert.True(data.TryGetProperty("form", out var fields), $"meta å“应缺少 form 区段:{data}");
foreach (var field in fields.EnumerateArray())
{
foreach (var prop in field.EnumerateObject())
{
if (String.Equals(prop.Name, "Name", StringComparison.OrdinalIgnoreCase) &&
String.Equals(prop.Value.GetString(), fieldName, StringComparison.OrdinalIgnoreCase))
return true;
}
}
return false;
}
#endregion
}
|