Files
allstarr/allstarr.Tests/Extensions/ExtensionCapabilityAdapterTests.cs
T

1177 lines
64 KiB
C#

using allstarr.Core.Capabilities;
using allstarr.Core.Extensions;
using allstarr.Core.Downloads;
using allstarr.Core.Storage;
using allstarr.Services.Common;
using Microsoft.Extensions.Logging.Abstractions;
using System.Net;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using Microsoft.AspNetCore.DataProtection;
namespace allstarr.Tests;
public sealed class ExtensionCapabilityAdapterTests
{
[Fact]
public void DownloadOnlyManifest_GainsHostBackedStreamingWithoutReinstallation()
{
const string stored = """
{"id":"download-demo","displayName":"Demo","version":"1.0.0","sdkVersion":"1","entryPoint":"index.js",
"capabilities":[{"kind":"Download","hooks":["checkAvailability","download"],"accountScopes":["User"],"accountRequired":true}],
"permissions":[]}
""";
var storedManifest = ExtensionSdkV1.ParseManifest(stored);
var playableManifest = ExtensionSdkV1.ParseManifest(
SpotiFlacExtensionCompatibility.EnsureDownloadStreamingCapability(stored));
Assert.True(SpotiFlacExtensionCompatibility.UsesDownloadBackedStreaming(storedManifest));
var streaming = Assert.Single(playableManifest.Capabilities,
item => item.Kind == ProviderCapabilityKind.Streaming);
Assert.True(streaming.AccountRequired);
Assert.Contains(ProviderAccountScope.User, streaming.AccountScopes);
}
[Fact]
public void ExplicitStreamingManifest_KeepsItsOwnStreamingAdapter()
{
const string stored = """
{"id":"stream-demo","displayName":"Demo","version":"1.0.0","sdkVersion":"1","entryPoint":"index.js",
"capabilities":[
{"kind":"Download","hooks":["checkAvailability","download"],"accountScopes":[],"accountRequired":false},
{"kind":"Streaming","hooks":["getStreamLease","probeStream"],"accountScopes":[],"accountRequired":false}],
"permissions":[]}
""";
var manifest = ExtensionSdkV1.ParseManifest(stored);
Assert.False(SpotiFlacExtensionCompatibility.UsesDownloadBackedStreaming(manifest));
Assert.Equal(stored, SpotiFlacExtensionCompatibility.EnsureDownloadStreamingCapability(stored));
}
[Fact]
public void RepeatedExtensionRuntimeErrors_AreSanitizedAndDeduplicated()
{
const string manifest = """
{"id":"logging-demo","displayName":"Logging demo","version":"1.0.0","sdkVersion":"1","entryPoint":"index.js",
"capabilities":[{"kind":"Metadata","hooks":["searchTracks"],"accountScopes":[],"accountRequired":false}],
"permissions":[]}
""";
const string script = """
registerExtension({
searchTracks:function(){
log.error('extension returned <redacted>');
return {items:[]};
}
});
""";
var events = new List<(string Level, string Message)>();
var logger = new CapturingLogger();
var permissions = new ExtensionRuntimePermissionSet(
new HashSet<string>(),
new HashSet<string>(),
new HashSet<string>(),
LogSink: (level, message) => events.Add((level, message)));
var firstSandbox = new ExtensionSandbox(
Path.GetTempPath(),
manifest,
script,
new HttpClientFactory(),
logger,
permissions);
var recreatedSandbox = new ExtensionSandbox(
Path.GetTempPath(),
manifest,
script,
new HttpClientFactory(),
logger,
permissions);
firstSandbox.InvokeJson("searchTracks", "{}");
recreatedSandbox.InvokeJson("searchTracks", "{}");
recreatedSandbox.InvokeJson("searchTracks", "{}");
var runtimeEvent = Assert.Single(events);
Assert.Equal("error", runtimeEvent.Level);
Assert.Equal(
"Provider operation failed without a safe diagnostic.",
runtimeEvent.Message);
Assert.DoesNotContain("redacted", runtimeEvent.Message, StringComparison.OrdinalIgnoreCase);
Assert.Equal(
"Provider operation failed without a safe diagnostic.",
logger.LastState!["Diagnostic"]);
Assert.DoesNotContain("Message", logger.LastState.Keys);
}
[Fact]
public void RepeatedExtensionHttpFailures_OpenCooldownAndSuppressNetworkCalls()
{
const string manifest = """
{"id":"failure-demo","displayName":"Failure demo","version":"1.0.0","sdkVersion":"1","entryPoint":"index.js",
"capabilities":[{"kind":"Metadata","hooks":["searchTracks"],"accountScopes":[],"accountRequired":false}],
"permissions":[{"kind":"Network","value":"https://api.example.test/","required":true}]}
""";
const string script = """
registerExtension({
searchTracks:function(){
var response = http.get('https://api.example.test/search', {});
if (response.statusCode === 503) log.error('provider bad response 503');
return {items:[], statusCode:response.statusCode, error:response.error || null};
}
});
""";
var handler = new StatusHandler(HttpStatusCode.Forbidden);
var events = new List<(string Level, string Message)>();
var permissions = new ExtensionRuntimePermissionSet(
new HashSet<string>(["https://api.example.test/"]),
new HashSet<string>(),
new HashSet<string>(),
LogSink: (level, message) => events.Add((level, message)));
var sandbox = new ExtensionSandbox(
Path.GetTempPath(),
manifest,
script,
new HttpClientFactory(handler),
NullLogger.Instance,
permissions);
var first = sandbox.InvokeJson("searchTracks", "{}");
var second = sandbox.InvokeJson("searchTracks", "{}");
var suppressed = sandbox.InvokeJson("searchTracks", "{}");
Assert.Contains("\"statusCode\":403", first, StringComparison.Ordinal);
Assert.Contains("\"statusCode\":403", second, StringComparison.Ordinal);
Assert.Contains("\"statusCode\":503", suppressed, StringComparison.Ordinal);
Assert.Contains("provider_temporarily_unavailable", suppressed, StringComparison.Ordinal);
Assert.Equal(2, handler.CallCount);
Assert.Empty(events);
}
[Fact]
public void SuccessfulExtensionHttpCall_ResetsConsecutiveFailureCount()
{
const string manifest = """
{"id":"recovery-demo","displayName":"Recovery demo","version":"1.0.0","sdkVersion":"1","entryPoint":"index.js",
"capabilities":[{"kind":"Metadata","hooks":["searchTracks"],"accountScopes":[],"accountRequired":false}],
"permissions":[{"kind":"Network","value":"https://api.example.test/","required":true}]}
""";
const string script = """
registerExtension({
searchTracks:function(){
var response = http.get('https://api.example.test/search', {});
return {items:[], statusCode:response.statusCode, error:response.error || null};
}
});
""";
var handler = new StatusHandler(
HttpStatusCode.Forbidden,
HttpStatusCode.OK,
HttpStatusCode.Forbidden,
HttpStatusCode.Forbidden);
var permissions = new ExtensionRuntimePermissionSet(
new HashSet<string>(["https://api.example.test/"]),
new HashSet<string>(),
new HashSet<string>());
var sandbox = new ExtensionSandbox(
Path.GetTempPath(),
manifest,
script,
new HttpClientFactory(handler),
NullLogger.Instance,
permissions);
sandbox.InvokeJson("searchTracks", "{}");
sandbox.InvokeJson("searchTracks", "{}");
sandbox.InvokeJson("searchTracks", "{}");
sandbox.InvokeJson("searchTracks", "{}");
var suppressed = sandbox.InvokeJson("searchTracks", "{}");
Assert.Contains("provider_temporarily_unavailable", suppressed, StringComparison.Ordinal);
Assert.Equal(4, handler.CallCount);
}
[Fact]
public void SignedSessionRuntime_BootstrapsExchangesAndSignsRequests()
{
var root = Path.Combine(Path.GetTempPath(), "allstarr-signed-session", Guid.NewGuid().ToString("N"));
try
{
const string manifest = """
{"id":"signed-demo","displayName":"Signed demo","version":"1.0.0","sdkVersion":"1","entryPoint":"index.js",
"capabilities":[{"kind":"Metadata","hooks":["searchTracks"],"accountScopes":[],"accountRequired":false}],
"permissions":[{"kind":"Network","value":"https://api.example.test/","required":true}],
"requiredRuntimeFeatures":["signedSession@1","sessionGrant@1"],
"signedSession":{"namespace":"demo-v1","baseUrl":"https://api.example.test/v1","appVersion":"demo@1.0.0"}}
""";
const string script = """
registerExtension({
searchTracks:function(){return session.signedFetch('GET','/catalog',null,{});},
authorize:function(){return session.signedFetch('GET','/catalog',null,{});}
});
""";
var handler = new SignedSessionHandler();
var permissions = new ExtensionRuntimePermissionSet(
new HashSet<string>(["https://api.example.test/"]), new HashSet<string>(), new HashSet<string>());
var sandbox = new ExtensionSandbox(root, manifest, script, new HttpClientFactory(handler), NullLogger.Instance,
permissions, Path.Combine(root, "runtime"), new EphemeralDataProtectionProvider().CreateProtector("test"));
var verification = sandbox.InvokeJson("authorize", "{}");
Assert.True(verification?.Contains("VERIFY_REQUIRED", StringComparison.Ordinal) == true, verification);
Assert.Contains("challenge-1", verification, StringComparison.Ordinal);
var exchange = JsonSerializer.Serialize(sandbox.CompleteSignedSessionGrant(
"spotiflac://session-grant/?cb_version=v2grant&state=signed-demo&grant=grant-1"));
Assert.Contains("true", exchange, StringComparison.OrdinalIgnoreCase);
Assert.Equal("grant-1", handler.ExchangeGrant);
var response = sandbox.InvokeJson("searchTracks", "{}");
Assert.Contains("catalog ok", response, StringComparison.Ordinal);
Assert.NotNull(handler.SignedRequest);
Assert.Equal("session-1", handler.SignedRequest!.Headers.GetValues("X-Sig-Session").Single());
Assert.True(handler.SignedRequest.Headers.Contains("X-Sig-Signature"));
}
finally
{
if (Directory.Exists(root)) Directory.Delete(root, true);
}
}
[Fact]
public void SpotiFlacRuntimeAdapter_MapsLegacySearchAndArtwork()
{
const string sourceManifest = """
{"name":"demo","displayName":"Demo","version":"1.0.0","description":"Fixture",
"type":["metadata_provider"],"permissions":{"storage":true}}
""";
const string script = """
registerExtension({customSearch:function(query){return query === 'Song'
? [{id:'track-1',name:'Song',artists:'Artist',artist_id:'artist-1',album_id:'album-1',album_name:'Album',cover_url:'https://images.example.test/cover.jpg',item_type:'track'}]
: [{id:'wrong-track',name:'Wrong song',artists:'Wrong artist',item_type:'track'}];},getPlaylist:function(){return {tracks:[]};}});
""";
var manifest = SpotiFlacExtensionCompatibility.NormalizeManifest(sourceManifest, script);
Assert.DoesNotContain(ExtensionSdkV1.ParseManifest(manifest).Capabilities,
capability => capability.Kind == ProviderCapabilityKind.Playlist);
var permissions = new ExtensionRuntimePermissionSet(new HashSet<string>(), new HashSet<string>(["*"]), new HashSet<string>());
var sandbox = new ExtensionSandbox(Path.GetTempPath(), manifest, script,
new HttpClientFactory(), NullLogger.Instance, permissions);
var json = sandbox.InvokeJson("searchTracks", "{\"query\":\"Song\",\"page\":{\"limit\":10}}");
Assert.NotNull(json);
Assert.Contains("\"title\":\"Song\"", json, StringComparison.Ordinal);
Assert.Contains("\"id\":\"artist-1\"", json, StringComparison.Ordinal);
Assert.Contains("\"albumId\":\"album-1\"", json, StringComparison.Ordinal);
Assert.Contains("https://images.example.test/cover.jpg", json, StringComparison.Ordinal);
var detail = sandbox.InvokeJson("getTrack", "{\"id\":\"track-1\"}");
Assert.Contains("\"id\":\"track-1\"", detail, StringComparison.Ordinal);
Assert.Contains("\"title\":\"Song\"", detail, StringComparison.Ordinal);
Assert.DoesNotContain("wrong-track", detail, StringComparison.Ordinal);
}
[Fact]
public async Task Metadata_GetTrackRejectsAProviderResponseForAnotherIdentity()
{
var manifest = Manifest(ProviderCapabilityKind.Metadata, ["getTrack"]);
var sandbox = Sandbox(manifest, """
registerExtension({getTrack:function(){return {
id:'other-track',title:'Wrong track',artists:[{name:'Wrong artist'}]
};}});
""");
var adapter = new ExtensionMetadataCapabilityAdapter(sandbox, manifest);
var requested = new ProviderExternalResourceId(manifest.Id, ProviderResourceKind.Track, "requested-track");
var outcome = await adapter.GetTrackAsync(Context(manifest.Id), new ProviderTrackLookupRequest(requested));
Assert.False(outcome.IsSuccess);
Assert.Equal(ProviderErrorKind.NotFound, outcome.Error!.Kind);
}
[Fact]
public void SpotiFlacRuntimeAdapter_CanInspectLargeTextResponses()
{
const string prefix = "eyJ0eXAiOiJKV1QiLCJhbGciOiJFUzI1NiIsImtpZCI6IldlYlBsYXlLaWQifQ.";
const string sourceManifest = """
{"name":"large-response","displayName":"Large response","version":"1.0.0","description":"Fixture",
"type":["metadata_provider"],"permissions":{"network":["music.apple.com"]}}
""";
const string script = """
registerExtension({customSearch:function(){
var response=http.get('https://music.apple.com/assets/index.js',{});
var index=response.body.indexOf('eyJ0eXAiOiJKV1QiLCJhbGciOiJFUzI1NiIsImtpZCI6IldlYlBsYXlLaWQifQ.');
return [{id:'track-1',name:String(index),artists:'Artist',item_type:'track'}];
}});
""";
const int markerOffset = 3_900_000;
const int responseBytes = 4 * 1024 * 1024 - 1;
var body = new string('x', markerOffset) + prefix + new string('y', responseBytes - markerOffset - prefix.Length);
var manifest = SpotiFlacExtensionCompatibility.NormalizeManifest(sourceManifest, script);
var permissions = new ExtensionRuntimePermissionSet(
new HashSet<string>(["https://music.apple.com/"], StringComparer.Ordinal),
new HashSet<string>(), new HashSet<string>());
var sandbox = new ExtensionSandbox(Path.GetTempPath(), manifest, script,
new HttpClientFactory(new BytesHandler(Encoding.UTF8.GetBytes(body))), NullLogger.Instance, permissions);
var json = sandbox.InvokeJson("searchTracks", "{\"query\":\"Song\",\"page\":{\"limit\":1}}");
Assert.Contains($"\"title\":\"{markerOffset}\"", json, StringComparison.Ordinal);
}
[Fact]
public void SpotiFlacRuntimeAdapter_ForwardsHttpHeaders()
{
const string sourceManifest = """
{"name":"headers","displayName":"Headers","version":"1.0.0","description":"Fixture",
"type":["metadata_provider"],"permissions":{"network":["api.example.test"]}}
""";
const string script = """
registerExtension({customSearch:function(){
http.get('https://api.example.test/items',{Authorization:'Bearer expected',Origin:'https://example.test','Media-User-Token':'expected-user-token'});
return [];
}});
""";
var handler = new HeaderHandler();
var manifest = SpotiFlacExtensionCompatibility.NormalizeManifest(sourceManifest, script);
var permissions = new ExtensionRuntimePermissionSet(
new HashSet<string>(["https://api.example.test/"], StringComparer.Ordinal),
new HashSet<string>(), new HashSet<string>());
var sandbox = new ExtensionSandbox(Path.GetTempPath(), manifest, script,
new HttpClientFactory(handler), NullLogger.Instance, permissions);
sandbox.InvokeJson("searchTracks", "{\"query\":\"Song\",\"page\":{\"limit\":1}}");
Assert.Equal("Bearer expected", handler.Authorization);
Assert.Equal("https://example.test", handler.Origin);
Assert.Equal("expected-user-token", handler.MediaUserToken);
}
[Fact]
public async Task SpotiFlacRuntimeAdapter_UsesKnownTrackMetadataForTimedLyrics()
{
const string sourceManifest = """
{"name":"lyrics-demo","displayName":"Lyrics demo","version":"1.0.0","description":"Fixture",
"type":["lyrics_provider"],"permissions":{}}
""";
const string script = """
registerExtension({
getTrack:function(){throw new Error('metadata lookup must not run');},
fetchLyrics:function(title,artist,album,duration){
if(title!=='Known title'||artist!=='Known artist'||album!=='Known album'||duration!==240) throw new Error('wrong metadata');
return {provider:'fixture',plainLyrics:'line one\nline two',lines:[
{startTimeMs:1250,words:'line one'},
{startTimeMs:62500,words:'line two'},
{startTimeMs:999999999,words:''}
]};
}
});
""";
var normalized = SpotiFlacExtensionCompatibility.NormalizeManifest(sourceManifest, script);
var manifest = ExtensionSdkV1.ParseManifest(normalized);
var sandbox = new ExtensionSandbox(Path.GetTempPath(), normalized, script,
new HttpClientFactory(), NullLogger.Instance, ExtensionRuntimePermissionSet.None);
var adapter = new ExtensionLyricsCapabilityAdapter(sandbox, manifest);
var outcome = await adapter.FetchLyricsAsync(Context("spotiflac-lyrics-demo"), new ProviderLyricsRequest(
Guid.CreateVersion7(),
new ProviderExternalResourceId("spotiflac-lyrics-demo", ProviderResourceKind.Track, "catalog-id"),
preferredFormat: ProviderLyricsFormat.LineTimed,
trackTitle: "Known title",
artistNames: ["Known artist"],
albumTitle: "Known album",
durationSeconds: 240));
Assert.True(outcome.IsSuccess, outcome.Error?.Kind.ToString());
Assert.Equal(ProviderLyricsFormat.LineTimed, outcome.Value!.Format);
Assert.Equal("[00:01.25]line one\n[01:02.50]line two", outcome.Value.Content);
}
[Fact]
public async Task SpotiFlacRuntimeAdapter_BrokersDirectDownloadsIntoManagedWorkspace()
{
var root = Path.Combine(Path.GetTempPath(), "allstarr-spotiflac-download", Guid.NewGuid().ToString("N"));
try
{
const string sourceManifest = """
{"name":"demo","displayName":"Demo","version":"1.0.0","description":"Fixture",
"type":["metadata_provider","download_provider"],"permissions":{"network":["media.example.test"]}}
""";
const string script = """
registerExtension({customSearch:function(){return [];},checkAvailability:function(){return {available:true};},
download:function(id, quality, path){return file.download('https://media.example.test/audio', path, {});}});
""";
var normalized = SpotiFlacExtensionCompatibility.NormalizeManifest(sourceManifest, script);
var manifest = ExtensionSdkV1.ParseManifest(normalized);
var permissions = new ExtensionRuntimePermissionSet(new HashSet<string>(["https://media.example.test/"]), new HashSet<string>(), new HashSet<string>());
var sandbox = new ExtensionSandbox(root, normalized, script,
new HttpClientFactory(new BytesHandler([1, 2, 3, 4])), NullLogger.Instance, permissions, Path.Combine(root, "runtime"));
var store = new MemoryArtifactStore();
var options = new ProviderDownloadWorkspaceOptions { RootPath = Path.Combine(root, "workspaces"), MaximumArtifactBytes = 1024 };
var resolver = new ProviderDownloadArtifactResolver(store, options);
var adapter = new ExtensionDownloadCapabilityAdapter(sandbox, manifest, artifacts: resolver, options: options);
var context = Context("spotiflac-demo");
var job = Guid.CreateVersion7();
var workspace = await resolver.CreateWorkspaceAsync(new(
context.Actor.TenantId, context.Actor.EffectiveUserId, job, "spotiflac-demo", null, "download"));
string? raw;
using (ExtensionArtifactInvocationScope.Open(resolver, workspace.Reference, job, "spotiflac-demo", 1024, CancellationToken.None))
raw = sandbox.InvokeJson("download", "{\"trackId\":\"track-1\",\"requestedQuality\":\"Any\"}");
Assert.NotNull(raw);
Assert.Contains("\"sizeBytes\":4", raw, StringComparison.Ordinal);
var outcome = await adapter.DownloadAsync(context, new(
new ProviderExternalResourceId("spotiflac-demo", ProviderResourceKind.Track, "track-1"),
job, workspace.Reference, ProviderAudioQuality.Any));
Assert.True(outcome.IsSuccess, outcome.Error?.Kind.ToString());
Assert.Equal(4, outcome.Value!.SizeBytes);
}
finally
{
if (Directory.Exists(root)) Directory.Delete(root, true);
}
}
[Fact]
public async Task SpotiFlacDownloadProvider_PreparesAStreamAndDeletesItsTransientWorkspace()
{
var root = Path.Combine(Path.GetTempPath(), "allstarr-spotiflac-stream", Guid.NewGuid().ToString("N"));
try
{
const string sourceManifest = """
{"name":"stream-demo","displayName":"Stream demo","version":"1.0.0","description":"Fixture",
"type":["download_provider"],"permissions":{"network":["media.example.test"]}}
""";
const string script = """
registerExtension({checkAvailability:function(){return {available:true};},
download:function(id, quality, path){return file.download('https://media.example.test/audio', path, {});}});
""";
var normalized = SpotiFlacExtensionCompatibility.NormalizeManifest(sourceManifest, script);
var manifest = ExtensionSdkV1.ParseManifest(normalized);
Assert.Contains(manifest.Capabilities, item => item.Kind == ProviderCapabilityKind.Streaming);
var sandbox = new ExtensionSandbox(root, normalized, script,
new HttpClientFactory(new BytesHandler([1, 2, 3, 4])), NullLogger.Instance,
new ExtensionRuntimePermissionSet(new HashSet<string>(["https://media.example.test/"]),
new HashSet<string>(), new HashSet<string>()), Path.Combine(root, "runtime"));
var options = new ProviderDownloadWorkspaceOptions
{
RootPath = Path.Combine(root, "workspaces"),
MaximumArtifactBytes = 1024
};
var resolver = new ProviderDownloadArtifactResolver(new MemoryArtifactStore(), options);
var adapter = new ExtensionDownloadStreamingCapabilityAdapter(
sandbox, manifest, null, resolver, options);
var outcome = await adapter.GetStreamLeaseAsync(
Context("spotiflac-stream-demo"),
new ProviderStreamLeaseRequest(new(
"spotiflac-stream-demo", ProviderResourceKind.Track, "track-1")));
Assert.True(outcome.IsSuccess, outcome.Error?.Kind.ToString());
var lease = outcome.RequireValue();
Assert.False(lease.SupportsByteRanges);
Assert.Equal(1337, lease.Media.Bitrate);
using (var request = new HttpRequestMessage(HttpMethod.Get, lease.ProtectedSourceUri))
using (var response = await lease.ProtectedResponseFactory!(request, CancellationToken.None))
Assert.Equal([1, 2, 3, 4], await response.Content.ReadAsByteArrayAsync());
Assert.Empty(Directory.EnumerateDirectories(options.RootPath));
}
finally
{
if (Directory.Exists(root)) Directory.Delete(root, true);
}
}
[Fact]
public async Task Metadata_AllDeclaredAlbumAndArtistHooksMapTypedSchemas()
{
string[] hooks = ["searchTracks", "getTrack", "lookupByIsrc", "searchAlbums", "getAlbum", "searchArtists", "getArtist", "getArtistAlbums", "getArtistTracks"];
var manifest = Manifest(ProviderCapabilityKind.Metadata, hooks);
var sandbox = Sandbox(manifest, """
const artist = { id: 'artist-1', name: 'Artist', artworkUrl: 'https://img.example/artist.jpg', snapshotVersion: 'a1' };
const album = { id: 'album-1', title: 'Album', artists: [{ id: 'artist-1', name: 'Artist' }], trackCount: 8, snapshotVersion: 'b1' };
const track = { id: 'track-1', title: 'Track', artists: [{ id: 'artist-1', name: 'Artist' }], albumId: 'album-1', albumTitle: 'Album', durationMs: 1234, bitrate: 320000, isrc: 'USABC1234567' };
registerExtension({
searchTracks: function() { return { items: [track] }; }, getTrack: function() { return track; }, lookupByIsrc: function() { return track; },
searchAlbums: function() { return { items: [album], nextCursor: 'next' }; }, getAlbum: function() { return album; },
searchArtists: function() { return { items: [artist] }; }, getArtist: function() { return artist; },
getArtistAlbums: function() { return { items: [album] }; }, getArtistTracks: function() { return { items: [track] }; }
});
""");
var adapter = new ExtensionMetadataCapabilityAdapter(sandbox, manifest);
var search = new ProviderMetadataSearchRequest("fixture", new ProviderPageRequest(10));
Assert.Equal("album-1", Assert.Single((await adapter.SearchAlbumsAsync(Context(), search)).RequireValue().Items).Id.Value);
Assert.Equal("next", (await adapter.SearchAlbumsAsync(Context(), search)).RequireValue().NextCursor);
Assert.Equal(8, (await adapter.GetAlbumAsync(Context(), new ProviderAlbumLookupRequest(Id(ProviderResourceKind.Album, "album-1")))).RequireValue().TrackCount);
Assert.Equal("artist-1", Assert.Single((await adapter.SearchArtistsAsync(Context(), search)).RequireValue().Items).Id.Value);
Assert.Equal("Artist", (await adapter.GetArtistAsync(Context(), new ProviderArtistLookupRequest(Id(ProviderResourceKind.Artist, "artist-1")))).RequireValue().Name);
var artistItems = new ProviderArtistItemsRequest(Id(ProviderResourceKind.Artist, "artist-1"), new ProviderPageRequest(10));
Assert.Equal("album-1", Assert.Single((await adapter.GetArtistAlbumsAsync(Context(), artistItems)).RequireValue().Items).Id.Value);
Assert.Equal("track-1", Assert.Single((await adapter.GetArtistTracksAsync(Context(), artistItems)).RequireValue().Items).Id.Value);
var track = (await adapter.LookupByIsrcAsync(Context(), new ProviderIsrcLookupRequest("USABC1234567"))).RequireValue();
Assert.Equal("USABC1234567", track.Isrc);
Assert.Equal(320_000, track.Bitrate);
}
[Fact]
public void LegacyMetadataMapping_PreservesPositiveBitrate()
{
var sandbox = Sandbox(Manifest(ProviderCapabilityKind.Metadata, "getTrack"), """
registerExtension({ getTrack: function() { return {
id: 'track-1', name: 'Track', artists: ['Artist'], album: 'Album',
duration_ms: 1234, bitrate: 320000
}; }});
""");
Assert.Equal(320_000, sandbox.GetSong("track-1")!.Bitrate);
}
[Fact]
public async Task SharedSandbox_SerializesMetadataAndHealthCapabilityCalls()
{
var manifest = new ExtensionSdkManifest("fixture-extension", "Fixture", "1.0.0", "1", "index.js",
[
new ExtensionSdkCapability(ProviderCapabilityKind.Metadata, ["searchArtists"], [ProviderAccountScope.User]),
new ExtensionSdkCapability(ProviderCapabilityKind.Health, ["probeMetadata"], [ProviderAccountScope.User])
], []);
var sandbox = Sandbox(manifest, """
let active = false;
function enter(value) { if (active) throw new Error('concurrent engine access'); active = true; for (let i = 0; i < 25000; i++) {} active = false; return value; }
registerExtension({
searchArtists: function() { return enter({ items: [{ id: 'artist-1', name: 'Artist' }] }); },
probeMetadata: function() { return enter({ status: 'Healthy', observedAt: '2030-01-01T00:00:00Z', latencyMs: 1 }); }
});
""");
var metadata = new ExtensionMetadataCapabilityAdapter(sandbox, manifest);
var health = new ExtensionHealthCapabilityAdapter(sandbox, manifest);
var calls = Enumerable.Range(0, 20).SelectMany(_ => new Task<bool>[]
{
Task.Run(async () => (await metadata.SearchArtistsAsync(Context(), new ProviderMetadataSearchRequest("x", new ProviderPageRequest(1)))).IsSuccess),
Task.Run(async () => (await health.ProbeAsync(Context(), new ProviderHealthProbeRequest(ProviderCapabilityKind.Metadata))).IsSuccess)
});
Assert.All(await Task.WhenAll(calls), Assert.True);
}
[Theory]
[InlineData("file:///etc/passwd")]
[InlineData("https://unapproved.example.test/audio")]
public async Task Streaming_RejectsFilesystemAndUnapprovedNetworkSources(string sourceUri)
{
var manifest = Manifest(ProviderCapabilityKind.Streaming, "getStreamLease");
var sandbox = Sandbox(manifest, """
registerExtension({ getStreamLease: function(request) { return {
leaseId: 'lease-1', sourceUri: 'SOURCE_URI', expiresAt: '2030-01-01T00:00:00Z',
supportsByteRanges: true, supportsSeeking: true,
media: { mimeType: 'audio/flac', container: 'flac', codec: 'flac' }, retryBehavior: 'RefreshLease'
}; }});
""".Replace("SOURCE_URI", sourceUri, StringComparison.Ordinal));
var adapter = new ExtensionStreamingCapabilityAdapter(sandbox, manifest);
var outcome = await adapter.GetStreamLeaseAsync(Context(includeAccount: true),
new ProviderStreamLeaseRequest(Id(ProviderResourceKind.Track, "track-1")));
Assert.False(outcome.IsSuccess);
Assert.Equal(ProviderErrorKind.TransientFailure, outcome.Error!.Kind);
}
[Fact]
public async Task Streaming_EnforcesAccountOriginCancellationAndTypedMediaFacts()
{
var manifest = Manifest(ProviderCapabilityKind.Streaming, "getStreamLease", "probeStream");
var permissions = new ExtensionRuntimePermissionSet(
new HashSet<string>(["https://media.example.test/"], StringComparer.OrdinalIgnoreCase),
new HashSet<string>(StringComparer.Ordinal),
new HashSet<string>(StringComparer.Ordinal));
var sandbox = Sandbox(manifest, """
registerExtension({
getStreamLease: function(request) {
if (request.trackId !== 'track-1' || request.requestedQuality !== 'Lossless' || request.rangeStart !== 4096)
throw new Error('request mismatch');
return {
leaseId: 'lease-1', sourceUri: 'https://media.example.test/audio', expiresAt: '2030-01-01T00:00:00Z',
supportsByteRanges: true, supportsSeeking: true,
media: { mimeType: 'audio/flac', container: 'flac', codec: 'flac', bitrate: 1411000,
sampleRate: 44100, bitDepth: 16, channels: 2 }, retryBehavior: 'RefreshLease',
qualityDowngradeReason: 'The source catalog has no lossless copy.'
};
},
probeStream: function(request) { return {
available: request.requestedQuality === 'Lossless', observedAt: '2030-01-01T00:00:00Z',
media: { mimeType: 'audio/flac', container: 'flac', codec: 'flac', sampleRate: 44100 }
}; }
});
""", permissions);
var adapter = new ExtensionStreamingCapabilityAdapter(sandbox, manifest);
var request = new ProviderStreamLeaseRequest(
Id(ProviderResourceKind.Track, "track-1"), ProviderAudioQuality.Lossless, 4096);
var missingAccount = await adapter.GetStreamLeaseAsync(Context(), request);
var leaseOutcome = await adapter.GetStreamLeaseAsync(Context(includeAccount: true), request);
var probe = await adapter.ProbeStreamAsync(Context(includeAccount: true), request);
using var cancellation = new CancellationTokenSource();
cancellation.Cancel();
var canceled = await adapter.GetStreamLeaseAsync(
Context(includeAccount: true, cancellationToken: cancellation.Token), request);
Assert.Equal(ProviderErrorKind.AccountNeedsConfiguration, missingAccount.Error!.Kind);
var lease = leaseOutcome.RequireValue();
Assert.True(lease.SupportsByteRanges);
Assert.True(lease.SupportsSeeking);
Assert.Equal(1_411_000, lease.Media.Bitrate);
Assert.Equal(44_100, lease.Media.SampleRate);
Assert.Equal(16, lease.Media.BitDepth);
Assert.Equal(2, lease.Media.Channels);
Assert.Equal("The source catalog has no lossless copy.", lease.QualityDowngradeReason);
Assert.True(probe.RequireValue().Available);
Assert.Equal(44_100, probe.RequireValue().Media!.SampleRate);
Assert.Equal(ProviderErrorKind.Canceled, canceled.Error!.Kind);
}
[Fact]
public async Task Lyrics_RejectsOversizedProviderContentWithoutEchoingIt()
{
var manifest = Manifest(ProviderCapabilityKind.Lyrics, "fetchLyrics");
var sandbox = Sandbox(manifest, """
registerExtension({ fetchLyrics: function(request) { return {
availability: 'Available', source: 'fixture', format: 'PlainText', content: 'x'.repeat(2000001)
}; }});
""");
var adapter = new ExtensionLyricsCapabilityAdapter(sandbox, manifest);
var outcome = await adapter.FetchLyricsAsync(Context(), new ProviderLyricsRequest(
Guid.CreateVersion7(), Id(ProviderResourceKind.Track, "track-1")));
Assert.False(outcome.IsSuccess);
Assert.Equal(ProviderErrorKind.TransientFailure, outcome.Error!.Kind);
Assert.DoesNotContain("x", outcome.Error.SafeMessage, StringComparison.Ordinal);
}
[Fact]
public async Task Playlist_ReadRequiresSelectedAccountBeforeInvokingExtension()
{
var manifest = Manifest(ProviderCapabilityKind.Playlist, "getUserPlaylists");
var sandbox = Sandbox(manifest, """
registerExtension({ getUserPlaylists: function(request) { throw new Error('secret provider diagnostic'); } });
""");
var adapter = new ExtensionPlaylistCapabilityAdapter(sandbox, manifest);
var outcome = await adapter.GetUserPlaylistsAsync(Context(),
new ProviderUserPlaylistsRequest(new ProviderPageRequest()));
Assert.False(outcome.IsSuccess);
Assert.Equal(ProviderErrorKind.AccountNeedsConfiguration, outcome.Error!.Kind);
Assert.DoesNotContain("diagnostic", outcome.Error.SafeMessage, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public async Task PlaylistTrackMetadata_PreservesExtensionDuration()
{
var manifest = Manifest(ProviderCapabilityKind.Playlist, "getPlaylistTracks");
var sandbox = Sandbox(manifest, """
registerExtension({ getPlaylistTracks: function() { return {
playlist: { id: 'playlist-1', name: 'Mix', owner: { providerUserId: 'owner' },
sourceRevision: 'r1', trackCount: 1 },
tracks: { items: [{ position: 0, trackId: 'track-1', metadata: {
title: 'Track', artists: [{ name: 'Artist' }], albumTitle: 'Album', durationMs: 196456
}}], isPartial: false }
}; }});
""");
var adapter = new ExtensionPlaylistCapabilityAdapter(sandbox, manifest);
var outcome = await adapter.GetPlaylistTracksAsync(
Context(includeAccount: true),
new ProviderPlaylistTracksRequest(
Id(ProviderResourceKind.Playlist, "playlist-1"),
new ProviderPageRequest()));
Assert.True(outcome.IsSuccess, outcome.Error?.Kind.ToString());
Assert.Equal(196_456, Assert.Single(outcome.Value!.Tracks.Items).Metadata!.Duration!.Value.TotalMilliseconds);
}
[Fact]
public async Task PlaylistArtwork_UsesDeclaredHookApprovedOriginAndBoundedImage()
{
var bytes = Convert.FromBase64String(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAusB9Y9Z0i8AAAAASUVORK5CYII=");
var handler = new BytesHandler(bytes, "image/png");
var manifest = new ExtensionSdkManifest(
"fixture-extension", "Fixture", "1.0.0", "1", "index.js",
[new ExtensionSdkCapability(ProviderCapabilityKind.Playlist,
["getUserPlaylists", "resolveArtwork"], [ProviderAccountScope.User])],
[new ExtensionPermissionRequest(
ExtensionPermissionKind.Network, "https://images.example.test/", true)]);
const string script = """
registerExtension({
getUserPlaylists: function() { return { items: [{ id: 'playlist-1', name: 'Mix',
owner: { providerUserId: 'owner' }, sourceRevision: 'r2', hasArtwork: true,
artworkRevision: 'r2' }] }; },
resolveArtwork: function(request) { return {
artworkUrl: 'https://images.example.test/cover.png', revision: 'r2'
}; }
});
""";
var allowed = new ExtensionRuntimePermissionSet(
new HashSet<string>(["https://images.example.test/"], StringComparer.Ordinal),
new HashSet<string>(StringComparer.Ordinal),
new HashSet<string>(StringComparer.Ordinal));
var sandbox = new ExtensionSandbox(
Path.GetTempPath(),
"""{"id":"fixture-extension","displayName":"Fixture","version":"1.0.0"}""",
script,
new HttpClientFactory(handler),
NullLogger.Instance,
allowed);
var adapter = new ExtensionPlaylistCapabilityAdapter(sandbox, manifest);
var context = Context(includeAccount: true);
var summary = Assert.Single((await adapter.GetUserPlaylistsAsync(
context, new ProviderUserPlaylistsRequest(new ProviderPageRequest()))).RequireValue().Items);
var outcome = await adapter.ResolveArtworkAsync(
context,
new ProviderPlaylistArtworkRequest(summary.Artwork!, 1024));
Assert.Equal("playlist-1", summary.Artwork!.ResourceId!.Value);
Assert.Equal("r2", summary.Artwork.Revision);
Assert.True(outcome.IsSuccess, outcome.Error?.Kind.ToString());
Assert.Equal(bytes, outcome.Value!.Bytes);
Assert.Equal("image/png", outcome.Value.ContentType);
Assert.Equal(1, handler.CallCount);
Assert.True(ExtensionPlaylistCapabilityAdapter.IsAllowedArtworkDimensions(4_000, 4_000));
Assert.False(ExtensionPlaylistCapabilityAdapter.IsAllowedArtworkDimensions(4_001, 4_000));
var deniedSandbox = new ExtensionSandbox(
Path.GetTempPath(),
"""{"id":"fixture-extension","displayName":"Fixture","version":"1.0.0"}""",
script,
new HttpClientFactory(handler),
NullLogger.Instance,
ExtensionRuntimePermissionSet.None);
var denied = await new ExtensionPlaylistCapabilityAdapter(deniedSandbox, manifest)
.ResolveArtworkAsync(context, new ProviderPlaylistArtworkRequest(summary.Artwork!, 1024));
Assert.Equal(ProviderErrorKind.Forbidden, denied.Error!.Kind);
Assert.Equal(1, handler.CallCount);
}
[Fact]
public async Task Intelligence_MapsAnalysisDiscoveryAndOptionalSonicContracts()
{
string[] hooks = ["startAnalysis", "getAnalysisProgress", "getClusters", "recommend",
"search", "findPath", "blend", "getMap", "disconnect"];
var manifest = new ExtensionSdkManifest("fixture-extension", "Fixture", "1.0.0", "1", "index.js",
[
new ExtensionSdkCapability(ProviderCapabilityKind.Intelligence, hooks, [ProviderAccountScope.User]),
new ExtensionSdkCapability(ProviderCapabilityKind.Health, ["probeIntelligence"], [ProviderAccountScope.User])
], []);
var sandbox = Sandbox(manifest, """
const track = { trackId: 'track-1', title: 'Track', artist: 'Artist', album: 'Album',
score: 0.9, clusterId: 'cluster-1', explanation: 'Similar sound' };
registerExtension({
startAnalysis: function() { return { jobId: 'job-1', state: 'Queued', completed: 0, total: 10 }; },
getAnalysisProgress: function() { return { jobId: 'job-1', state: 'Running', completed: 4, total: 10 }; },
getClusters: function() { return { items: [{ id: 'cluster-1', name: 'Cluster', tracks: [track] }] }; },
recommend: function() { return { items: [track] }; },
search: function() { return { items: [track] }; },
findPath: function() { return { items: [track, { trackId: 'track-2', title: 'Bridge', artist: 'Artist', score: 0.8 }], totalDistance: 0.4 }; },
blend: function() { return { items: [track] }; },
getMap: function() { return { items: [{ trackId: 'track-1', title: 'Track', artist: 'Artist', x: 0.25, y: -0.5 }], projection: 'umap', nextCursor: 'next', snapshotVersion: 'map-1' }; },
disconnect: function() { return { disconnected: true }; },
probeIntelligence: function() { return { status: 'Healthy', observedAt: '2030-01-01T00:00:00Z', latencyMs: 1 }; }
});
""");
var intelligence = new ExtensionIntelligenceCapabilityAdapter(sandbox, manifest);
var health = new ExtensionHealthCapabilityAdapter(sandbox, manifest);
var context = Context(includeAccount: true);
Assert.Equal("job-1", (await intelligence.StartAnalysisAsync(context)).RequireValue().JobId);
Assert.Equal(4, (await intelligence.GetAnalysisProgressAsync(context, "job-1")).RequireValue().Completed);
Assert.Equal("cluster-1", Assert.Single((await intelligence.GetClustersAsync(context)).RequireValue()).Id);
Assert.Equal("Similar sound", Assert.Single((await intelligence.RecommendAsync(context, ["seed"], 10)).RequireValue()).Explanation);
Assert.Equal("Track", Assert.Single((await intelligence.SearchAsync(context, "lyrics", true, 10)).RequireValue()).Title);
Assert.Equal(["track-1", "track-2"], (await intelligence.FindPathAsync(context, "track-1", "track-2", 10)).RequireValue().Tracks.Select(item => item.TrackId));
Assert.Equal("track-1", Assert.Single((await intelligence.BlendAsync(context, ["track-1"], ["track-2"], 10)).RequireValue()).TrackId);
var map = (await intelligence.GetMapAsync(context, new ProviderPageRequest(10))).RequireValue();
Assert.Equal("umap", map.Projection);
Assert.Equal(0.25, Assert.Single(map.Items).X);
Assert.Equal("next", map.NextCursor);
Assert.True((await intelligence.DisconnectAsync(context)).RequireValue());
Assert.Equal(ProviderProbeStatus.Healthy,
(await health.ProbeAsync(context, new(ProviderCapabilityKind.Intelligence))).RequireValue().Status);
await Assert.ThrowsAsync<ArgumentOutOfRangeException>(() =>
intelligence.RecommendAsync(context, ["seed"], 0));
await Assert.ThrowsAsync<ArgumentException>(() =>
intelligence.BlendAsync(context, ["same"], ["same"], 10));
}
[Fact]
public async Task Intelligence_RejectsMalformedAndOversizedExtensionResults()
{
string[] hooks = ["startAnalysis", "getAnalysisProgress", "getClusters", "recommend",
"search", "findPath", "blend", "getMap"];
var manifest = new ExtensionSdkManifest("fixture-extension", "Fixture", "1.0.0", "1", "index.js",
[
new ExtensionSdkCapability(ProviderCapabilityKind.Intelligence, hooks, [ProviderAccountScope.User])
], []);
var sandbox = Sandbox(manifest, """
function tracks(count) {
var result = [];
for (var i = 0; i < count; i++) result.push({ trackId: 'track-' + i, title: 'Track', artist: 'Artist', score: 0.9 });
return result;
}
registerExtension({
startAnalysis: function() { return { jobId: '', state: 'Queued', completed: 0, total: 1 }; },
getAnalysisProgress: function() { return { jobId: 'job-1', state: 'Running', completed: 2, total: 1 }; },
getClusters: function() { return { items: [
{ id: 'one', name: 'One', tracks: [] }, { id: 'two', name: 'Two', tracks: [] }
] }; },
recommend: function() { return { items: tracks(2) }; },
search: function() { return { items: [{ trackId: 'track-1', title: 'Track', artist: 'Artist', score: 0.9, album: Array(502).join('x') }] }; },
findPath: function() { return { items: tracks(3), totalDistance: 0.4 }; },
blend: function() { return { items: tracks(2) }; },
getMap: function() { return { items: [{ trackId: 'track-1', title: 'Track', artist: 'Artist', x: 2, y: 0 }], projection: 'umap' }; }
});
""");
var intelligence = new ExtensionIntelligenceCapabilityAdapter(sandbox, manifest);
var context = Context(includeAccount: true);
static void Transient<T>(ProviderOutcome<T> outcome) =>
Assert.Equal(ProviderErrorKind.TransientFailure, outcome.Error!.Kind);
Transient(await intelligence.StartAnalysisAsync(context));
Transient(await intelligence.GetAnalysisProgressAsync(context, "job-1"));
Transient(await intelligence.GetClustersAsync(context, 1));
Transient(await intelligence.RecommendAsync(context, ["seed"], 1));
Transient(await intelligence.SearchAsync(context, "query", false, 1));
Transient(await intelligence.FindPathAsync(context, "start", "end", 2));
Transient(await intelligence.BlendAsync(context, ["start"], [], 1));
Transient(await intelligence.GetMapAsync(context, new ProviderPageRequest(1)));
}
[Fact]
public async Task Intelligence_OmittedSonicHooksReportNotSupported()
{
var manifest = Manifest(ProviderCapabilityKind.Intelligence, "recommend");
var adapter = new ExtensionIntelligenceCapabilityAdapter(Sandbox(manifest,
"registerExtension({ recommend: function() { return { items: [] }; } });"), manifest);
var context = Context();
Assert.Equal(ProviderErrorKind.NotSupported,
(await adapter.FindPathAsync(context, "one", "two", 2)).Error!.Kind);
Assert.Equal(ProviderErrorKind.NotSupported,
(await adapter.BlendAsync(context, ["one"], [], 1)).Error!.Kind);
Assert.Equal(ProviderErrorKind.NotSupported,
(await adapter.GetMapAsync(context, new ProviderPageRequest(1))).Error!.Kind);
}
[Fact]
public async Task ForeignResourceId_IsRejectedBeforeHookInvocation()
{
var manifest = Manifest(ProviderCapabilityKind.Download, "checkAvailability");
var sandbox = Sandbox(manifest, "registerExtension({ checkAvailability: function() { return { state: 'Available' }; } });");
var adapter = new ExtensionDownloadCapabilityAdapter(sandbox, manifest);
await Assert.ThrowsAsync<ArgumentException>(() => adapter.CheckAvailabilityAsync(Context(),
new ProviderDownloadAvailabilityRequest(new ProviderExternalResourceId("other-provider", ProviderResourceKind.Track, "track-1"))));
}
[Fact]
public async Task Download_BrokerStreamsIntoExactWorkspaceAndRetryReusesVerifiedArtifact()
{
var bytes = Encoding.UTF8.GetBytes("extension audio");
using var fixture = DownloadFixture(bytes, 1024, "track.flac");
var first = await fixture.Adapter.DownloadAsync(fixture.Context,
new(fixture.Track, fixture.JobId, fixture.Workspace.Reference, ProviderAudioQuality.Lossless));
var second = await fixture.Adapter.DownloadAsync(fixture.Context,
new(fixture.Track, fixture.JobId, fixture.Workspace.Reference, ProviderAudioQuality.Lossless));
var output = first.RequireValue();
Assert.Equal("track.flac", output.ArtifactId);
Assert.Equal(Convert.ToHexString(SHA256.HashData(bytes)).ToLowerInvariant(), output.Sha256);
Assert.Equal(output, second.RequireValue());
var resolved = await fixture.Resolver.ResolveAsync(fixture.Workspace.Reference, output);
Assert.Equal(bytes, await File.ReadAllBytesAsync(resolved.SourcePath));
Assert.Single(fixture.Store.Artifacts);
}
[Fact]
public async Task Download_BrokerRejectsOversizeInvalidPathAndForeignWorkspaceLineage()
{
using var oversized = DownloadFixture(new byte[128], 16, "track.flac");
Assert.Equal(ProviderErrorKind.TransientFailure,
(await oversized.Adapter.DownloadAsync(oversized.Context,
new(oversized.Track, oversized.JobId, oversized.Workspace.Reference, ProviderAudioQuality.Any))).Error!.Kind);
using var traversal = DownloadFixture(Encoding.UTF8.GetBytes("audio"), 1024, "../track.flac");
Assert.Equal(ProviderErrorKind.TransientFailure,
(await traversal.Adapter.DownloadAsync(traversal.Context,
new(traversal.Track, traversal.JobId, traversal.Workspace.Reference, ProviderAudioQuality.Any))).Error!.Kind);
Assert.Empty(Directory.EnumerateFiles(Path.Combine(traversal.Root, traversal.Workspace.Reference.WorkspaceId)));
using var foreign = DownloadFixture(Encoding.UTF8.GetBytes("audio"), 1024, "track.flac");
var otherJob = Guid.CreateVersion7();
var foreignWorkspace = await foreign.Resolver.CreateWorkspaceAsync(new(
foreign.Context.Actor.TenantId, foreign.Context.Actor.EffectiveUserId, otherJob,
"fixture-extension", null, "foreign"));
Assert.Equal(ProviderErrorKind.TransientFailure,
(await foreign.Adapter.DownloadAsync(foreign.Context,
new(foreign.Track, foreign.JobId, foreignWorkspace.Reference, ProviderAudioQuality.Any))).Error!.Kind);
}
[Fact]
public async Task Download_RejectsClaimThatWasNotWrittenThroughBroker()
{
var root = Path.Combine(Path.GetTempPath(), "allstarr-extension-claim", Guid.NewGuid().ToString("N"));
try
{
var store = new MemoryArtifactStore();
var options = new ProviderDownloadWorkspaceOptions { RootPath = root, MaximumArtifactBytes = 1024 };
var resolver = new ProviderDownloadArtifactResolver(store, options);
var manifest = DownloadManifest();
var sandbox = Sandbox(manifest, """
registerExtension({ download: function() { return {
artifactId: 'claimed.flac', sha256: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
sizeBytes: 10, verified: true, media: { mimeType: 'audio/flac', container: 'flac', codec: 'flac' }
}; }});
""");
var adapter = new ExtensionDownloadCapabilityAdapter(sandbox, manifest, artifacts: resolver, options: options);
var context = Context();
var job = Guid.CreateVersion7();
var workspace = await resolver.CreateWorkspaceAsync(new(
context.Actor.TenantId, context.Actor.EffectiveUserId, job, "fixture-extension", null, "claim"));
var outcome = await adapter.DownloadAsync(context,
new(Id(ProviderResourceKind.Track, "track-1"), job, workspace.Reference, ProviderAudioQuality.Any));
Assert.Equal(ProviderErrorKind.TransientFailure, outcome.Error!.Kind);
Assert.Empty(store.Artifacts);
}
finally
{
if (Directory.Exists(root)) Directory.Delete(root, true);
}
}
private static ExtensionSdkManifest Manifest(ProviderCapabilityKind kind, params string[] hooks) => new(
"fixture-extension", "Fixture", "1.0.0", "1", "index.js",
[new ExtensionSdkCapability(kind, hooks, [ProviderAccountScope.User])], []);
private static ExtensionSandbox Sandbox(
ExtensionSdkManifest manifest,
string script,
ExtensionRuntimePermissionSet? permissions = null) => new(
Path.GetTempPath(), """{"id":"fixture-extension","displayName":"Fixture","version":"1.0.0"}""", script,
new HttpClientFactory(), NullLogger.Instance, permissions);
private static ExtensionSdkManifest DownloadManifest() => new(
"fixture-extension", "Fixture", "1.0.0", "1", "index.js",
[new ExtensionSdkCapability(ProviderCapabilityKind.Download, ["download"],
[ProviderAccountScope.User], AccountRequired: false)],
[new ExtensionPermissionRequest(ExtensionPermissionKind.Network, "https://media.example.test/", true)]);
private static DownloadFixtureState DownloadFixture(byte[] bytes, long maximumBytes, string artifactId)
{
var root = Path.Combine(Path.GetTempPath(), "allstarr-extension-download", Guid.NewGuid().ToString("N"));
var store = new MemoryArtifactStore();
var options = new ProviderDownloadWorkspaceOptions { RootPath = root, MaximumArtifactBytes = maximumBytes };
var resolver = new ProviderDownloadArtifactResolver(store, options);
var manifest = DownloadManifest();
var permissions = new ExtensionRuntimePermissionSet(
new HashSet<string>(["https://media.example.test/"], StringComparer.Ordinal),
new HashSet<string>(StringComparer.Ordinal), new HashSet<string>(StringComparer.Ordinal));
var sandbox = new ExtensionSandbox(root,
"""{"id":"fixture-extension","displayName":"Fixture","version":"1.0.0"}""",
$$$"""
registerExtension({ download: function() {
const written = artifacts.download('https://media.example.test/audio', '{{{artifactId}}}');
return { artifactId: written.artifactId, sha256: written.sha256, sizeBytes: written.sizeBytes,
verified: written.verified, media: { mimeType: 'audio/flac', container: 'flac', codec: 'flac' } };
}});
""",
new HttpClientFactory(new BytesHandler(bytes)), NullLogger.Instance, permissions,
Path.Combine(root, "runtime"));
var adapter = new ExtensionDownloadCapabilityAdapter(sandbox, manifest, artifacts: resolver, options: options);
var context = Context();
var job = Guid.CreateVersion7();
var workspace = resolver.CreateWorkspaceAsync(new(
context.Actor.TenantId, context.Actor.EffectiveUserId, job, "fixture-extension", null, "download"))
.GetAwaiter().GetResult();
return new(root, store, resolver, adapter, context, job, workspace,
Id(ProviderResourceKind.Track, "track-1"));
}
private static ProviderExternalResourceId Id(ProviderResourceKind kind, string value) =>
new("fixture-extension", kind, value);
private static ProviderExecutionContext Context(
string providerId = "fixture-extension",
bool includeAccount = false,
CancellationToken cancellationToken = default)
{
var actor = new ProviderActorContext(Guid.CreateVersion7(), ProviderActorKind.User, Guid.CreateVersion7(),
new ProviderBackendPrincipal("jellyfin", "fixture", "user"));
var account = includeAccount
? new ProviderAccountContext(Guid.CreateVersion7(), providerId, ProviderAccountScope.User, 1,
tenantId: actor.TenantId, ownerUserId: actor.EffectiveUserId)
: null;
return new ProviderExecutionContext(actor, providerId, account, null,
new ProviderExecutionPolicy(new ProviderQualityPolicy(ProviderAudioQuality.Any, ProviderAudioQuality.HighResolution, true),
ProviderExplicitContentPolicy.Allow, true, false, true, [providerId]),
"extension-test", "extension-test-correlation", DateTimeOffset.UtcNow.AddMinutes(1), cancellationToken,
"extension-test-idempotency");
}
private sealed class HttpClientFactory(HttpMessageHandler? handler = null) : IHttpClientFactory
{
public HttpClient CreateClient(string name) => handler == null
? new HttpClient()
: new HttpClient(handler, disposeHandler: false);
}
private sealed class BytesHandler(byte[] bytes, string? contentType = null) : HttpMessageHandler
{
public int CallCount { get; private set; }
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
CallCount++;
var response = new HttpResponseMessage(HttpStatusCode.OK) { Content = new ByteArrayContent(bytes) };
if (contentType != null)
response.Content.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue(contentType);
response.RequestMessage = request;
return Task.FromResult(response);
}
}
private sealed class StatusHandler(params HttpStatusCode[] statuses) : HttpMessageHandler
{
private readonly Queue<HttpStatusCode> _statuses = new(statuses);
private readonly HttpStatusCode _fallback = statuses.LastOrDefault();
public int CallCount { get; private set; }
protected override Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request,
CancellationToken cancellationToken)
{
CallCount++;
var status = _statuses.TryDequeue(out var queued) ? queued : _fallback;
var response = new HttpResponseMessage(status)
{
Content = new StringContent("{}"),
RequestMessage = request
};
return Task.FromResult(response);
}
}
private sealed class HeaderHandler : HttpMessageHandler
{
public string? Authorization { get; private set; }
public string? Origin { get; private set; }
public string? MediaUserToken { get; private set; }
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
Authorization = request.Headers.TryGetValues("Authorization", out var authorization)
? authorization.Single() : null;
Origin = request.Headers.TryGetValues("Origin", out var origin) ? origin.Single() : null;
MediaUserToken = request.Headers.TryGetValues("Media-User-Token", out var mediaUserToken)
? mediaUserToken.Single() : null;
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent("{}"),
RequestMessage = request
});
}
}
private sealed class CapturingLogger : Microsoft.Extensions.Logging.ILogger
{
public IReadOnlyDictionary<string, object?>? LastState { get; private set; }
public IDisposable? BeginScope<TState>(TState state) where TState : notnull => null;
public bool IsEnabled(Microsoft.Extensions.Logging.LogLevel logLevel) => true;
public void Log<TState>(
Microsoft.Extensions.Logging.LogLevel logLevel,
Microsoft.Extensions.Logging.EventId eventId,
TState state,
Exception? exception,
Func<TState, Exception?, string> formatter)
{
if (state is IEnumerable<KeyValuePair<string, object?>> fields)
LastState = fields.ToDictionary(item => item.Key, item => item.Value);
}
}
private sealed class SignedSessionHandler : HttpMessageHandler
{
public HttpRequestMessage? SignedRequest { get; private set; }
public string? ExchangeGrant { get; private set; }
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
HttpResponseMessage response;
if (request.RequestUri!.AbsolutePath.EndsWith("/bootstrap", StringComparison.Ordinal))
response = Json("{\"challenge_id\":\"challenge-1\"}");
else if (request.RequestUri.AbsolutePath.EndsWith("/session/exchange", StringComparison.Ordinal))
{
var body = request.Content!.ReadAsStringAsync(cancellationToken).GetAwaiter().GetResult();
ExchangeGrant = JsonDocument.Parse(body).RootElement.GetProperty("grant").GetString();
response = Json("{\"session_id\":\"session-1\",\"session_secret\":\"secret-1\",\"expires_at\":\"2099-01-01T00:00:00Z\"}");
}
else
{
SignedRequest = request;
response = Json("{\"message\":\"catalog ok\"}");
}
response.RequestMessage = request;
return Task.FromResult(response);
}
private static HttpResponseMessage Json(string value) => new(HttpStatusCode.OK)
{
Content = new StringContent(value, Encoding.UTF8, "application/json")
};
}
private sealed record DownloadFixtureState(string Root, MemoryArtifactStore Store,
ProviderDownloadArtifactResolver Resolver, ExtensionDownloadCapabilityAdapter Adapter,
ProviderExecutionContext Context, Guid JobId, ProviderDownloadWorkspace Workspace,
ProviderExternalResourceId Track) : IDisposable
{
public void Dispose()
{
if (Directory.Exists(Root)) Directory.Delete(Root, true);
}
}
private sealed class MemoryArtifactStore : IProviderDownloadArtifactStore
{
public List<ProviderDownloadWorkspaceEntity> Workspaces { get; } = [];
public List<ProviderDownloadArtifactEntity> Artifacts { get; } = [];
public Task<ProviderDownloadWorkspaceEntity> CreateWorkspaceAsync(ProviderDownloadWorkspaceEntity value, CancellationToken token)
{
var existing = Workspaces.SingleOrDefault(item => item.WorkspaceId == value.WorkspaceId);
if (existing != null) return Task.FromResult(existing);
Workspaces.Add(value); return Task.FromResult(value);
}
public Task<ProviderDownloadWorkspaceEntity?> GetWorkspaceAsync(string id, CancellationToken token) =>
Task.FromResult(Workspaces.SingleOrDefault(item => item.WorkspaceId == id));
public Task<ProviderDownloadArtifactEntity> AddVerifiedAsync(ProviderDownloadArtifactEntity value, CancellationToken token)
{
var existing = Artifacts.SingleOrDefault(item => item.WorkspaceRecordId == value.WorkspaceRecordId &&
item.ProviderArtifactId == value.ProviderArtifactId);
if (existing != null) return Task.FromResult(existing);
Artifacts.Add(value); return Task.FromResult(value);
}
public Task<ProviderDownloadArtifactEntity?> FindByJobAsync(Guid tenantId, Guid jobId, string providerId, CancellationToken token) =>
Task.FromResult(Artifacts.SingleOrDefault(item => item.TenantId == tenantId &&
item.DurableJobId == jobId && item.ProviderId == providerId));
public Task MarkPlacedAsync(Guid artifactId, Guid managedFileId, CancellationToken token) => Task.CompletedTask;
}
}