Files
Deal/src/telegram-service/Deal.Telegram.Tests/Support/SessionStorageTests.cs
T
Rustam Khalimov b053d58335 Почистить комментарии от упоминаний процесса
Удалены <remarks>, <summary> сжаты до короткой фразы, вырезаны
ссылки на Task/Ruling/этап/python/прототип; //-комментарии со ссылками
на процесс удалены; то же в .proto. Правила обновлены в
docs/spec/Код-стайл-Дейл.md. Строк комментариев 27210 -> ~19100.
2026-09-11 13:39:39 +03:00

355 lines
12 KiB
C#

using System.Security.Cryptography;
using Deal.Telegram.Sessions;
using Deal.Telegram.Tests.Telegram;
using Grpc.Core;
namespace Deal.Telegram.Tests.Support;
/// <summary>
/// Тесты файлового хранилища сессий и AES-GCM-обёртки.
/// </summary>
public sealed class SessionStorageTests
{
private const int ApiId = 123456;
private const string ApiHash = "0123456789abcdef0123456789abcdef";
private static readonly byte[] SessionBytes = [9, 8, 7, 6, 5, 4, 3];
/// <summary>
/// Шифр: roundtrip произвольных байт сессии.
/// </summary>
[Fact]
public void Cipher_EncryptDecrypt_Roundtrip()
{
string dir = TestSessionFactory.NewSessionsDirectory();
try
{
SessionFileCipher cipher = TestSessionFactory.Cipher(dir);
byte[] payload = new byte[2048];
RandomNumberGenerator.Fill(payload);
string encrypted = cipher.EncryptBytes(payload);
byte[]? decrypted = cipher.DecryptBytes(encrypted);
Assert.NotNull(decrypted);
Assert.Equal(payload, decrypted);
}
finally
{
TestSessionFactory.Cleanup(dir);
}
}
/// <summary>
/// Шифр: значение имеет префикс enc: и не содержит открытых байт сессии.
/// </summary>
[Fact]
public void Cipher_Encrypt_ProducesEncPrefix_AndHidesPlaintext()
{
string dir = TestSessionFactory.NewSessionsDirectory();
try
{
SessionFileCipher cipher = TestSessionFactory.Cipher(dir);
string encrypted = cipher.EncryptBytes(SessionBytes);
Assert.StartsWith(SessionFileCipher.EncryptedPrefix, encrypted, StringComparison.Ordinal);
Assert.DoesNotContain(System.Text.Encoding.UTF8.GetString(SessionBytes), encrypted, StringComparison.Ordinal);
}
finally
{
TestSessionFactory.Cleanup(dir);
}
}
/// <summary>
/// Шифр: значение не в формате enc: — Decrypt возвращает null
/// </summary>
[Theory]
[InlineData("")]
[InlineData("мусор")]
[InlineData("plain:AAAA")]
public void Cipher_Decrypt_NotOurFormat_ReturnsNull(string value)
{
string dir = TestSessionFactory.NewSessionsDirectory();
try
{
SessionFileCipher cipher = TestSessionFactory.Cipher(dir);
Assert.Null(cipher.DecryptBytes(value));
}
finally
{
TestSessionFactory.Cleanup(dir);
}
}
/// <summary>
/// Шифр: чужой ключ не расшифровывает
/// </summary>
[Fact]
public void Cipher_Decrypt_WrongKey_ThrowsCryptographic()
{
string dir = TestSessionFactory.NewSessionsDirectory();
try
{
SessionFileCipher encoder = TestSessionFactory.Cipher(dir, TestSessionFactory.TestKey);
SessionFileCipher decoder = TestSessionFactory.Cipher(dir, TestSessionFactory.TestKey.Select(b => (byte)(b ^ 0xFF)).ToArray());
string encrypted = encoder.EncryptBytes(SessionBytes);
Assert.ThrowsAny<CryptographicException>(() => decoder.DecryptBytes(encrypted));
}
finally
{
TestSessionFactory.Cleanup(dir);
}
}
/// <summary>
/// Store: Save → Load возвращает тот же StoredSession
/// </summary>
[Fact]
public async Task Store_SaveLoad_Roundtrip()
{
string dir = TestSessionFactory.NewSessionsDirectory();
try
{
SessionStore store = TestSessionFactory.Store(dir);
var stored = NewStoredSession();
await store.SaveAsync("tenant-a", stored);
StoredSession? loaded = await store.LoadAsync("tenant-a");
Assert.NotNull(loaded);
Assert.Equal(stored.ApiId, loaded.ApiId);
Assert.Equal(stored.ApiHash, loaded.ApiHash);
Assert.Equal(stored.SessionBytes, loaded.SessionBytes);
}
finally
{
TestSessionFactory.Cleanup(dir);
}
}
/// <summary>
/// Store: файл создаётся как enc:-обёртка
/// </summary>
[Fact]
public async Task Store_Save_WritesEncryptedFileWithoutPlaintext()
{
string dir = TestSessionFactory.NewSessionsDirectory();
try
{
SessionStore store = TestSessionFactory.Store(dir);
string filePath = Path.Combine(dir, "tenant-a.session");
await store.SaveAsync("tenant-a", NewStoredSession());
Assert.True(File.Exists(filePath));
string content = await File.ReadAllTextAsync(filePath);
Assert.StartsWith(SessionFileCipher.EncryptedPrefix, content, StringComparison.Ordinal);
Assert.DoesNotContain(ApiHash, content, StringComparison.Ordinal);
Assert.DoesNotContain(Convert.ToBase64String(SessionBytes), content, StringComparison.Ordinal);
}
finally
{
TestSessionFactory.Cleanup(dir);
}
}
/// <summary>
/// Store: повторное сохранение атомарно перезаписывает
/// </summary>
[Fact]
public async Task Store_SaveTwice_LastWriteWins()
{
string dir = TestSessionFactory.NewSessionsDirectory();
try
{
SessionStore store = TestSessionFactory.Store(dir);
var first = NewStoredSession(apiId: 111, marker: [1, 1, 1]);
var second = NewStoredSession(apiId: 222, marker: [2, 2, 2]);
await store.SaveAsync("tenant-a", first);
await store.SaveAsync("tenant-a", second);
StoredSession? loaded = await store.LoadAsync("tenant-a");
Assert.NotNull(loaded);
Assert.Equal(222, loaded.ApiId);
Assert.Equal([2, 2, 2], loaded.SessionBytes);
}
finally
{
TestSessionFactory.Cleanup(dir);
}
}
/// <summary>
/// Store: файла нет → Load возвращает null
/// </summary>
[Fact]
public async Task Store_Load_MissingFile_ReturnsNull()
{
string dir = TestSessionFactory.NewSessionsDirectory();
try
{
SessionStore store = TestSessionFactory.Store(dir);
Assert.Null(await store.LoadAsync("tenant-a"));
}
finally
{
TestSessionFactory.Cleanup(dir);
}
}
/// <summary>
/// Store: чужой ключ → файл нечитаем → Load возвращает null
/// </summary>
[Fact]
public async Task Store_Load_WrongKey_ReturnsNull()
{
string dir = TestSessionFactory.NewSessionsDirectory();
try
{
SessionStore storeA = TestSessionFactory.Store(dir, TestSessionFactory.TestKey);
await storeA.SaveAsync("tenant-a", NewStoredSession());
SessionStore storeB = TestSessionFactory.Store(dir, TestSessionFactory.TestKey.Select(b => (byte)(b ^ 0xFF)).ToArray());
Assert.Null(await storeB.LoadAsync("tenant-a"));
}
finally
{
TestSessionFactory.Cleanup(dir);
}
}
/// <summary>
/// Store: битый файл
/// </summary>
[Fact]
public async Task Store_Load_CorruptFile_ReturnsNull_FileKept()
{
string dir = TestSessionFactory.NewSessionsDirectory();
try
{
Directory.CreateDirectory(dir);
string filePath = Path.Combine(dir, "tenant-a.session");
await File.WriteAllTextAsync(filePath, "enc:не-base64-мусор");
SessionStore store = TestSessionFactory.Store(dir);
Assert.Null(await store.LoadAsync("tenant-a"));
Assert.True(File.Exists(filePath));
}
finally
{
TestSessionFactory.Cleanup(dir);
}
}
/// <summary>
/// Store: изоляция тенантов — файлы разных тенантов не пересекаются.
/// </summary>
[Fact]
public async Task Store_TenantIsolation_SeparateFiles()
{
string dir = TestSessionFactory.NewSessionsDirectory();
try
{
SessionStore store = TestSessionFactory.Store(dir);
await store.SaveAsync("tenant-a", NewStoredSession(apiId: 111, marker: [1]));
await store.SaveAsync("tenant-b", NewStoredSession(apiId: 222, marker: [2]));
Assert.Equal(111, (await store.LoadAsync("tenant-a"))?.ApiId);
Assert.Equal(222, (await store.LoadAsync("tenant-b"))?.ApiId);
}
finally
{
TestSessionFactory.Cleanup(dir);
}
}
/// <summary>
/// Store: Delete удаляет файл; повторный Delete не бросает.
/// </summary>
[Fact]
public async Task Store_Delete_RemovesFile()
{
string dir = TestSessionFactory.NewSessionsDirectory();
try
{
SessionStore store = TestSessionFactory.Store(dir);
await store.SaveAsync("tenant-a", NewStoredSession());
await store.DeleteAsync("tenant-a");
Assert.False(File.Exists(Path.Combine(dir, "tenant-a.session")));
Assert.Null(await store.LoadAsync("tenant-a"));
await store.DeleteAsync("tenant-a");
}
finally
{
TestSessionFactory.Cleanup(dir);
}
}
/// <summary>
/// Store: ListTenantIds перечисляет только *.session текущего каталога
/// </summary>
[Fact]
public async Task Store_ListTenantIds_ReturnsSessionFilesOnly()
{
string dir = TestSessionFactory.NewSessionsDirectory();
try
{
Directory.CreateDirectory(dir);
SessionStore store = TestSessionFactory.Store(dir);
await store.SaveAsync("tenant-a", NewStoredSession());
await store.SaveAsync("tenant-b", NewStoredSession());
await File.WriteAllTextAsync(Path.Combine(dir, "notes.txt"), "не сессия");
Directory.CreateDirectory(Path.Combine(dir, "sub"));
await File.WriteAllTextAsync(Path.Combine(dir, "sub", "tenant-c.session"), "не наш файл");
string[] tenants = store.ListTenantIds().OrderBy(id => id).ToArray();
Assert.Equal(["tenant-a", "tenant-b"], tenants);
}
finally
{
TestSessionFactory.Cleanup(dir);
}
}
/// <summary>
/// Store: некорректный tenant-id
/// </summary>
[Fact]
public async Task Store_InvalidTenantId_ThrowsSessionException()
{
string dir = TestSessionFactory.NewSessionsDirectory();
try
{
SessionStore store = TestSessionFactory.Store(dir);
SessionException exception = await Assert.ThrowsAsync<SessionException>(() => store.SaveAsync("../escape", NewStoredSession()));
Assert.Equal(StatusCode.InvalidArgument, exception.Code);
Assert.Equal(SessionErrorMessages.InvalidTenantId, exception.Message);
}
finally
{
TestSessionFactory.Cleanup(dir);
}
}
// Образец StoredSession с настраиваемыми apiId/маркером сессии.
// apiId: api_id.
// marker: Байты сессии.
private static StoredSession NewStoredSession(int? apiId = null, byte[]? marker = null)
=> new()
{
ApiId = apiId ?? ApiId,
ApiHash = ApiHash,
SessionBytes = marker ?? SessionBytes,
};
}