@@ -108,6 +108,55 @@ public async Task UploadAsync_ResourceIdWithoutLeadingSlash_IsNormalizedForArmRe
108108 result ! . Value . VideoId . ShouldBe ( "video123" ) ;
109109 }
110110
111+ [ Theory ]
112+ [ InlineData ( "subscriptions/sub/resourceGroups/rg/providers/Microsoft.VideoIndexer/accounts/account/overview" ) ]
113+ [ InlineData ( "/subscriptions/sub/resourceGroups/rg/providers/Microsoft.VideoIndexer/accounts/account/overview/" ) ]
114+ [ InlineData ( "https://management.azure.com/subscriptions/sub/resourceGroups/rg/providers/Microsoft.VideoIndexer/accounts/account/overview/?api-version=2024-01-01" ) ]
115+ public async Task UploadAsync_ResourceIdWithExtraSegments_IsNormalizedToAccountPath ( string resourceId )
116+ {
117+ var sequence = new SequenceHandler ( ) ;
118+
119+ sequence . Enqueue ( request =>
120+ {
121+ request . Method . ShouldBe ( HttpMethod . Post ) ;
122+ request . RequestUri ! . ToString ( ) . ShouldContain ( "https://management.azure.com/subscriptions/sub/resourceGroups/rg/providers/Microsoft.VideoIndexer/accounts/account/generateAccessToken" ) ;
123+ request . RequestUri ! . ToString ( ) . ShouldNotContain ( "/overview" ) ;
124+ request . RequestUri ! . ToString ( ) . ShouldNotContain ( "https://management.azure.comsubscriptions/" ) ;
125+
126+ var payload = new
127+ {
128+ accessToken = "token123" ,
129+ expirationTime = "2025-01-01T00:00:00Z"
130+ } ;
131+
132+ return JsonResponse ( HttpStatusCode . OK , payload ) ;
133+ } ) ;
134+
135+ sequence . Enqueue ( _ =>
136+ JsonResponse ( HttpStatusCode . OK , new { id = "video123" } ) ) ;
137+
138+ using var httpClient = new HttpClient ( sequence )
139+ {
140+ BaseAddress = new Uri ( "https://api.videoindexer.ai/" )
141+ } ;
142+
143+ var options = new AzureMediaIntelligenceOptions
144+ {
145+ AccountId = "account" ,
146+ Location = "trial" ,
147+ ResourceId = resourceId
148+ } ;
149+
150+ var client = new VideoIndexerClient ( options , httpClient , new StubArmTokenService ( "arm-token" ) ) ;
151+ await using var stream = new MemoryStream ( new byte [ ] { 1 , 2 , 3 } ) ;
152+ var streamInfo = new StreamInfo ( mimeType : "video/mp4" , extension : ".mp4" , fileName : "sample.mp4" ) ;
153+
154+ var result = await client . UploadAsync ( stream , streamInfo , CancellationToken . None ) ;
155+
156+ result . ShouldNotBeNull ( ) ;
157+ result ! . Value . VideoId . ShouldBe ( "video123" ) ;
158+ }
159+
111160 [ Fact ]
112161 public async Task UploadAsync_WithVideoIndexerAccountAccessToken_SkipsArmGenerateAccessToken ( )
113162 {
@@ -146,6 +195,32 @@ public async Task UploadAsync_WithVideoIndexerAccountAccessToken_SkipsArmGenerat
146195 result . Value . AccountAccessToken . ShouldBe ( token ) ;
147196 }
148197
198+ [ Fact ]
199+ public async Task UploadAsync_WithReadOnlyVideoIndexerAccountToken_FailsFastWithActionableError ( )
200+ {
201+ using var httpClient = new HttpClient ( new SequenceHandler ( ) )
202+ {
203+ BaseAddress = new Uri ( "https://api.videoindexer.ai/" )
204+ } ;
205+
206+ var options = new AzureMediaIntelligenceOptions
207+ {
208+ AccountId = "account" ,
209+ Location = "trial" ,
210+ ResourceId = "/subscriptions/sub/resourceGroups/rg/providers/Microsoft.VideoIndexer/accounts/account"
211+ } ;
212+
213+ var readOnlyToken = BuildUnsignedJwtWithAudience ( "https://api.videoindexer.ai/" , DateTimeOffset . UtcNow . AddMinutes ( 30 ) , permission : "Reader" ) ;
214+ var client = new VideoIndexerClient ( options , httpClient , new StubArmTokenService ( readOnlyToken ) ) ;
215+ await using var stream = new MemoryStream ( new byte [ ] { 1 , 2 , 3 } ) ;
216+ var streamInfo = new StreamInfo ( mimeType : "video/mp4" , extension : ".mp4" , fileName : "sample.mp4" ) ;
217+
218+ var exception = await Should . ThrowAsync < FileConversionException > ( ( ) => client . UploadAsync ( stream , streamInfo , CancellationToken . None ) ) ;
219+
220+ exception . Message . ShouldContain ( "Permission=Reader" ) ;
221+ exception . Message . ShouldContain ( "Contributor" ) ;
222+ }
223+
149224 [ Fact ]
150225 public async Task UploadAsync_WhenNameConflicts_RetriesWithGeneratedName ( )
151226 {
@@ -400,7 +475,7 @@ private static HttpResponseMessage JsonResponse(HttpStatusCode statusCode, objec
400475 return new HttpResponseMessage ( statusCode ) { Content = content } ;
401476 }
402477
403- private static string BuildUnsignedJwtWithAudience ( string audience , DateTimeOffset expiresOn )
478+ private static string BuildUnsignedJwtWithAudience ( string audience , DateTimeOffset expiresOn , string ? permission = null )
404479 {
405480 static string Encode ( object value )
406481 {
@@ -410,11 +485,18 @@ static string Encode(object value)
410485 }
411486
412487 var header = Encode ( new { alg = "none" , typ = "JWT" } ) ;
413- var payload = Encode ( new
488+ var payloadBody = new Dictionary < string , object ? >
414489 {
415- aud = audience ,
416- exp = expiresOn . ToUnixTimeSeconds ( )
417- } ) ;
490+ [ "aud" ] = audience ,
491+ [ "exp" ] = expiresOn . ToUnixTimeSeconds ( )
492+ } ;
493+
494+ if ( ! string . IsNullOrWhiteSpace ( permission ) )
495+ {
496+ payloadBody [ "Permission" ] = permission ;
497+ }
498+
499+ var payload = Encode ( payloadBody ) ;
418500
419501 return $ "{ header } .{ payload } .";
420502 }
0 commit comments