Skip to content
Merged
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
2 changes: 1 addition & 1 deletion src/server/infra/transport/handlers/auth-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -223,7 +223,7 @@ export class AuthHandler {
return {isAuthorized: false}
}

const {projectPath} = data
const projectPath = data?.projectPath
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

note (non-blocking): The fix here is exactly right for ENG-2941. Flagging for a follow-up (already acknowledged in the PR description): the broad } catch { return {isAuthorized: false} } on lines 249–251 still converts any unexpected throw from userService.getCurrentUser (e.g. transient network error) into {isAuthorized: false}, which re-creates the exact "stuck on provider chooser" symptom this PR fixes — just on flaky networks instead of every startup. Worth narrowing the catch in a follow-up so token validity (the actual auth signal) is decoupled from user-info fetch failures. The new broadcastAuthStateChanged (lines 117–122) already does this — broadcasts {isAuthorized: true} without user details on getCurrentUser failure — so the pattern is already in this file.

const [user, brvConfig] = await Promise.all([
this.userService.getCurrentUser(token.sessionKey),
projectPath ? this.projectConfigStore.read(projectPath) : Promise.resolve(),
Expand Down
2 changes: 1 addition & 1 deletion src/shared/transport/events/auth-events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ export const AuthEvents = {
} as const

export interface AuthGetStateRequest {
projectPath: string
projectPath?: string
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion (non-blocking, if-minor): Widening projectPath: stringprojectPath?: string is the right call, but the field now has subtle semantics that aren't obvious from the type alone: omitting it returns a valid AuthGetStateResponse without brvConfig. Consider a one-line JSDoc so future callers don't get caught the way the TUI did in #653:

Suggested change
}
export interface AuthGetStateRequest {
/** Project-scoped lookup for `brvConfig` (space/team). Omit to skip the lookup and return auth-only state. */
projectPath?: string
}


export interface AuthGetStateResponse {
Expand Down
91 changes: 91 additions & 0 deletions test/unit/infra/transport/handlers/auth-handler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,39 @@ function createTestBrvConfig(): BrvConfig {

// ==================== Tests ====================

function makeValidTokenStoreFixture(): ITokenStore {
return {
clear: stub().resolves(),
load: stub().resolves(createValidToken()),
save: stub().resolves(),
} as unknown as ITokenStore
}

function makeMissingTokenStoreFixture(): ITokenStore {
return {
clear: stub().resolves(),
load: stub().resolves(),
save: stub().resolves(),
} as unknown as ITokenStore
}

function makeExpiredTokenStoreFixture(): ITokenStore {
const expired = new AuthToken({
accessToken: 'expired-access',
expiresAt: new Date(Date.now() - 60_000),
refreshToken: 'expired-refresh',
sessionKey: 'expired-session',
tokenType: 'Bearer',
userEmail: 'test@example.com',
userId: 'user-123',
})
return {
clear: stub().resolves(),
load: stub().resolves(expired),
save: stub().resolves(),
} as unknown as ITokenStore
}

function createMockProviderConfigStore(
options: {isConnected?: boolean} = {},
): SinonStubbedInstance<IProviderConfigStore> {
Expand Down Expand Up @@ -524,4 +557,62 @@ describe('AuthHandler — setupExternalAuthSync', () => {
.to.be.lessThan(callOrder.indexOf('LOGIN_COMPLETED'))
})
})

describe('setupGetState', () => {
it('returns isAuthorized=true and skips brvConfig when body is undefined (TUI sends no body)', async () => {
createHandler({tokenStore: makeValidTokenStoreFixture()})
const handler = transport._handlers.get(AuthEvents.GET_STATE)!


const result = await handler(undefined, 'client-1')

expect(result.isAuthorized).to.equal(true)
expect(result.user).to.deep.include({email: 'test@example.com', id: 'user-123'})
expect(result.brvConfig).to.equal(undefined)
expect(result.authToken).to.have.property('accessToken', 'test-access-token')
expect(projectConfigStore.read.called, 'projectConfigStore.read should not be called without projectPath').to.be
.false
})
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

praise: Good ENG-2941 regression guard. The expect(projectConfigStore.read.called, '...').to.be.false assertion (line 573–574) is the right thing to pin — it locks in both the new contract (projectPath is optional) and the runtime behavior (skip the project read when absent). The empty-object case below (line 588) and the projectPath-present case (line 577) cover the other branches cleanly. The five tests together are a solid spec for the handler.


it('returns full state including brvConfig when body has projectPath (WebUI happy path)', async () => {
createHandler({tokenStore: makeValidTokenStoreFixture()})
const handler = transport._handlers.get(AuthEvents.GET_STATE)!

const result = await handler({projectPath: '/foo'}, 'client-1')

expect(result.isAuthorized).to.equal(true)
expect(result.brvConfig).to.deep.include({spaceId: 'space-1', teamId: 'team-1'})
expect(projectConfigStore.read.calledOnceWith('/foo')).to.be.true
})

it('returns isAuthorized=true and skips brvConfig when body is empty object', async () => {
createHandler({tokenStore: makeValidTokenStoreFixture()})
const handler = transport._handlers.get(AuthEvents.GET_STATE)!

const result = await handler({}, 'client-1')

expect(result.isAuthorized).to.equal(true)
expect(result.brvConfig).to.equal(undefined)
expect(projectConfigStore.read.called).to.be.false
})

it('returns isAuthorized=false when token is missing, regardless of body', async () => {
createHandler({tokenStore: makeMissingTokenStoreFixture()})
const handler = transport._handlers.get(AuthEvents.GET_STATE)!


const result = await handler(undefined, 'client-1')

expect(result).to.deep.equal({isAuthorized: false})
})

it('returns isAuthorized=false when token is expired, regardless of body', async () => {
createHandler({tokenStore: makeExpiredTokenStoreFixture()})
const handler = transport._handlers.get(AuthEvents.GET_STATE)!

const result = await handler({projectPath: '/foo'}, 'client-1')

expect(result).to.deep.equal({isAuthorized: false})
})
})
})
Loading