Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ import { TestFeatures } from '@aws/language-server-runtimes/testing'
import { deepStrictEqual } from 'assert'
import sinon from 'ts-sinon'
import { AmazonQIAMServiceManager } from './AmazonQIAMServiceManager'
import { AmazonQServicePendingSigninError } from './errors'
import { CodeWhispererServiceIAM } from '../codeWhispererService'
import { generateSingletonInitializationTests } from './testUtils'
import * as utils from '../utils'

Expand All @@ -23,6 +25,10 @@ describe('AmazonQIAMServiceManager', () => {
'updateCachedServiceConfig' as keyof AmazonQIAMServiceManager
)

// Default: IAM credentials are present. Individual tests override this to exercise
// the missing-credentials path.
features.credentialsProvider.hasCredentials.withArgs('iam').returns(true)

AmazonQIAMServiceManager.resetInstance()
serviceManager = AmazonQIAMServiceManager.initInstance(features)
})
Expand Down Expand Up @@ -63,17 +69,35 @@ describe('AmazonQIAMServiceManager', () => {
serviceManager.getCodewhispererService()
throw new Error('Expected error was not thrown')
} catch (error) {
deepStrictEqual(error instanceof AmazonQServicePendingSigninError, true)
deepStrictEqual((error as Error).message.includes('No IAM credentials available'), true)
}
})

it('should validate credentials before creating service', () => {
const hasCredentialsSpy = sinon.spy(features.credentialsProvider, 'hasCredentials')
it('should not create or cache a service while IAM credentials are missing', () => {
features.credentialsProvider.hasCredentials.withArgs('iam').returns(false)

try {
serviceManager.getCodewhispererService()
} catch {
// expected
}

sinon.assert.notCalled(updateCachedServiceConfigSpy)

// Once credentials arrive the service is created normally
features.credentialsProvider.hasCredentials.withArgs('iam').returns(true)
const service = serviceManager.getCodewhispererService()
deepStrictEqual(service instanceof CodeWhispererServiceIAM, true)
sinon.assert.calledOnce(updateCachedServiceConfigSpy)
})

it('should validate credentials before creating service', () => {
// hasCredentials is already a stub on TestFeatures; assert on it directly
features.credentialsProvider.hasCredentials.withArgs('iam').returns(true)
serviceManager.getCodewhispererService()

sinon.assert.calledWith(hasCredentialsSpy, 'iam')
sinon.assert.calledWith(features.credentialsProvider.hasCredentials, 'iam')
})

it('should return correct credential validation status', () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,11 @@ import {
} from './BaseAmazonQServiceManager'
import { getAmazonQRegionAndEndpoint } from './configurationUtils'
import { StreamingClientServiceIAM } from '../streamingClientService'
import { AmazonQServiceAlreadyInitializedError, AmazonQServiceInitializationError } from './errors'
import {
AmazonQServiceAlreadyInitializedError,
AmazonQServiceInitializationError,
AmazonQServicePendingSigninError,
} from './errors'
import {
CancellationToken,
CredentialsType,
Expand Down Expand Up @@ -52,6 +56,13 @@ export class AmazonQIAMServiceManager extends BaseAmazonQServiceManager<
}

public getCodewhispererService() {
// Mirror the token-based manager: do not hand out a service that cannot authenticate.
// Without this, the SDK credential callback dereferences `undefined` and every
// inline-completion trigger fails with a bare TypeError until credentials arrive.
if (!this.hasValidCredentials()) {
throw new AmazonQServicePendingSigninError('No IAM credentials available')
}

if (!this.cachedCodewhispererService) {
this.cachedCodewhispererService = new CodeWhispererServiceIAM(
this.features.credentialsProvider,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
CancellationToken,
InlineCompletionWithReferencesParams,
} from '@aws/language-server-runtimes/server-interface'
import { AwsCredentialIdentity } from '@aws-sdk/types'
import * as sinon from 'sinon'
import * as assert from 'assert'
import {
Expand Down Expand Up @@ -214,6 +215,96 @@ describe('CodeWhispererService', function () {
})
})

describe('credentials provider callback', function () {
// Re-create the service with a stub that captures the SDK client options so the
// `credentials` function handed to the SigV4 client can be exercised directly.
let capturedCredentialsFn: () => Promise<AwsCredentialIdentity>

beforeEach(function () {
const createClientStub = require('../client/sigv4/codewhisperer')
.createCodeWhispererSigv4Client as sinon.SinonStub
createClientStub.callsFake((options: any) => {
capturedCredentialsFn = options.credentials
return { send: sandbox.stub(), middlewareStack: { add: sandbox.stub() } }
})
service = new CodeWhispererServiceIAM(
mockCredentialsProvider as any,
{} as any,
mockLogging as any,
'us-east-1',
'https://codewhisperer.us-east-1.amazonaws.com',
mockSDKInitializator as any
)
})

it('should throw a clear authorization error when IAM credentials are not set', async function () {
mockCredentialsProvider.getCredentials.withArgs('iam').returns(undefined)

await assert.rejects(
() => capturedCredentialsFn(),
(err: unknown) =>
err instanceof Error &&
!(err instanceof TypeError) &&
err.message === 'Authorization failed, IAM credentials are not set'
)
})

it('should throw a clear authorization error when IAM credentials are incomplete', async function () {
// deliberately incomplete credentials object
mockCredentialsProvider.getCredentials.withArgs('iam').returns({ accessKeyId: 'AKIA' } as any)

await assert.rejects(
() => capturedCredentialsFn(),
(err: unknown) => err instanceof Error && !(err instanceof TypeError)
)
})

it('should convert a string expiration into a Date so the SDK can call getTime()', async function () {
// Credentials reach the server over JSON, so Date fields arrive as ISO strings.
const iso = new Date(Date.now() + 3600 * 1000).toISOString()
mockCredentialsProvider.getCredentials.withArgs('iam').returns({
accessKeyId: 'AKIA',
secretAccessKey: 'secret',
sessionToken: 'token',
expiration: iso,
} as any)

const identity = await capturedCredentialsFn()
assert.ok(identity.expiration instanceof Date, 'expiration must be a Date instance')
assert.strictEqual(identity.expiration!.toISOString(), iso)
// This is exactly what @smithy/core does when deciding whether to refresh.
assert.doesNotThrow(() => identity.expiration!.getTime())
})

it('should leave expiration undefined when the credentials have none', async function () {
mockCredentialsProvider.getCredentials.withArgs('iam').returns({
accessKeyId: 'AKIA',
secretAccessKey: 'secret',
sessionToken: 'token',
} as any)

const identity = await capturedCredentialsFn()
assert.strictEqual(identity.expiration, undefined)
})

it('should return the IAM credentials when they are set', async function () {
const expiration = new Date()
mockCredentialsProvider.getCredentials.withArgs('iam').returns({
accessKeyId: 'AKIA',
secretAccessKey: 'secret',
sessionToken: 'token',
expiration,
})

assert.deepStrictEqual(await capturedCredentialsFn(), {
accessKeyId: 'AKIA',
secretAccessKey: 'secret',
sessionToken: 'token',
expiration,
})
})
})

describe('generateSuggestions', function () {
it('should call client.generateRecommendations and process response', async function () {
const mockRequest: GenerateSuggestionsRequest = {
Expand Down
12 changes: 10 additions & 2 deletions server/aws-lsp-codewhisperer/src/shared/codeWhispererService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -310,14 +310,22 @@ export class CodeWhispererServiceIAM extends CodeWhispererServiceBase {
logging.info('CodeWhispererService IAM: Attempting to get credentials')

try {
const creds = credentialsProvider.getCredentials('iam') as AwsCredentialIdentity
const creds = credentialsProvider.getCredentials('iam') as AwsCredentialIdentity | undefined
if (!creds?.accessKeyId || !creds.secretAccessKey) {
// Same contract as the bearer-token provider: fail with a clear auth error
// instead of a TypeError from dereferencing missing credentials.
throw new Error('Authorization failed, IAM credentials are not set')
}
logging.info('CodeWhispererService IAM: Successfully got credentials')

return {
accessKeyId: creds.accessKeyId,
secretAccessKey: creds.secretAccessKey,
sessionToken: creds.sessionToken,
expiration: creds.expiration,
// Credentials are pushed to the server over JSON, so `expiration` arrives as an
// ISO string. The SDK calls `expiration.getTime()` when deciding whether to
// refresh, so it must be a real Date (see StreamingClientServiceIAM for the same).
expiration: creds.expiration ? new Date(creds.expiration) : undefined,
}
} catch (err) {
if (err instanceof Error) {
Expand Down
Loading