44 */
55
66import * as assert from 'assert'
7- import { AuthNode , isSsoConnection , promptForConnection , SsoConnection } from '../../credentials/auth'
7+ import * as sinon from 'sinon'
8+ import {
9+ AuthNode ,
10+ Connection ,
11+ isIamConnection ,
12+ isSsoConnection ,
13+ promptForConnection ,
14+ ssoAccountAccessScopes ,
15+ } from '../../credentials/auth'
816import { ToolkitError } from '../../shared/errors'
917import { assertTreeItem } from '../shared/treeview/testUtil'
1018import { getTestWindow } from '../shared/vscode/window'
1119import { captureEventOnce } from '../testUtil'
12- import { createSsoProfile , createTestAuth } from './testUtil'
20+ import { createBuilderIdProfile , createSsoProfile , createTestAuth } from './testUtil'
21+ import { toCollection } from '../../shared/utilities/asyncCollection'
22+ import globals from '../../shared/extensionGlobals'
1323
1424const ssoProfile = createSsoProfile ( )
1525const scopedSsoProfile = createSsoProfile ( { scopes : [ 'foo' ] } )
@@ -181,15 +191,15 @@ describe('Auth', function () {
181191 assert . ok ( await conn . getToken ( ) )
182192 } )
183193
184- describe ( 'SSO Connections' , function ( ) {
185- async function runExpiredGetTokenFlow ( conn : SsoConnection , selection : string | RegExp ) {
186- const token = conn . getToken ( )
187- const message = await getTestWindow ( ) . waitForMessage ( expiredConnPattern )
188- message . selectItem ( selection )
194+ async function runExpiredConnectionFlow ( conn : Connection , selection : string | RegExp ) {
195+ const creds = conn . type === 'sso' ? conn . getToken ( ) : conn . getCredentials ( )
196+ const message = await getTestWindow ( ) . waitForMessage ( expiredConnPattern )
197+ message . selectItem ( selection )
189198
190- return token
191- }
199+ return creds
200+ }
192201
202+ describe ( 'SSO Connections' , function ( ) {
193203 it ( 'creates a new token if one does not exist' , async function ( ) {
194204 const conn = await auth . createConnection ( ssoProfile )
195205 const provider = auth . getTestTokenProvider ( conn )
@@ -198,7 +208,7 @@ describe('Auth', function () {
198208
199209 it ( 'prompts the user if the token is invalid or expired' , async function ( ) {
200210 const conn = await auth . createInvalidSsoConnection ( ssoProfile )
201- const token = await runExpiredGetTokenFlow ( conn , / l o g i n / i)
211+ const token = await runExpiredConnectionFlow ( conn , / l o g i n / i)
202212 assert . notStrictEqual ( token , undefined )
203213 } )
204214
@@ -207,7 +217,7 @@ describe('Auth', function () {
207217 await auth . useConnection ( conn )
208218 await auth . invalidateCachedCredentials ( conn )
209219
210- const token = runExpiredGetTokenFlow ( conn , / n o / i)
220+ const token = runExpiredConnectionFlow ( conn , / n o / i)
211221 await assert . rejects ( token , ToolkitError )
212222
213223 assert . strictEqual ( auth . activeConnection ?. state , 'invalid' )
@@ -217,12 +227,139 @@ describe('Auth', function () {
217227 const err1 = new ToolkitError ( 'test' , { code : 'test' } )
218228 const conn = await auth . createConnection ( ssoProfile )
219229 auth . getTestTokenProvider ( conn ) ?. getToken . rejects ( err1 )
220- const err2 = await runExpiredGetTokenFlow ( conn , / n o / i) . catch ( e => e )
230+ const err2 = await runExpiredConnectionFlow ( conn , / n o / i) . catch ( e => e )
221231 assert . ok ( err2 instanceof ToolkitError )
222232 assert . strictEqual ( err2 . cause , err1 )
223233 } )
224234 } )
225235
236+ describe ( 'Linked Connections' , function ( ) {
237+ const linkedSsoProfile = createSsoProfile ( { scopes : ssoAccountAccessScopes } )
238+ const accountRoles = [
239+ { accountId : '1245678910' , roleName : 'foo' } ,
240+ { accountId : '9876543210' , roleName : 'foo' } ,
241+ { accountId : '9876543210' , roleName : 'bar' } ,
242+ ]
243+
244+ beforeEach ( function ( ) {
245+ auth . ssoClient . listAccounts . returns (
246+ toCollection ( async function * ( ) {
247+ yield [ { accountId : '1245678910' } , { accountId : '9876543210' } ]
248+ } )
249+ )
250+
251+ auth . ssoClient . listAccountRoles . callsFake ( req =>
252+ toCollection ( async function * ( ) {
253+ yield accountRoles . filter ( i => i . accountId === req . accountId )
254+ } )
255+ )
256+
257+ auth . ssoClient . getRoleCredentials . resolves ( {
258+ accessKeyId : 'xxx' ,
259+ secretAccessKey : 'xxx' ,
260+ expiration : new Date ( Date . now ( ) + 1000000 ) ,
261+ } )
262+
263+ sinon . stub ( globals . loginManager , 'validateCredentials' ) . resolves ( '' )
264+ } )
265+
266+ afterEach ( function ( ) {
267+ sinon . restore ( )
268+ } )
269+
270+ it ( 'lists linked conections for SSO connections' , async function ( ) {
271+ await auth . createConnection ( linkedSsoProfile )
272+ const connections = await auth . listAndTraverseConnections ( ) . promise ( )
273+ assert . deepStrictEqual (
274+ connections . map ( c => c . type ) ,
275+ [ 'sso' , 'iam' , 'iam' , 'iam' ]
276+ )
277+ } )
278+
279+ it ( 'does not gather linked accounts when calling `listConnections`' , async function ( ) {
280+ await auth . createConnection ( linkedSsoProfile )
281+ const connections = await auth . listConnections ( )
282+ assert . deepStrictEqual (
283+ connections . map ( c => c . type ) ,
284+ [ 'sso' ]
285+ )
286+ } )
287+
288+ it ( 'caches linked conections when the source connection becomes invalid' , async function ( ) {
289+ const conn = await auth . createConnection ( linkedSsoProfile )
290+ await auth . listAndTraverseConnections ( ) . promise ( )
291+ await auth . invalidateCachedCredentials ( conn )
292+
293+ const connections = await auth . listConnections ( )
294+ assert . deepStrictEqual (
295+ connections . map ( c => c . type ) ,
296+ [ 'sso' , 'iam' , 'iam' , 'iam' ]
297+ )
298+ } )
299+
300+ it ( 'gracefully handles source connections becoming invalid when discovering linked accounts' , async function ( ) {
301+ await auth . createConnection ( linkedSsoProfile )
302+ auth . ssoClient . listAccounts . rejects ( new Error ( 'No access' ) )
303+ const connections = await auth . listAndTraverseConnections ( ) . promise ( )
304+ assert . deepStrictEqual (
305+ connections . map ( c => c . type ) ,
306+ [ 'sso' ]
307+ )
308+ } )
309+
310+ it ( 'removes linked connections when the source connection is deleted' , async function ( ) {
311+ const conn = await auth . createConnection ( linkedSsoProfile )
312+ await auth . listAndTraverseConnections ( ) . promise ( )
313+ await auth . deleteConnection ( conn )
314+
315+ assert . deepStrictEqual ( await auth . listAndTraverseConnections ( ) . promise ( ) , [ ] )
316+ } )
317+
318+ it ( 'prompts the user to reauthenticate if the source connection becomes invalid' , async function ( ) {
319+ const source = await auth . createConnection ( linkedSsoProfile )
320+ const conn = await auth . listAndTraverseConnections ( ) . find ( c => isIamConnection ( c ) && c . id . includes ( 'sso' ) )
321+ assert . ok ( conn )
322+ await auth . useConnection ( conn )
323+ await auth . reauthenticate ( conn )
324+ await auth . invalidateCachedCredentials ( conn )
325+ await auth . invalidateCachedCredentials ( source )
326+
327+ await runExpiredConnectionFlow ( conn , / l o g i n / i)
328+ assert . strictEqual ( auth . getConnectionState ( source ) , 'valid' )
329+ assert . strictEqual ( auth . getConnectionState ( conn ) , 'valid' )
330+ } )
331+
332+ describe ( 'Multiple Connections' , function ( ) {
333+ const otherProfile = createBuilderIdProfile ( { scopes : ssoAccountAccessScopes } )
334+
335+ // Equivalent profiles from multiple sources is a fairly rare situation right now
336+ // Ideally they would be de-duped although the implementation can be tricky
337+ it ( 'can handle multiple SSO connection and does not de-dupe' , async function ( ) {
338+ await auth . createConnection ( linkedSsoProfile )
339+ await auth . createConnection ( otherProfile )
340+
341+ const connections = await auth . listAndTraverseConnections ( ) . promise ( )
342+ assert . deepStrictEqual (
343+ connections . map ( c => c . type ) ,
344+ [ 'sso' , 'sso' , 'iam' , 'iam' , 'iam' , 'iam' , 'iam' , 'iam' ] ,
345+ 'Expected two SSO connections and 3 IAM connections for each SSO connection'
346+ )
347+ } )
348+
349+ it ( 'does not stop discovery if one connection fails' , async function ( ) {
350+ const otherProfile = createBuilderIdProfile ( { scopes : ssoAccountAccessScopes } )
351+ await auth . createConnection ( linkedSsoProfile )
352+ await auth . createConnection ( otherProfile )
353+ auth . ssoClient . listAccounts . onFirstCall ( ) . rejects ( new Error ( 'No access' ) )
354+ const connections = await auth . listAndTraverseConnections ( ) . promise ( )
355+ assert . deepStrictEqual (
356+ connections . map ( c => c . type ) ,
357+ [ 'sso' , 'sso' , 'iam' , 'iam' , 'iam' ]
358+ )
359+ } )
360+ } )
361+ } )
362+
226363 describe ( 'AuthNode' , function ( ) {
227364 it ( 'shows a message to create a connection if no connections exist' , async function ( ) {
228365 const node = new AuthNode ( auth )
0 commit comments