Удалены <remarks>, <summary> сжаты до короткой фразы, вырезаны ссылки на Task/Ruling/этап/python/прототип; //-комментарии со ссылками на процесс удалены; то же в .proto. Правила обновлены в docs/spec/Код-стайл-Дейл.md. Строк комментариев 27210 -> ~19100.
355 lines
12 KiB
C#
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,
|
|
};
|
|
}
|