From 12b692f4a9ea4df0f6e770907c46e4ade59d9165 Mon Sep 17 00:00:00 2001 From: sevenhill Date: Wed, 7 Oct 2026 22:34:40 +0300 Subject: [PATCH] Fix avatar URLs, photo IDs and profile update notifications --- README.md | 12 ++ .../AvatarCompatibilityTests.cs | 98 ++++++++++ .../QlyraCompatibilityController.cs | 37 +++- ikar_server/src/Ikar.Server/Program.cs | 3 +- tool/deploy_server_fixes.py | 25 ++- tool/verify_avatar.dart | 183 ++++++++++++++++++ 6 files changed, 338 insertions(+), 20 deletions(-) create mode 100644 ikar_server/src/Ikar.Server.Tests/AvatarCompatibilityTests.cs create mode 100644 tool/verify_avatar.dart diff --git a/README.md b/README.md index 0c7d85f..f3abeaa 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,18 @@ Сквозное шифрование исходного клиента сохранено в коде как отдельная функция. Обычные сообщения сервер принимает и хранит в читаемом виде; HTTPS обеспечивает шифрование транспорта. Проверки не доказывают работу всех режимов сквозного шифрования и звонков с большим количеством участников. +## Исправление установки аватарки + +Фото загружалось на сервер, но профиль и список фотографий возвращали относительный путь изображения и не передавали `photoId`. Клиент не мог открыть фото и корректно обновить кэш. Теперь эти ответы содержат полный HTTPS-адрес и стабильный ID фотографии; замена меняет URL и ID, удаление очищает оба значения. Push обновления профиля использует ожидаемую клиентом структуру `payload.profile`. + +Ссылка на аватарку содержит токен, ограниченный конкретным изображением. Закрытую фотографию владелец видит через обычный загрузчик изображений без дополнительных заголовков; настройки доступа сервера продолжают действовать. Такой токен не даёт доступа к другим API. + +Рабочий API обновлён до `releases/20261007-avatar`, предыдущий `20261007-komet` сохранён. Перед обновлением создана копия базы и вложений `/mnt/data/ikar_server/backups/ikar-20261007T193300Z.tar.gz`; целостность рабочей базы и копии проверена, соседние контейнеры не перезапускались. Новый APK для этого исправления не требуется. + +Проверки: 152 серверных теста и 23 клиентских теста выбора и просмотра аватарок прошли. Публичная проверка загрузки настоящего JPEG воспроизвела проблему до обновления и прошла после: полный URL, ID, push, скачивание с проверкой SHA-256, галерея, повторная загрузка профиля, приватность, замена и удаление. Проверка выполнялась через API и HTTP-загрузчик; выбор фотографии на физическом телефоне в этой задаче не проверялся. Отчёты: `artifacts/avatar-before.json`, `artifacts/avatar-after.json`, `artifacts/avatar-server-tests.log`, `artifacts/avatar-client-tests.log`, `artifacts/deployment-avatar.json`. Повторно прошли 15 общих проверок API и все 8 сценариев невидимости. + +Развёртывание: `python3 deploy_server_fixes.py `. Архив должен содержать свежий `publish` и файлы контейнера; скрипт проверяет текущий выпуск, создаёт резервную копию, обновляет API и возвращает прежний контейнер при сбое проверки. + ## Доработки из Komet Адаптированы изменения ветки `feature/FullStack` (`58576bc1`) и жесты из [PR №166](https://github.com/KometTeam/Komet/pull/166) (`10a194ed`). Ползунок качества видео получил полноценную область касания; качество и обрезка учитывают края системных жестов Android. Копирование, выделение и редактирование зашифрованных сообщений используют доступный расшифрованный текст. Недоступный текст не подменяется шифротекстом; локальное хранение и отправка сохраняют существующую схему шифрования. diff --git a/ikar_server/src/Ikar.Server.Tests/AvatarCompatibilityTests.cs b/ikar_server/src/Ikar.Server.Tests/AvatarCompatibilityTests.cs new file mode 100644 index 0000000..56a535a --- /dev/null +++ b/ikar_server/src/Ikar.Server.Tests/AvatarCompatibilityTests.cs @@ -0,0 +1,98 @@ +using System.IdentityModel.Tokens.Jwt; +using System.Text.Json; +using Ikar.Server.Controllers; +using Ikar.Server.Data.Entities; +using Ikar.Server.Infrastructure.Storage; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.WebUtilities; + +namespace Ikar.Server.Tests; + +public sealed partial class QlyraCompatibilityControllerTests +{ + [Fact] + public async Task AvatarUpload_ProfileAndGalleryReturnAbsoluteScopedUrlAndStablePhotoId() + { + var directory = Path.Combine(Path.GetTempPath(), $"ikar-avatar-test-{Guid.NewGuid():N}"); + Directory.CreateDirectory(directory); + try + { + await using var database = new SqliteTestDatabase(); + await using var db = await database.CreateContextAsync(); + var user = new User { Username = "avatar", NormalizedUsername = "AVATAR", DisplayName = "Avatar", PasswordHash = "test" }; + db.Users.Add(user); + await db.SaveChangesAsync(); + var uploads = ControllerTestFixture.CreateQlyraUploadStore(directory); + var controller = CreateController(db, user.Id, uploadStore: uploads); + controller.Request.Scheme = "https"; + controller.Request.Host = new HostString("ikar.example"); + + var first = await CommitAvatarAsync(controller, uploads, user.Id); + var contact = first.GetProperty("profile").GetProperty("contact"); + var photoId = contact.GetProperty("photoId").GetInt32(); + Assert.True(photoId > 0); + var uri = new Uri(contact.GetProperty("baseUrl").GetString()!, UriKind.Absolute); + Assert.Equal("https", uri.Scheme); + Assert.Equal("ikar.kusoft.xyz", uri.Host); + Assert.Contains($"/api/users/{user.Id}/avatar/", uri.AbsolutePath); + var token = new JwtSecurityTokenHandler().ReadJwtToken(QueryHelpers.ParseQuery(uri.Query)["access_token"]!); + Assert.Equal(uri.AbsolutePath, token.Claims.Single(claim => claim.Type == "media_path").Value); + Assert.True(File.Exists(user.AvatarStoragePath)); + var firstPath = user.AvatarStoragePath!; + + var gallery = await AvatarPayloadAsync(controller, 39, new { contactId = contact.GetProperty("id").GetInt32() }); + Assert.Equal(photoId, gallery.GetProperty("ids")[0].GetInt32()); + Assert.Equal(uri.AbsolutePath, new Uri(gallery.GetProperty("urls")[0].GetString()!).AbsolutePath); + var reloaded = await AvatarPayloadAsync(controller, 16, new { firstName = "Renamed" }); + Assert.Equal(photoId, reloaded.GetProperty("profile").GetProperty("contact").GetProperty("photoId").GetInt32()); + + var second = (await CommitAvatarAsync(controller, uploads, user.Id)).GetProperty("profile").GetProperty("contact"); + Assert.NotEqual(photoId, second.GetProperty("photoId").GetInt32()); + Assert.NotEqual(uri.AbsolutePath, new Uri(second.GetProperty("baseUrl").GetString()!).AbsolutePath); + Assert.False(File.Exists(firstPath)); + var secondPath = user.AvatarStoragePath!; + var removed = (await AvatarPayloadAsync(controller, 43, new { photoId = second.GetProperty("photoId").GetInt32() })).GetProperty("profile").GetProperty("contact"); + Assert.Equal(JsonValueKind.Null, removed.GetProperty("baseUrl").ValueKind); + Assert.Equal(JsonValueKind.Null, removed.GetProperty("photoId").ValueKind); + Assert.False(File.Exists(secondPath)); + var empty = await AvatarPayloadAsync(controller, 39, new { contactId = contact.GetProperty("id").GetInt32() }); + Assert.Empty(empty.GetProperty("ids").EnumerateArray()); + Assert.Empty(empty.GetProperty("urls").EnumerateArray()); + } + finally { Directory.Delete(directory, true); } + } + + [Fact] + public async Task AvatarLegacyFileName_PhotoIdStaysStableAcrossRequests() + { + await using var database = new SqliteTestDatabase(); + await using var db = await database.CreateContextAsync(); + var user = new User { Username = "legacy-avatar", NormalizedUsername = "LEGACY-AVATAR", DisplayName = "Legacy", PasswordHash = "test", AvatarStoragePath = "/legacy/profile-photo.jpg" }; + db.Users.Add(user); + await db.SaveChangesAsync(); + var controller = CreateController(db, user.Id); + controller.Request.Scheme = "https"; + controller.Request.Host = new HostString("ikar.example"); + var first = (await AvatarPayloadAsync(controller, 16, new { firstName = "First" })).GetProperty("profile").GetProperty("contact"); + var second = (await AvatarPayloadAsync(controller, 16, new { firstName = "Second" })).GetProperty("profile").GetProperty("contact"); + Assert.Equal(first.GetProperty("photoId").GetInt32(), second.GetProperty("photoId").GetInt32()); + Assert.Equal("profile-photo.jpg", new Uri(first.GetProperty("baseUrl").GetString()!).Segments.Last()); + } + + private static async Task CommitAvatarAsync(QlyraCompatibilityController controller, QlyraUploadStore uploads, Guid userId) + { + var reservation = uploads.Reserve(userId, "photo"); + byte[] bytes = [255, 216, 255, 217]; + await using var stream = new MemoryStream(bytes); + Assert.NotNull(await uploads.SaveAsync(reservation.Token, stream, "photo.jpg", "image/jpeg", bytes.Length, CancellationToken.None)); + return await AvatarPayloadAsync(controller, 16, new { photoToken = reservation.Token, avatarType = "USER_AVATAR" }); + } + + private static async Task AvatarPayloadAsync(QlyraCompatibilityController controller, int opcode, object payload) + { + var response = Assert.IsType(await controller.Dispatch(opcode, + new(JsonSerializer.SerializeToElement(payload)), CancellationToken.None)); + return JsonSerializer.SerializeToElement(response.Value, new JsonSerializerOptions(JsonSerializerDefaults.Web)).GetProperty("payload"); + } +} diff --git a/ikar_server/src/Ikar.Server/Controllers/QlyraCompatibilityController.cs b/ikar_server/src/Ikar.Server/Controllers/QlyraCompatibilityController.cs index 68da02d..09b5a2a 100644 --- a/ikar_server/src/Ikar.Server/Controllers/QlyraCompatibilityController.cs +++ b/ikar_server/src/Ikar.Server/Controllers/QlyraCompatibilityController.cs @@ -1358,7 +1358,7 @@ VideoPreviewService? videoPreviewService = null) : ControllerBase } await dbContext.SaveChangesAsync(cancellationToken); var profile = await BuildProfileAsync(user, cancellationToken); - await PublishPacketAsync([user.Id], 159, profile, cancellationToken); + await PublishPacketAsync([user.Id], 159, new { profile }, cancellationToken); return Ok(QlyraCompatibilityResponse.Ok(opcode, new { profile })); } @@ -1619,10 +1619,11 @@ VideoPreviewService? videoPreviewService = null) : ControllerBase } var targetId = await clientIdMapper.FindEntityIdAsync("user", ReadInt(payload, "contactId") ?? 0, cancellationToken); var user = targetId is null ? null : await dbContext.Users.SingleOrDefaultAsync(item => item.Id == targetId.Value, cancellationToken); - var urls = user is null || string.IsNullOrWhiteSpace(user.AvatarStoragePath) - ? Array.Empty() - : new[] { $"/api/users/{user.Id}/avatar/{Uri.EscapeDataString(Path.GetFileName(user.AvatarStoragePath))}" }; - return Ok(QlyraCompatibilityResponse.Ok(opcode, new { urls, total = urls.Length })); + var url = user is null ? null : BuildAvatarUrl(user); + var photoId = user is null ? null : await BuildAvatarPhotoIdAsync(user, cancellationToken); + var urls = url is null ? Array.Empty() : new[] { url }; + var ids = photoId is null ? Array.Empty() : new[] { photoId.Value }; + return Ok(QlyraCompatibilityResponse.Ok(opcode, new { urls, ids, total = urls.Length })); } private async Task RemoveProfilePhotoAsync(int opcode, CancellationToken cancellationToken) @@ -2750,7 +2751,8 @@ VideoPreviewService? videoPreviewService = null) : ControllerBase options = user.IsBot ? (string.IsNullOrWhiteSpace(botWebApp) ? new[] { "BOT" } : new[] { "BOT", "HAS_WEBAPP" }) : Array.Empty(), updateTime = user.CreatedAt.ToUnixTimeMilliseconds(), names = nameEntries, - baseUrl = string.IsNullOrWhiteSpace(user.AvatarStoragePath) ? null : $"/api/users/{user.Id}/avatar/{Uri.EscapeDataString(Path.GetFileName(user.AvatarStoragePath))}" + photoId = await BuildAvatarPhotoIdAsync(user, cancellationToken), + baseUrl = BuildAvatarUrl(user) }; } @@ -3016,7 +3018,9 @@ VideoPreviewService? videoPreviewService = null) : ControllerBase private string BuildPublicUrl(string path) { var url = new Uri(GetPublicBaseUri(), path).ToString(); - if (path.Contains("/attachments/", StringComparison.Ordinal) && User.Identity?.IsAuthenticated == true) + if ((path.Contains("/attachments/", StringComparison.Ordinal) || + path.StartsWith("/api/users/", StringComparison.Ordinal) && path.Contains("/avatar/", StringComparison.Ordinal)) && + User.Identity?.IsAuthenticated == true) { var token = tokenService.CreateMediaToken(User.GetRequiredUserId(), User.GetRequiredSessionId(), path); url += "?access_token=" + Uri.EscapeDataString(token); @@ -3024,6 +3028,20 @@ VideoPreviewService? videoPreviewService = null) : ControllerBase return url; } + private string? BuildAvatarUrl(User user) => string.IsNullOrWhiteSpace(user.AvatarStoragePath) + ? null + : BuildPublicUrl($"/api/users/{user.Id}/avatar/{Uri.EscapeDataString(Path.GetFileName(user.AvatarStoragePath))}"); + + private async Task BuildAvatarPhotoIdAsync(User user, CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(user.AvatarStoragePath)) return null; + var fileName = Path.GetFileName(user.AvatarStoragePath); + var photoGuid = Guid.TryParseExact(Path.GetFileNameWithoutExtension(fileName), "N", out var parsed) + ? parsed + : new Guid(SHA256.HashData(Encoding.UTF8.GetBytes($"{user.Id:N}:{fileName}")).AsSpan(0, 16)); + return await clientIdMapper.GetOrCreateAsync("avatar", photoGuid, cancellationToken); + } + private async Task BuildProfileAsync(User user, CancellationToken cancellationToken) { var userId = await clientIdMapper.GetOrCreateAsync("user", user.Id, cancellationToken); @@ -3038,9 +3056,8 @@ VideoPreviewService? videoPreviewService = null) : ControllerBase accountStatus = user.IsBlocked ? 1 : 0, updateTime = user.CreatedAt.ToUnixTimeMilliseconds(), names = new[] { new { type = "ONEME", firstName = names.FirstName, lastName = names.LastName } }, - baseUrl = string.IsNullOrWhiteSpace(user.AvatarStoragePath) - ? null - : $"/api/users/{user.Id}/avatar/{Uri.EscapeDataString(Path.GetFileName(user.AvatarStoragePath))}", + photoId = await BuildAvatarPhotoIdAsync(user, cancellationToken), + baseUrl = BuildAvatarUrl(user), about = user.About, online = presenceTracker.IsOnline(user.Id) }, diff --git a/ikar_server/src/Ikar.Server/Program.cs b/ikar_server/src/Ikar.Server/Program.cs index 509e5fa..79bd7cd 100644 --- a/ikar_server/src/Ikar.Server/Program.cs +++ b/ikar_server/src/Ikar.Server/Program.cs @@ -90,7 +90,8 @@ builder.Services var path = context.HttpContext.Request.Path; if (!string.IsNullOrWhiteSpace(accessToken) && (path.StartsWithSegments("/hubs/messenger") || - (path.StartsWithSegments("/api/chats") || path.StartsWithSegments("/api/stories")) && path.Value!.Contains("/attachments/"))) + (path.StartsWithSegments("/api/chats") || path.StartsWithSegments("/api/stories")) && path.Value!.Contains("/attachments/") || + path.StartsWithSegments("/api/users") && path.Value!.Contains("/avatar/"))) { context.Token = accessToken; } diff --git a/tool/deploy_server_fixes.py b/tool/deploy_server_fixes.py index fcbb4e2..a78be33 100644 --- a/tool/deploy_server_fixes.py +++ b/tool/deploy_server_fixes.py @@ -3,6 +3,7 @@ from pathlib import Path import hashlib import json import os +import re import shutil import sqlite3 import subprocess @@ -13,11 +14,17 @@ import time import urllib.request root = Path('/mnt/data/ikar_server') -release = root / 'releases/20261007-komet' +release_name = sys.argv[2] if len(sys.argv) > 2 else '20261007-komet' +previous_name = sys.argv[3] if len(sys.argv) > 3 else '20261007-video' +archive_name = sys.argv[4] if len(sys.argv) > 4 else 'server-fixes.tar.gz' +if not all(re.fullmatch(r'[a-zA-Z0-9_.-]+', name) and name not in {'.', '..'} + for name in (release_name, previous_name, archive_name)): + raise ValueError('Invalid release or archive name') +release = root / 'releases' / release_name current = root / 'current' previous = current.resolve(strict=True) expected_sha = sys.argv[1].lower() -if not os.path.ismount('/mnt/data') or previous != root / 'releases/20261007-video' or release.exists(): +if not os.path.ismount('/mnt/data') or previous != root / 'releases' / previous_name or release.exists(): raise RuntimeError('Unexpected release or external disk state') def snapshot(): @@ -38,20 +45,20 @@ with tempfile.TemporaryDirectory() as temp: raise RuntimeError('Backup failed integrity verification') shutil.copytree(previous, release, ignore=shutil.ignore_patterns('publish', 'release.tar.gz')) -with tarfile.open(root / 'server-fixes.tar.gz') as archive: +with tarfile.open(root / archive_name) as archive: archive.extractall(release, filter='data') actual_sha = hashlib.sha256((release / 'publish/Ikar.Server.dll').read_bytes()).hexdigest() if actual_sha != expected_sha: raise RuntimeError('Uploaded server differs from verified local build') compose = release / 'compose.yaml' text = compose.read_text() -old_image = 'image: ikar_server:20261007-video' +old_image = f'image: ikar_server:{previous_name}' if text.count(old_image) != 1: raise RuntimeError('Unexpected API image in compose') -compose.write_text(text.replace(old_image, 'image: ikar_server:20261007-komet', 1)) +compose.write_text(text.replace(old_image, f'image: ikar_server:{release_name}', 1)) print('Building IKAR API image', flush=True) -with (root / 'build-komet.log').open('w') as log: - subprocess.run(['docker', 'build', '-f', 'Dockerfile.release', '-t', 'ikar_server:20261007-komet', '.'], +with (root / f'build-{release_name}.log').open('w') as log: + subprocess.run(['docker', 'build', '-f', 'Dockerfile.release', '-t', f'ikar_server:{release_name}', '.'], cwd=release, stdout=log, stderr=subprocess.STDOUT, check=True) def update(path): @@ -82,7 +89,7 @@ try: if integrity != 'ok' or 'PublicLastSeenUnixMs' not in columns: raise RuntimeError('Database or presence schema verification failed') subprocess.run(['docker', 'exec', 'ikar_server', 'ffmpeg', '-version'], stdout=subprocess.DEVNULL, check=True) - pending = root / 'current.next-komet' + pending = root / f'current.next-{release_name}' pending.symlink_to(release, target_is_directory=True) os.replace(pending, current) except Exception: @@ -98,5 +105,5 @@ report = { 'protectedServicesUnchanged': snapshot() == protected_before, 'protectedServices': protected_before, } -(root / 'deployment-komet.json').write_text(json.dumps(report, indent=2)) +(root / f'deployment-{release_name}.json').write_text(json.dumps(report, indent=2)) print(json.dumps(report), flush=True) diff --git a/tool/verify_avatar.dart b/tool/verify_avatar.dart new file mode 100644 index 0000000..f38a950 --- /dev/null +++ b/tool/verify_avatar.dart @@ -0,0 +1,183 @@ +import 'dart:convert'; +import 'dart:io'; +import 'package:crypto/crypto.dart'; +import 'package:image/image.dart' as img; +import 'package:ikar/core/transport/ikar_network.dart'; +import 'live_ikar_smoke.dart' as live; + +Future main(List args) async { + final label = args.isEmpty ? 'after' : args.first; + final base = Uri.parse('https://ikar.kusoft.xyz'); + final client = IkarNetwork.client(); + final checks = []; + live.Device? owner; + final report = {'passed': false, 'checks': checks}; + try { + final stamp = DateTime.now().microsecondsSinceEpoch; + owner = await live.Device.create( + base, + 'node', + '+999$stamp', + 'Avatar verification', + ); + final input = img.decodePng( + await File('assets/ikar_icon.png').readAsBytes(), + )!; + final jpeg = img.encodeJpg(img.copyResize(input, width: 128, height: 128)); + + Future upload(List bytes) async { + final slot = await owner!.ok(80, {'count': 1, 'profile': true}); + final request = await client.postUrl(Uri.parse(slot['url'])); + request.headers.contentType = ContentType('image', 'jpeg'); + request.contentLength = bytes.length; + request.add(bytes); + final response = await request.close(); + live.check(response.statusCode == 200, 'Avatar upload failed'); + final token = jsonDecode(await utf8.decodeStream(response))['photoToken']; + final pushed = owner.transport.events + .firstWhere((event) => event['opcode'] == 159) + .timeout(const Duration(seconds: 10)); + final result = await owner.ok(16, { + 'photoToken': token, + 'avatarType': 'USER_AVATAR', + }); + final push = await pushed; + report['profilePushWrapped'] = push['payload']?['profile'] is Map; + return result['profile']['contact'] as Map; + } + + final first = await upload(jpeg); + final url = Uri.parse(first['baseUrl'] as String); + report['absoluteAvatarUrl'] = url.isAbsolute; + report['photoIdReturned'] = first['photoId'] is int; + live.check( + url.isAbsolute && url.scheme == 'https', + 'Server returned a relative avatar URL', + ); + live.check(first['photoId'] is int, 'Server omitted avatar photo ID'); + live.check( + report['profilePushWrapped'] == true, + 'Profile push has incompatible structure', + ); + checks.add( + 'upload commits absolute HTTPS URL, photo ID and profile update push', + ); + final image = await (await client.getUrl(url)).close(); + final fetched = await image.fold>( + [], + (bytes, chunk) => bytes..addAll(chunk), + ); + live.check( + image.statusCode == 200 && + sha256.convert(fetched) == sha256.convert(jpeg), + 'Avatar download differs from uploaded photo', + ); + checks.add('avatar image renders through a normal HTTP image loader'); + final history = await owner.ok(39, {'contactId': owner.id}); + live.check( + history['ids'][0] == first['photoId'] && + Uri.parse(history['urls'][0]).isAbsolute, + 'Avatar gallery differs from profile', + ); + final login = await owner.ok(19, {}); + live.check( + login['profile']['contact']['photoId'] == first['photoId'], + 'Avatar did not survive profile reload', + ); + checks.add('avatar gallery and profile reload preserve current photo ID'); + final privacyRequest = await client.putUrl( + base.resolve('/api/users/me/privacy'), + ); + privacyRequest.headers.set( + 'Authorization', + 'Bearer ${owner.transport.token}', + ); + privacyRequest.headers.contentType = ContentType.json; + privacyRequest.write( + jsonEncode({ + 'phoneNumberVisibility': 'nobody', + 'lastSeenVisibility': 'nobody', + 'profilePhotoVisibility': 'nobody', + }), + ); + final privacyResponse = await privacyRequest.close(); + await privacyResponse.drain(); + live.check( + privacyResponse.statusCode == 200, + 'Could not restrict avatar privacy', + ); + final privateImage = await (await client.getUrl(url)).close(); + await privateImage.drain(); + live.check( + privateImage.statusCode == 200, + 'Owner cannot display private avatar', + ); + final anonymousImage = await (await client.getUrl( + url.replace(query: ''), + )).close(); + await anonymousImage.drain(); + live.check( + anonymousImage.statusCode == 404, + 'Private avatar exposed anonymously', + ); + final scopedRequest = await client.getUrl(base.resolve('/api/users/me')); + scopedRequest.headers.set( + 'Authorization', + 'Bearer ${url.queryParameters['access_token']}', + ); + final scopedResponse = await scopedRequest.close(); + await scopedResponse.drain(); + live.check( + scopedResponse.statusCode == 401, + 'Avatar token granted broader API access', + ); + checks.add( + 'private avatar renders for owner, stays hidden anonymously, and token is resource scoped', + ); + final nextJpeg = img.encodeJpg(img.copyRotate(input, angle: 90)); + final replacement = await upload(nextJpeg); + live.check( + replacement['photoId'] != first['photoId'] && + replacement['baseUrl'] != first['baseUrl'], + 'Replacing avatar reused the old image cache key', + ); + final oldImage = await (await client.getUrl(url)).close(); + await oldImage.drain(); + live.check( + oldImage.statusCode == 404, + 'Replaced avatar is still available', + ); + checks.add( + 'replacement changes photo ID and URL and removes previous image', + ); + final removed = await owner.ok(43, {'photoId': replacement['photoId']}); + live.check( + removed['profile']['contact']['baseUrl'] == null && + removed['profile']['contact']['photoId'] == null, + 'Avatar deletion failed', + ); + live.check( + (await owner.ok(39, {'contactId': owner.id}))['urls'].isEmpty, + 'Deleted avatar remains in gallery', + ); + checks.add('avatar deletion clears profile and gallery'); + report['passed'] = true; + } catch (error) { + report['error'] = error.toString(); + rethrow; + } finally { + report['timestamp'] = DateTime.now().toUtc().toIso8601String(); + await File( + 'artifacts/avatar-$label.json', + ).writeAsString(const JsonEncoder.withIndent(' ').convert(report)); + stdout.writeln(jsonEncode(report)); + if (owner != null) { + try { + await owner.ok(199, {}); + } finally { + await owner.close(); + } + } + client.close(force: true); + } +}