Files
Telegram-Panel/tests/TelegramPanel.Web.Tests/WarpLifecycleRegressionTests.cs
jack a9f1e6afb0 fix: 修复任务失败明细与登录 WARP 分配
## 本次版本主要更新

### 修复问题

- 批量加群/订阅/启用 Bot 历史任务详情现在展示失败账号、目标和原因。
- 账号持续活跃任务详情在失败记录缺少账号文本时回退显示账号 ID。
- 手动登录的自动 WARP 选项改为只复用已启用的已有 WARP,不再为登录创建独立容器。

### 文档更新

- 补充存储桶备份恢复步骤与导入/登录出口说明。

## 验证

- Docker Build & Publish(PR #80)通过。
- pnpm --dir frontend run build && pnpm --dir frontend test
- dotnet build TelegramPanel.sln -c Release --no-restore && dotnet test TelegramPanel.sln -c Release --no-build
- mkdocs build --strict
- 本地浏览器 smoke:任务中心 #6 批量加群/订阅/启用 Bot 详情显示账号 #101、目标和 USERNAME_INVALID 原因。
2026-08-13 09:20:40 +08:00

1176 lines
44 KiB
C#
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging.Abstractions;
using TelegramPanel.Core.Interfaces;
using TelegramPanel.Core.Models;
using TelegramPanel.Core.Services.Proxy;
using TelegramPanel.Data;
using TelegramPanel.Data.Entities;
using TelegramPanel.Web.Api;
using TelegramPanel.Web.Services;
using WTelegram;
using Xunit;
namespace TelegramPanel.Web.Tests;
public sealed class WarpLifecycleRegressionTests
{
[Fact]
public async Task WARP记录允许复用历史主机端口()
{
await using var connection = new SqliteConnection("Data Source=:memory:");
await connection.OpenAsync();
var options = new DbContextOptionsBuilder<AppDbContext>()
.UseSqlite(connection)
.Options;
await using var db = new AppDbContext(options);
await db.Database.EnsureCreatedAsync();
db.WarpProfiles.Add(Profile("deleted-profile", "deleted", 42080));
await db.SaveChangesAsync();
db.WarpProfiles.Add(Profile("active-profile", "active", 42080));
await db.SaveChangesAsync();
Assert.Equal(2, await db.WarpProfiles.CountAsync());
}
[Fact]
public async Task WARP记录仍禁止重复主机端口()
{
await using var connection = new SqliteConnection("Data Source=:memory:");
await connection.OpenAsync();
var options = new DbContextOptionsBuilder<AppDbContext>()
.UseSqlite(connection)
.Options;
await using var db = new AppDbContext(options);
await db.Database.EnsureCreatedAsync();
db.WarpProfiles.Add(Profile("active-profile", "active", 42080));
await db.SaveChangesAsync();
db.WarpProfiles.Add(Profile("starting-profile", "starting", 42080));
await Assert.ThrowsAsync<DbUpdateException>(() => db.SaveChangesAsync());
}
[Fact]
public async Task WARP记录允许复用历史主机端口()
{
await using var connection = new SqliteConnection("Data Source=:memory:");
await connection.OpenAsync();
var options = new DbContextOptionsBuilder<AppDbContext>()
.UseSqlite(connection)
.Options;
await using var db = new AppDbContext(options);
await db.Database.EnsureCreatedAsync();
db.WarpProfiles.Add(Profile("failed-profile", "failed", 42080));
await db.SaveChangesAsync();
db.WarpProfiles.Add(Profile("active-profile", "active", 42080));
await db.SaveChangesAsync();
Assert.Equal(2, await db.WarpProfiles.CountAsync());
}
[Fact]
public async Task WARP记录会继续占用主机端口()
{
await using var connection = new SqliteConnection("Data Source=:memory:");
await connection.OpenAsync();
var options = new DbContextOptionsBuilder<AppDbContext>()
.UseSqlite(connection)
.Options;
await using var db = new AppDbContext(options);
await db.Database.EnsureCreatedAsync();
db.WarpProfiles.Add(Profile("cleanup-pending-profile", "cleanup_pending", 42080));
await db.SaveChangesAsync();
db.WarpProfiles.Add(Profile("new-profile", "creating", 42080));
await Assert.ThrowsAsync<DbUpdateException>(() => db.SaveChangesAsync());
}
[Fact]
public async Task WARP显示名超长时在创建资源前拒绝()
{
await using var connection = new SqliteConnection("Data Source=:memory:");
await connection.OpenAsync();
var options = new DbContextOptionsBuilder<AppDbContext>()
.UseSqlite(connection)
.Options;
await using var db = new AppDbContext(options);
await db.Database.EnsureCreatedAsync();
var configuration = new ConfigurationBuilder()
.AddInMemoryCollection()
.Build();
var manager = new WarpContainerManager(
db,
configuration,
new ProxyEgressProbeService(),
NullLogger<WarpContainerManager>.Instance);
await Assert.ThrowsAsync<ArgumentException>(() =>
manager.CreateAsync(new string('x', 201)));
Assert.Empty(await db.WarpProfiles.ToListAsync());
}
[Fact]
public async Task WARP基础配置默认关闭()
{
await using var connection = new SqliteConnection("Data Source=:memory:");
await connection.OpenAsync();
var options = new DbContextOptionsBuilder<AppDbContext>()
.UseSqlite(connection)
.Options;
await using var db = new AppDbContext(options);
var configuration = new ConfigurationBuilder()
.AddInMemoryCollection()
.Build();
var manager = new WarpContainerManager(
db,
configuration,
new ProxyEgressProbeService(),
NullLogger<WarpContainerManager>.Instance);
var status = await manager.GetStatusAsync();
Assert.False(status.Enabled);
}
[Fact]
public async Task WARP运行状态公开已配置的默认协议()
{
await using var connection = new SqliteConnection("Data Source=:memory:");
await connection.OpenAsync();
var options = new DbContextOptionsBuilder<AppDbContext>()
.UseSqlite(connection)
.Options;
await using var db = new AppDbContext(options);
var manager = CreateManager(
db,
new FakeWarpDockerClient(),
enabled: false,
defaultProtocol: "socks5");
var status = await manager.GetStatusAsync();
Assert.Equal("socks5", status.DefaultProtocol);
}
[Fact]
public void WARP容器资源限制配置会映射到Docker创建模板()
{
var configuration = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
["Proxy:Warp:Enabled"] = "true",
["Proxy:Warp:Container:MemoryLimitBytes"] = "134217728",
["Proxy:Warp:Container:CpuLimit"] = "0.5",
["Proxy:Warp:Container:PidsLimit"] = "64"
})
.Build();
var settings = WarpContainerManager.WarpSettings.From(configuration);
var hostConfig = WarpContainerManager.BuildDockerHostConfig(
settings,
"1080/tcp",
42080,
"warp-volume");
Assert.Equal(134217728L, hostConfig["Memory"]);
Assert.Equal(500000000L, hostConfig["NanoCpus"]);
Assert.Equal(64L, hostConfig["PidsLimit"]);
}
[Fact]
public async Task WARP创建达到配置上限时不会创建Docker资源()
{
await using var connection = new SqliteConnection("Data Source=:memory:");
await connection.OpenAsync();
var dbOptions = new DbContextOptionsBuilder<AppDbContext>()
.UseSqlite(connection)
.Options;
await using var db = new AppDbContext(dbOptions);
await db.Database.EnsureCreatedAsync();
var existingProfile = Profile("existing-cap-profile", "active", 42100);
existingProfile.Proxy = new OutboundProxy
{
Name = "existing-cap-warp",
Kind = OutboundProxyKinds.Warp,
Protocol = OutboundProxyProtocols.Http,
Host = existingProfile.ContainerName,
Port = 1080,
IsEnabled = true
};
db.WarpProfiles.Add(existingProfile);
await db.SaveChangesAsync();
var configuration = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
["Proxy:Warp:Enabled"] = "true",
["Proxy:Warp:DockerSocketPath"] = Path.Combine(Path.GetTempPath(), "fake-docker.sock"),
["Proxy:Warp:MaxManagedProxyCount"] = "1"
})
.Build();
var docker = new FakeWarpDockerClient();
var manager = new WarpContainerManager(
db,
configuration,
new SequenceProbeService(SuccessfulWarpProbe("2606:4700:100::9")),
NullLogger<WarpContainerManager>.Instance,
new FakeWarpDockerClientFactory(docker));
var error = await Assert.ThrowsAsync<InvalidOperationException>(() =>
manager.CreateAsync(requestId: "over-cap"));
Assert.Contains("受管 WARP 数量已达到上限 1", error.Message);
Assert.Equal(0, docker.CreateVolumeCalls);
Assert.Single(await db.OutboundProxies.AsNoTracking().ToListAsync());
}
[Fact]
public void WARP维护默认启用但不主动刷新健康出口()
{
var configuration = new ConfigurationBuilder()
.AddInMemoryCollection()
.Build();
var options = WarpMaintenanceOptions.From(configuration);
Assert.True(options.Enabled);
Assert.Equal(5, options.HealthCheckIntervalMinutes);
Assert.Equal(2, options.FailureThreshold);
Assert.Equal(30, options.RecoveryCooldownMinutes);
Assert.False(options.ScheduledRefreshEnabled);
Assert.Equal(720, options.ScheduledRefreshIntervalMinutes);
}
[Fact]
public async Task WARP可以覆盖系统默认协议()
{
await using var connection = new SqliteConnection("Data Source=:memory:");
await connection.OpenAsync();
var options = new DbContextOptionsBuilder<AppDbContext>()
.UseSqlite(connection)
.Options;
await using var db = new AppDbContext(options);
await db.Database.EnsureCreatedAsync();
var docker = new FakeWarpDockerClient
{
StartError = new InvalidOperationException("stop after capturing settings")
};
var manager = CreateManager(
db,
docker,
enabled: true,
defaultProtocol: "http");
await Assert.ThrowsAsync<InvalidOperationException>(() =>
manager.CreateAsync(requestId: "protocol-override", protocol: " SOCKS5 "));
Assert.Equal("socks5", docker.LastCreatedProtocol);
Assert.Empty(await db.OutboundProxies.AsNoTracking().ToListAsync());
}
[Fact]
public async Task WARP拒绝不支持的协议且不创建资源()
{
await using var connection = new SqliteConnection("Data Source=:memory:");
await connection.OpenAsync();
var options = new DbContextOptionsBuilder<AppDbContext>()
.UseSqlite(connection)
.Options;
await using var db = new AppDbContext(options);
await db.Database.EnsureCreatedAsync();
var docker = new FakeWarpDockerClient();
var manager = CreateManager(db, docker, enabled: true);
var error = await Assert.ThrowsAsync<ArgumentException>(() =>
manager.CreateAsync(protocol: "mtproto"));
Assert.Contains("HTTP 或 SOCKS5", error.Message);
Assert.Null(docker.LastCreatedProtocol);
Assert.Empty(await db.WarpProfiles.AsNoTracking().ToListAsync());
}
[Fact]
public async Task WARP静默改成另一协议()
{
await using var connection = new SqliteConnection("Data Source=:memory:");
await connection.OpenAsync();
var options = new DbContextOptionsBuilder<AppDbContext>()
.UseSqlite(connection)
.Options;
await using var db = new AppDbContext(options);
await db.Database.EnsureCreatedAsync();
var proxy = new OutboundProxy
{
Name = "existing-warp",
Kind = "warp",
Protocol = "http",
Host = "existing-warp",
Port = 1080,
IsEnabled = true
};
var profile = Profile("existing-profile", "active", 42080);
profile.RequestId = "same-request";
profile.Proxy = proxy;
db.WarpProfiles.Add(profile);
await db.SaveChangesAsync();
var manager = CreateManager(db, new FakeWarpDockerClient(), enabled: false);
var error = await Assert.ThrowsAsync<InvalidOperationException>(() =>
manager.CreateAsync(requestId: "same-request", protocol: "socks5"));
Assert.Contains("不能改为 SOCKS5", error.Message);
Assert.Equal("http", (await db.OutboundProxies.AsNoTracking().SingleAsync()).Protocol);
}
[Fact]
public void WARP代理接口公开独立容器的连接状态()
{
var proxy = new OutboundProxy
{
Id = 7,
Name = "warp-proxy",
Kind = "warp",
Protocol = "socks5",
Host = "warp-proxy",
Port = 1080,
IsEnabled = true,
WarpProfile = new WarpProfile
{
ProfileId = "profile-7",
ContainerName = "warp-proxy",
VolumeName = "warp-data-7",
HostPort = 42080,
Status = "active",
WarpStatus = "on"
}
};
var dto = ProxyApiEndpoints.ToDto(proxy);
Assert.Equal("on", dto.WarpStatus);
Assert.Equal("active", dto.WarpRuntimeStatus);
}
[Fact]
public async Task WARP连续失败达到阈值后自动重启并释放绑定账号客户端()
{
await using var connection = new SqliteConnection("Data Source=:memory:");
await connection.OpenAsync();
var dbOptions = new DbContextOptionsBuilder<AppDbContext>()
.UseSqlite(connection)
.Options;
await using var db = new AppDbContext(dbOptions);
await db.Database.EnsureCreatedAsync();
var profile = Profile("self-heal-profile", "active", 42090);
profile.ContainerId = "self-heal-container-id";
var proxy = new OutboundProxy
{
Name = "self-heal-warp",
Kind = OutboundProxyKinds.Warp,
Protocol = OutboundProxyProtocols.Http,
Host = profile.ContainerName,
Port = 1080,
IsEnabled = true,
WarpProfile = profile
};
var account = new Account
{
Phone = "+10000000001",
UserId = 1001,
SessionPath = "self-heal.session",
ApiId = 1,
ApiHash = "0123456789abcdef0123456789abcdef",
Proxy = proxy,
UseGlobalProxy = false
};
db.Accounts.Add(account);
await db.SaveChangesAsync();
var docker = new FakeWarpDockerClient();
docker.SeedResources(
profile.ProfileId,
profile.ContainerName,
profile.VolumeName,
profile.ContainerId);
var probe = new SequenceProbeService(
FailedProbe("tunnel down 1"),
FailedProbe("tunnel down 2"),
SuccessfulWarpProbe("2606:4700:100::1"));
var manager = CreateManager(db, docker, enabled: true, probeService: probe);
var clientPool = new TrackingClientPool();
var service = CreateProxyService(db, manager, probe, clientPool);
var options = MaintenanceOptions(failureThreshold: 2);
var first = await service.MaintainWarpAsync(proxy.Id, options);
var second = await service.MaintainWarpAsync(proxy.Id, options);
Assert.False(first.Restarted);
Assert.Contains("1/2", first.Summary);
Assert.True(second.Success);
Assert.True(second.Restarted);
Assert.True(second.Recovered);
Assert.Single(docker.RestartedContainerReferences);
Assert.Equal(profile.ContainerId, docker.RestartedContainerReferences[0]);
Assert.Equal(2, clientPool.RemovedAccountIds.Count(x => x == account.Id));
var saved = await db.OutboundProxies
.AsNoTracking()
.Include(x => x.WarpProfile)
.SingleAsync(x => x.Id == proxy.Id);
Assert.Equal("ok", saved.TestStatus);
Assert.Equal("2606:4700:100::1", saved.EgressIp);
Assert.Equal("active", saved.WarpProfile!.Status);
Assert.Equal(0, saved.WarpProfile.ConsecutiveFailures);
Assert.Equal(1, saved.WarpProfile.RecoveryCount);
Assert.NotNull(saved.WarpProfile.LastRecoveredAtUtc);
}
[Fact]
public async Task WARP默认不定时换IP但显式启用后到期刷新()
{
await using var connection = new SqliteConnection("Data Source=:memory:");
await connection.OpenAsync();
var dbOptions = new DbContextOptionsBuilder<AppDbContext>()
.UseSqlite(connection)
.Options;
await using var db = new AppDbContext(dbOptions);
await db.Database.EnsureCreatedAsync();
var profile = Profile("scheduled-refresh-profile", "active", 42091);
profile.ContainerId = "scheduled-refresh-container-id";
profile.CreatedAtUtc = DateTime.UtcNow.AddHours(-13);
var proxy = new OutboundProxy
{
Name = "scheduled-refresh-warp",
Kind = OutboundProxyKinds.Warp,
Protocol = OutboundProxyProtocols.Socks5,
Host = profile.ContainerName,
Port = 1080,
IsEnabled = true,
WarpProfile = profile
};
db.OutboundProxies.Add(proxy);
await db.SaveChangesAsync();
var docker = new FakeWarpDockerClient();
docker.SeedResources(
profile.ProfileId,
profile.ContainerName,
profile.VolumeName,
profile.ContainerId);
var probe = new SequenceProbeService(
SuccessfulWarpProbe("2606:4700:100::2"),
SuccessfulWarpProbe("2606:4700:100::3"),
SuccessfulWarpProbe("2606:4700:100::4"));
var manager = CreateManager(db, docker, enabled: true, probeService: probe);
var service = CreateProxyService(db, manager, probe);
var safeDefault = await service.MaintainWarpAsync(
proxy.Id,
MaintenanceOptions(scheduledRefreshEnabled: false));
var scheduled = await service.MaintainWarpAsync(
proxy.Id,
MaintenanceOptions(scheduledRefreshEnabled: true));
Assert.True(safeDefault.Success);
Assert.False(safeDefault.Restarted);
Assert.True(scheduled.Success);
Assert.True(scheduled.Restarted);
Assert.Single(docker.RestartedContainerReferences);
}
[Fact]
public async Task WARP不会被后台或手动刷新打断()
{
await using var connection = new SqliteConnection("Data Source=:memory:");
await connection.OpenAsync();
var dbOptions = new DbContextOptionsBuilder<AppDbContext>()
.UseSqlite(connection)
.Options;
await using var db = new AppDbContext(dbOptions);
await db.Database.EnsureCreatedAsync();
var profile = Profile("claimed-profile", "active", 42092);
profile.RequestId = "telegram-panel.internal.login.123.claimed";
profile.ContainerId = "claimed-container-id";
var proxy = new OutboundProxy
{
Name = "claimed-warp",
Kind = OutboundProxyKinds.Warp,
Protocol = OutboundProxyProtocols.Http,
Host = profile.ContainerName,
Port = 1080,
IsEnabled = true,
WarpProfile = profile
};
db.OutboundProxies.Add(proxy);
await db.SaveChangesAsync();
var docker = new FakeWarpDockerClient();
docker.SeedResources(
profile.ProfileId,
profile.ContainerName,
profile.VolumeName,
profile.ContainerId);
var probe = new SequenceProbeService();
var claims = new TemporaryWarpClaimStore();
var loginStates = new AccountLoginProxyStateStore();
var manager = CreateManager(db, docker, enabled: true, probeService: probe);
var service = CreateProxyService(
db,
manager,
probe,
temporaryWarpClaims: claims,
warpProxyUsageGuard: loginStates);
using (claims.ClaimRequest(profile.RequestId))
{
var background = await service.MaintainWarpAsync(
proxy.Id,
MaintenanceOptions());
var manual = await service.MaintainWarpAsync(
proxy.Id,
MaintenanceOptions(),
forceRestart: true);
Assert.True(background.Success);
Assert.Contains("跳过巡检", background.Summary);
Assert.False(manual.Success);
Assert.Contains("已阻止刷新", manual.Summary);
}
var frozenProxy = new ProxyConnectionOptions(
proxy.Id,
proxy.Name,
proxy.Kind,
proxy.Protocol,
proxy.Host,
proxy.Port,
null,
null,
null);
Assert.True(loginStates.TryAdd(new AccountLoginProxyState(
123,
new AccountProxyBindingInput("existing", proxy.Id),
"existing",
new AccountProxyResolution(frozenProxy, false),
null,
null,
null,
DateTimeOffset.UtcNow)));
var frozenBackground = await service.MaintainWarpAsync(
proxy.Id,
MaintenanceOptions());
var frozenManual = await service.MaintainWarpAsync(
proxy.Id,
MaintenanceOptions(),
forceRestart: true);
Assert.True(frozenBackground.Success);
Assert.Contains("跳过巡检", frozenBackground.Summary);
Assert.False(frozenManual.Success);
Assert.Contains("已阻止刷新", frozenManual.Summary);
Assert.Equal(0, probe.CallCount);
Assert.Empty(docker.RestartedContainerReferences);
}
[Fact]
public async Task ()
{
await using var connection = new SqliteConnection("Data Source=:memory:");
await connection.OpenAsync();
var options = new DbContextOptionsBuilder<AppDbContext>()
.UseSqlite(connection)
.Options;
await using var db = new AppDbContext(options);
await db.Database.EnsureCreatedAsync();
var docker = new FakeWarpDockerClient
{
StartError = new InvalidOperationException("start failed")
};
var manager = CreateManager(db, docker, enabled: true);
await Assert.ThrowsAsync<InvalidOperationException>(() =>
manager.CreateAsync(requestId: "cleanup-success"));
Assert.Empty(await db.OutboundProxies.AsNoTracking().ToListAsync());
Assert.Empty(await db.WarpProfiles.AsNoTracking().ToListAsync());
Assert.Empty(docker.Containers);
Assert.Empty(docker.Volumes);
}
[Fact]
public async Task ()
{
await using var connection = new SqliteConnection("Data Source=:memory:");
await connection.OpenAsync();
var options = new DbContextOptionsBuilder<AppDbContext>()
.UseSqlite(connection)
.Options;
await using var db = new AppDbContext(options);
await db.Database.EnsureCreatedAsync();
var docker = new FakeWarpDockerClient
{
StartError = new InvalidOperationException("start failed"),
RemoveContainerError = new InvalidOperationException("container remove failed"),
RemoveVolumeError = new InvalidOperationException("volume remove failed")
};
var manager = CreateManager(db, docker, enabled: true);
var error = await Assert.ThrowsAsync<InvalidOperationException>(() =>
manager.CreateAsync(requestId: "cleanup-failed"));
Assert.Contains("已保留禁用代理", error.Message);
var proxy = await db.OutboundProxies
.AsNoTracking()
.Include(x => x.WarpProfile)
.SingleAsync();
Assert.False(proxy.IsEnabled);
Assert.Equal("fail", proxy.TestStatus);
Assert.NotNull(proxy.WarpProfile);
// 补偿失败时资源仍可能占用宿主端口,必须保持 cleanup_pending
// 避免被“failed”状态的端口过滤索引错误地再次分配。
Assert.Equal("cleanup_pending", proxy.WarpProfile!.Status);
Assert.Equal("cleanup-failed", proxy.WarpProfile.RequestId);
Assert.False(proxy.WarpProfile.DesiredEnabled);
Assert.False(string.IsNullOrWhiteSpace(proxy.WarpProfile.ContainerId));
Assert.NotEmpty(docker.Containers);
Assert.NotEmpty(docker.Volumes);
docker.RemoveContainerError = null;
docker.RemoveVolumeError = null;
var service = CreateProxyService(db, manager);
await service.DeleteAsync(proxy.Id);
Assert.Empty(await db.OutboundProxies.AsNoTracking().ToListAsync());
var deletedProfile = await db.WarpProfiles.AsNoTracking().SingleAsync();
Assert.Equal("deleted", deletedProfile.Status);
Assert.Null(deletedProfile.OutboundProxyId);
Assert.Empty(docker.Containers);
Assert.Empty(docker.Volumes);
}
[Fact]
public async Task ContainerId为空会按名称校验并删除()
{
await using var connection = new SqliteConnection("Data Source=:memory:");
await connection.OpenAsync();
var options = new DbContextOptionsBuilder<AppDbContext>()
.UseSqlite(connection)
.Options;
await using var db = new AppDbContext(options);
await db.Database.EnsureCreatedAsync();
var proxy = new OutboundProxy
{
Name = "recover-by-name",
Kind = "warp",
Protocol = "http",
Host = "recover-by-name",
Port = 1080,
IsEnabled = false,
TestStatus = "fail"
};
var profile = new WarpProfile
{
ProfileId = "recover-by-name-profile",
RequestId = "recover-by-name-request",
ContainerName = "recover-by-name-container",
ContainerId = null,
VolumeName = "recover-by-name-volume",
HostPort = 42201,
Status = "failed",
DesiredEnabled = false,
Proxy = proxy
};
db.WarpProfiles.Add(profile);
await db.SaveChangesAsync();
var docker = new FakeWarpDockerClient();
docker.SeedResources(
profile.ProfileId,
profile.ContainerName,
profile.VolumeName,
"docker-generated-id");
var manager = CreateManager(db, docker, enabled: true);
await CreateProxyService(db, manager).DeleteAsync(proxy.Id);
Assert.Contains(profile.ContainerName, docker.RemovedContainerReferences);
Assert.Empty(docker.Containers);
Assert.Empty(docker.Volumes);
}
[Fact]
public async Task WARP供重试清理()
{
await using var connection = new SqliteConnection("Data Source=:memory:");
await connection.OpenAsync();
var options = new DbContextOptionsBuilder<AppDbContext>()
.UseSqlite(connection)
.Options;
await using var db = new AppDbContext(options);
await db.Database.EnsureCreatedAsync();
var proxy = new OutboundProxy
{
Name = "partial-delete",
Kind = "warp",
Protocol = "http",
Host = "partial-delete",
Port = 1080,
IsEnabled = true,
TestStatus = "ok"
};
var profile = new WarpProfile
{
ProfileId = "partial-delete-profile",
RequestId = "partial-delete-request",
ContainerName = "partial-delete-container",
ContainerId = "partial-delete-container-id",
VolumeName = "partial-delete-volume",
HostPort = 42202,
Status = "active",
DesiredEnabled = true,
Proxy = proxy
};
db.WarpProfiles.Add(profile);
await db.SaveChangesAsync();
var docker = new FakeWarpDockerClient
{
RemoveVolumeError = new InvalidOperationException("volume remove failed")
};
docker.SeedResources(
profile.ProfileId,
profile.ContainerName,
profile.VolumeName,
profile.ContainerId);
var service = CreateProxyService(db, CreateManager(db, docker, enabled: true));
var error = await Assert.ThrowsAsync<InvalidOperationException>(() =>
service.DeleteAsync(proxy.Id));
Assert.Contains("WARP 资源清理失败", error.Message);
Assert.Empty(docker.Containers);
Assert.Single(docker.Volumes);
var retainedProxy = await db.OutboundProxies.AsNoTracking().SingleAsync();
Assert.False(retainedProxy.IsEnabled);
Assert.Equal("fail", retainedProxy.TestStatus);
var retainedProfile = await db.WarpProfiles.AsNoTracking().SingleAsync();
Assert.Equal("deleting", retainedProfile.Status);
Assert.False(retainedProfile.DesiredEnabled);
docker.RemoveVolumeError = null;
await service.DeleteAsync(proxy.Id);
Assert.Empty(await db.OutboundProxies.AsNoTracking().ToListAsync());
Assert.Empty(docker.Containers);
Assert.Empty(docker.Volumes);
var deletedProfile = await db.WarpProfiles.AsNoTracking().SingleAsync();
Assert.Equal("deleted", deletedProfile.Status);
Assert.Null(deletedProfile.OutboundProxyId);
}
[Fact]
public async Task Profile会先严格清理且失败时保留请求键()
{
await using var connection = new SqliteConnection("Data Source=:memory:");
await connection.OpenAsync();
var options = new DbContextOptionsBuilder<AppDbContext>()
.UseSqlite(connection)
.Options;
await using var db = new AppDbContext(options);
await db.Database.EnsureCreatedAsync();
var profile = new WarpProfile
{
ProfileId = "historical-failed-profile",
RequestId = "same-request",
ContainerName = "historical-failed-container",
VolumeName = "historical-failed-volume",
HostPort = 42199,
Status = "failed",
DesiredEnabled = false
};
db.WarpProfiles.Add(profile);
await db.SaveChangesAsync();
var docker = new FakeWarpDockerClient
{
RemoveContainerError = new InvalidOperationException("container remove failed"),
RemoveVolumeError = new InvalidOperationException("volume remove failed")
};
docker.SeedResources(
profile.ProfileId,
profile.ContainerName,
profile.VolumeName,
"historical-container-id");
var manager = CreateManager(db, docker, enabled: false);
await Assert.ThrowsAsync<InvalidOperationException>(() =>
manager.CreateAsync(requestId: "same-request"));
var retained = await db.WarpProfiles.AsNoTracking().SingleAsync();
Assert.Equal("same-request", retained.RequestId);
Assert.Equal(0, docker.CreateVolumeCalls);
docker.RemoveContainerError = null;
docker.RemoveVolumeError = null;
await Assert.ThrowsAsync<InvalidOperationException>(() =>
manager.CreateAsync(requestId: "same-request"));
Assert.Empty(await db.WarpProfiles.AsNoTracking().ToListAsync());
Assert.Empty(docker.Containers);
Assert.Empty(docker.Volumes);
Assert.Equal(0, docker.CreateVolumeCalls);
}
internal static WarpContainerManager CreateManager(
AppDbContext db,
FakeWarpDockerClient docker,
bool enabled,
string defaultProtocol = "http",
IProxyEgressProbeService? probeService = null)
{
var configuration = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
["Proxy:Warp:Enabled"] = enabled.ToString(),
["Proxy:Warp:DockerSocketPath"] = Path.Combine(Path.GetTempPath(), "fake-docker.sock"),
["Proxy:Warp:Protocol"] = defaultProtocol
})
.Build();
return new WarpContainerManager(
db,
configuration,
probeService ?? new ProxyEgressProbeService(),
NullLogger<WarpContainerManager>.Instance,
new FakeWarpDockerClientFactory(docker));
}
private static ProxyManagementService CreateProxyService(
AppDbContext db,
WarpContainerManager manager,
IProxyEgressProbeService? probeService = null,
ITelegramClientPool? clientPool = null,
TemporaryWarpClaimStore? temporaryWarpClaims = null,
IWarpProxyUsageGuard? warpProxyUsageGuard = null) =>
new(
db,
clientPool ?? new NoopClientPool(),
probeService ?? new ProxyEgressProbeService(),
manager,
NullLogger<ProxyManagementService>.Instance,
configuration: null,
temporaryWarpClaims: temporaryWarpClaims,
warpProxyUsageGuard: warpProxyUsageGuard);
private static WarpMaintenanceOptions MaintenanceOptions(
int failureThreshold = 2,
bool scheduledRefreshEnabled = false) =>
new(
true,
0,
5,
failureThreshold,
30,
2,
0,
scheduledRefreshEnabled,
720);
private static EgressProbeResult FailedProbe(string error) =>
new(false, null, null, null, null, null, null, DateTime.UtcNow, error);
private static EgressProbeResult SuccessfulWarpProbe(string ip) =>
new(true, ip, "US", null, null, "on", 12, DateTime.UtcNow, null);
private static WarpProfile Profile(string profileId, string status, int hostPort) => new()
{
ProfileId = profileId,
ContainerName = $"container-{profileId}",
VolumeName = $"volume-{profileId}",
HostPort = hostPort,
Status = status,
DesiredEnabled = status is not "deleted"
};
private sealed class FakeWarpDockerClientFactory : WarpContainerManager.IWarpDockerClientFactory
{
private readonly FakeWarpDockerClient _client;
public FakeWarpDockerClientFactory(FakeWarpDockerClient client)
{
_client = client;
}
public bool PlatformSupported => true;
public WarpContainerManager.IWarpDockerClient Create(string socketPath) => _client;
}
internal sealed class FakeWarpDockerClient : WarpContainerManager.IWarpDockerClient
{
private readonly List<ContainerResource> _containers = new();
private readonly Dictionary<string, string> _volumes = new(StringComparer.Ordinal);
private int _nextContainerId;
public Exception? StartError { get; set; }
public Exception? StopError { get; set; }
public Exception? RestartError { get; set; }
public Exception? RemoveContainerError { get; set; }
public Exception? RemoveVolumeError { get; set; }
public int CreateVolumeCalls { get; private set; }
public string? LastCreatedProtocol { get; private set; }
public IReadOnlyCollection<string> Containers => _containers.Select(x => x.Id).ToArray();
public IReadOnlyCollection<string> Volumes => _volumes.Keys.ToArray();
public Queue<Exception> StartErrors { get; } = new();
public List<int> CreatedHostPorts { get; } = new();
public List<string> StartAttemptedContainerReferences { get; } = new();
public List<string> StartedContainerReferences { get; } = new();
public List<string> StoppedContainerReferences { get; } = new();
public List<string> RestartedContainerReferences { get; } = new();
public List<string> RemovedContainerReferences { get; } = new();
public void SeedResources(
string profileId,
string containerName,
string volumeName,
string containerId)
{
_containers.Add(new ContainerResource(containerId, containerName, profileId));
_volumes[volumeName] = profileId;
}
public Task<string?> GetVersionAsync(CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
return Task.FromResult<string?>("fake-docker");
}
public Task EnsureImageAsync(
string image,
bool pullIfMissing,
CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
return Task.CompletedTask;
}
public Task CreateVolumeAsync(
string volumeName,
string profileId,
CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
CreateVolumeCalls++;
_volumes[volumeName] = profileId;
return Task.CompletedTask;
}
public Task<string> CreateContainerAsync(
WarpContainerManager.WarpSettings settings,
string profileId,
string containerName,
string volumeName,
int hostPort,
CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
LastCreatedProtocol = settings.Protocol;
CreatedHostPorts.Add(hostPort);
var id = $"fake-container-{++_nextContainerId}";
_containers.Add(new ContainerResource(id, containerName, profileId));
return Task.FromResult(id);
}
public Task StartContainerAsync(
string containerId,
CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
StartAttemptedContainerReferences.Add(containerId);
if (StartErrors.Count > 0)
throw StartErrors.Dequeue();
if (StartError != null)
throw StartError;
StartedContainerReferences.Add(containerId);
return Task.CompletedTask;
}
public Task StopContainerAsync(
string containerId,
CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
if (StopError != null)
throw StopError;
StoppedContainerReferences.Add(containerId);
return Task.CompletedTask;
}
public Task RestartContainerAsync(
string containerId,
CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
if (RestartError != null)
throw RestartError;
RestartedContainerReferences.Add(containerId);
return Task.CompletedTask;
}
public Task<bool> VerifyContainerOwnershipAsync(
string containerId,
string profileId,
CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
var container = _containers.FirstOrDefault(
x => x.Id == containerId || x.Name == containerId);
if (container == null)
return Task.FromResult(false);
if (!string.Equals(container.ProfileId, profileId, StringComparison.Ordinal))
throw new InvalidOperationException("Docker 容器不属于当前 WARP 配置");
return Task.FromResult(true);
}
public Task<bool> VerifyVolumeOwnershipAsync(
string volumeName,
string profileId,
CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
if (!_volumes.TryGetValue(volumeName, out var actualProfileId))
return Task.FromResult(false);
if (!string.Equals(actualProfileId, profileId, StringComparison.Ordinal))
throw new InvalidOperationException("Docker 卷不属于当前 WARP 配置");
return Task.FromResult(true);
}
public Task RemoveContainerAsync(
string containerId,
CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
if (RemoveContainerError != null)
throw RemoveContainerError;
var container = _containers.FirstOrDefault(
x => x.Id == containerId || x.Name == containerId);
if (container != null)
_containers.Remove(container);
RemovedContainerReferences.Add(containerId);
return Task.CompletedTask;
}
public Task RemoveVolumeAsync(
string volumeName,
CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
if (RemoveVolumeError != null)
throw RemoveVolumeError;
_volumes.Remove(volumeName);
return Task.CompletedTask;
}
public void Dispose()
{
}
private sealed record ContainerResource(string Id, string Name, string ProfileId);
}
private sealed class NoopClientPool : ITelegramClientPool
{
public int ActiveClientCount => 0;
public Task<Client> GetOrCreateClientAsync(
int accountId,
int apiId,
string apiHash,
string sessionPath,
string? sessionKey = null,
string? phoneNumber = null,
long? userId = null) =>
throw new NotSupportedException();
public Client? GetClient(int accountId) => null;
public Task RemoveClientAsync(int accountId) => Task.CompletedTask;
public Task RemoveAllClientsAsync() => Task.CompletedTask;
public bool IsClientConnected(int accountId) => false;
}
private sealed class TrackingClientPool : ITelegramClientPool
{
public List<int> RemovedAccountIds { get; } = new();
public int ActiveClientCount => 0;
public Task<Client> GetOrCreateClientAsync(
int accountId,
int apiId,
string apiHash,
string sessionPath,
string? sessionKey = null,
string? phoneNumber = null,
long? userId = null) =>
throw new NotSupportedException();
public Client? GetClient(int accountId) => null;
public Task RemoveClientAsync(int accountId)
{
RemovedAccountIds.Add(accountId);
return Task.CompletedTask;
}
public Task RemoveAllClientsAsync() => Task.CompletedTask;
public bool IsClientConnected(int accountId) => false;
}
private sealed class SequenceProbeService : IProxyEgressProbeService
{
private readonly Queue<EgressProbeResult> _results;
private EgressProbeResult? _lastResult;
public int CallCount { get; private set; }
public SequenceProbeService(params EgressProbeResult[] results)
{
_results = new Queue<EgressProbeResult>(results);
}
public Task<EgressProbeResult> ProbePanelAsync(
CancellationToken cancellationToken = default) =>
NextAsync(cancellationToken);
public Task<EgressProbeResult> ProbeProxyAsync(
OutboundProxy proxy,
string stableAccountKey,
CancellationToken cancellationToken = default) =>
NextAsync(cancellationToken);
public Task<EgressProbeResult> ProbeProxyAsync(
ProxyConnectionOptions options,
bool requireWarp = false,
CancellationToken cancellationToken = default) =>
NextAsync(cancellationToken);
private Task<EgressProbeResult> NextAsync(CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
CallCount++;
if (_results.TryDequeue(out var result))
_lastResult = result;
return Task.FromResult(_lastResult
?? throw new InvalidOperationException("未配置出口检测结果"));
}
}
}