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
3 changes: 1 addition & 2 deletions apps/admin-frontend/src/app/composition/container.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,8 @@ import {
GetCurrentSession,
Logout,
toTenantContext,
Tenant,
User,
} from '@/features/auth'
import { Tenant, User } from '@/test/fixtures/authEntityFixtures'
import { ListTags } from '@/features/catalog'

function stubOidcEnv(): void {
Expand Down
8 changes: 5 additions & 3 deletions apps/admin-frontend/src/app/layouts/AdminLayout.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,12 @@ import userEvent from '@testing-library/user-event'
import { MemoryRouter, Routes, Route } from 'react-router'
import { AdminLayout } from '@/app/layouts/AdminLayout'
import { AppContainerContext } from '@/app/providers/AppContainerContext'
import { AuthProvider, ProtectedRoute, Tenant, User, type TenantContext } from '@/features/auth'
import { AuthProvider, ProtectedRoute, type TenantContext } from '@/features/auth'
import { Tenant, User } from '@/test/fixtures/authEntityFixtures'
import { ThemeProvider } from '@/shared/presentation/providers/ThemeProvider'
import type { AppContainer } from '@/app/composition/container'
import { createFakeAppContainer } from '@/test/fixtures/createFakeAppContainer'
import { success } from '@/shared/application/Result'
import { vi } from 'vitest'

function buildTenantContext(): TenantContext {
Expand All @@ -24,7 +26,7 @@ function buildTenantContextWithoutName(): TenantContext {

function buildContainer(
tenantContext: TenantContext | null,
logoutFn = vi.fn(() => Promise.resolve()),
logoutFn = vi.fn(() => Promise.resolve(success(undefined))),
): AppContainer {
return createFakeAppContainer({
auth: {
Expand Down Expand Up @@ -95,7 +97,7 @@ describe('AdminLayout', () => {
})

it('calls the logout use case when the sign out button is clicked', async () => {
const logoutSpy = vi.fn(() => Promise.resolve())
const logoutSpy = vi.fn(() => Promise.resolve(success(undefined)))
renderLayout(buildContainer(buildTenantContext(), logoutSpy))

await userEvent.click(await screen.findByRole('button', { name: /sair/i }))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ export type AuthFlowErrorCode =
| 'AUTH_ATTEMPT_EXPIRED'
| 'AUTH_RESPONSE_INVALID'
| 'AUTH_ACCOUNT_WITHOUT_TENANT'
| 'AUTH_LOGOUT_FAILED'

interface AuthFlowErrorInput {
code: AppErrorCode
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import { Session } from '@/features/auth/domain/entities/Session'
import type { AuthFlowError } from '@/features/auth/application/errors/AuthFlowError'
import type { Result } from '@/shared/application/Result'

export interface AuthCallbackResult {
session: Session
Expand All @@ -8,15 +10,16 @@ export interface AuthCallbackResult {
export type LoginTheme = 'light' | 'dark'

export interface AuthRepository {
initiateLogin(returnTo: string, theme: LoginTheme): Promise<void>
initiateLogin(returnTo: string, theme: LoginTheme): Promise<Result<void, AuthFlowError>>

// callbackUrl is the full redirect-back URL (query/fragment included),
// kept as a plain string so this port has no routing-library dependency.
handleCallback(callbackUrl: string): Promise<AuthCallbackResult>
handleCallback(callbackUrl: string): Promise<Result<AuthCallbackResult, AuthFlowError>>

// Attempts a silent renewal first if the token is expired/near-expiry;
// null if there's no session or renewal failed (stale state is cleared).
// Never fails in a way a caller needs to distinguish - null already means
// "no usable session" whether that's because none exists, renewal
// failed, or the cached data was malformed. See docs/adr/014.
getCurrentSession(): Promise<Session | null>

logout(): Promise<void>
logout(): Promise<Result<void, AuthFlowError>>
}
Original file line number Diff line number Diff line change
@@ -1,11 +1,20 @@
import type { AuthRepository } from '@/features/auth/application/repositories/AuthRepository'
import { AuthFlowError } from '@/features/auth/application/errors/AuthFlowError'
import { failure, success } from '@/shared/application/Result'

const NOT_IMPLEMENTED = new AuthFlowError({
code: 'unexpected',
flowCode: 'AUTH_LOGIN_FAILED',
message: 'not implemented in this fake',
retryable: false,
})

export function createFakeAuthRepository(overrides: Partial<AuthRepository> = {}): AuthRepository {
return {
initiateLogin: () => Promise.resolve(),
handleCallback: () => Promise.reject(new Error('not implemented in this fake')),
initiateLogin: () => Promise.resolve(success(undefined)),
handleCallback: () => Promise.resolve(failure(NOT_IMPLEMENTED)),
getCurrentSession: () => Promise.resolve(null),
logout: () => Promise.resolve(),
logout: () => Promise.resolve(success(undefined)),
...overrides,
}
}
Original file line number Diff line number Diff line change
@@ -1,9 +1,7 @@
import { describe, it, expect } from 'vitest'
import { GetCurrentSession } from '@/features/auth/application/use-cases/GetCurrentSession'
import { createFakeAuthRepository } from '@/features/auth/application/test-helpers/createFakeAuthRepository'
import { Session } from '@/features/auth/domain/entities/Session'
import { User } from '@/features/auth/domain/entities/User'
import { Tenant } from '@/features/auth/domain/value-objects/Tenant'
import { Session, Tenant, User } from '@/test/fixtures/authEntityFixtures'

describe('GetCurrentSession', () => {
it('returns the tenant context when a valid session exists', async () => {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,15 +1,25 @@
import { describe, it, expect } from 'vitest'
import { HandleAuthCallback } from '@/features/auth/application/use-cases/HandleAuthCallback'
import { createFakeAuthRepository } from '@/features/auth/application/test-helpers/createFakeAuthRepository'
import { Session } from '@/features/auth/domain/entities/Session'
import { User } from '@/features/auth/domain/entities/User'
import { Tenant } from '@/features/auth/domain/value-objects/Tenant'
import { Session, Tenant, User } from '@/test/fixtures/authEntityFixtures'
import type { AuthCallbackResult } from '@/features/auth/application/repositories/AuthRepository'
import { AuthFlowError } from '@/features/auth/application/errors/AuthFlowError'
import { success, failure } from '@/shared/application/Result'

function callbackResult(session: Session, returnTo: string | null = null): AuthCallbackResult {
function callbackResult(
session: AuthCallbackResult['session'],
returnTo: string | null = null,
): AuthCallbackResult {
return { session, returnTo }
}

const TOKEN_EXCHANGE_FAILED = new AuthFlowError({
code: 'unauthenticated',
flowCode: 'AUTH_ATTEMPT_EXPIRED',
message: 'invalid_grant: code expired',
retryable: true,
})

describe('HandleAuthCallback', () => {
it('returns the tenant context when the callback is handled successfully', async () => {
const tenant = Tenant.create('tenant-123')
Expand All @@ -21,17 +31,19 @@ describe('HandleAuthCallback', () => {
})

const authRepository = createFakeAuthRepository({
handleCallback: () => Promise.resolve(callbackResult(session)),
handleCallback: () => Promise.resolve(success(callbackResult(session))),
})

const handleAuthCallback = new HandleAuthCallback(authRepository)
const result = await handleAuthCallback.execute(
'https://admin.example.com/callback?code=abc123&state=xyz',
)

expect(result.tenantContext.tenant.equals(tenant)).toBe(true)
expect(result.tenantContext.user).toBe(user)
expect(result.returnTo).toBe('/dashboard')
expect(result.success).toBe(true)
if (!result.success) return
expect(result.value.tenantContext.tenant.equals(tenant)).toBe(true)
expect(result.value.tenantContext.user).toBe(user)
expect(result.value.returnTo).toBe('/dashboard')
})

it('passes the callback URL through to the repository unchanged', async () => {
Expand All @@ -47,7 +59,7 @@ describe('HandleAuthCallback', () => {
const authRepository = createFakeAuthRepository({
handleCallback: url => {
receivedUrl = url
return Promise.resolve(callbackResult(session))
return Promise.resolve(success(callbackResult(session)))
},
})

Expand All @@ -60,14 +72,18 @@ describe('HandleAuthCallback', () => {

it('propagates the error when token exchange fails', async () => {
const authRepository = createFakeAuthRepository({
handleCallback: () => Promise.reject(new Error('invalid_grant: code expired')),
handleCallback: () => Promise.resolve(failure(TOKEN_EXCHANGE_FAILED)),
})

const handleAuthCallback = new HandleAuthCallback(authRepository)

await expect(
handleAuthCallback.execute('https://admin.example.com/callback?error=access_denied'),
).rejects.toThrow('invalid_grant: code expired')
const result = await handleAuthCallback.execute(
'https://admin.example.com/callback?error=access_denied',
)

expect(result.success).toBe(false)
if (result.success) return
expect(result.error.message).toBe('invalid_grant: code expired')
})

it('exchanges the code only once for two concurrent calls with the same callback URL', async () => {
Expand All @@ -82,7 +98,7 @@ describe('HandleAuthCallback', () => {
const authRepository = createFakeAuthRepository({
handleCallback: () => {
callCount += 1
return Promise.resolve(callbackResult(session))
return Promise.resolve(success(callbackResult(session)))
},
})

Expand Down Expand Up @@ -112,7 +128,7 @@ describe('HandleAuthCallback', () => {
const authRepository = createFakeAuthRepository({
handleCallback: () => {
callCount += 1
return Promise.resolve(callbackResult(session))
return Promise.resolve(success(callbackResult(session)))
},
})

Expand All @@ -125,26 +141,26 @@ describe('HandleAuthCallback', () => {
expect(callCount).toBe(1)
})

it('propagates the same rejection to every caller sharing a failed callback URL', async () => {
it('propagates the same failure to every caller sharing a failed callback URL', async () => {
let callCount = 0
const authRepository = createFakeAuthRepository({
handleCallback: () => {
callCount += 1
return Promise.reject(new Error('invalid_grant: code expired'))
return Promise.resolve(failure(TOKEN_EXCHANGE_FAILED))
},
})

const handleAuthCallback = new HandleAuthCallback(authRepository)
const callbackUrl = 'https://admin.example.com/callback?code=abc123&state=xyz'

const [firstResult, secondResult] = await Promise.allSettled([
const [firstResult, secondResult] = await Promise.all([
handleAuthCallback.execute(callbackUrl),
handleAuthCallback.execute(callbackUrl),
])

expect(callCount).toBe(1)
expect(firstResult.status).toBe('rejected')
expect(secondResult.status).toBe('rejected')
expect(firstResult.success).toBe(false)
expect(secondResult.success).toBe(false)
})

it('exchanges the code again for a different callback URL (a fresh login)', async () => {
Expand All @@ -159,7 +175,7 @@ describe('HandleAuthCallback', () => {
const authRepository = createFakeAuthRepository({
handleCallback: () => {
callCount += 1
return Promise.resolve(callbackResult(session))
return Promise.resolve(success(callbackResult(session)))
},
})

Expand All @@ -181,14 +197,16 @@ describe('HandleAuthCallback', () => {
})
const authRepository = createFakeAuthRepository({
handleCallback: () =>
Promise.resolve(callbackResult(session, '/services?search=massagem#editor')),
Promise.resolve(success(callbackResult(session, '/services?search=massagem#editor'))),
})

const result = await new HandleAuthCallback(authRepository).execute(
'https://admin.example.com/callback?code=abc',
)

expect(result.returnTo).toBe('/services?search=massagem#editor')
expect(result.success).toBe(true)
if (!result.success) return
expect(result.value.returnTo).toBe('/services?search=massagem#editor')
})

it('falls back to the dashboard when callback state contains an external URL', async () => {
Expand All @@ -201,13 +219,15 @@ describe('HandleAuthCallback', () => {
})
const authRepository = createFakeAuthRepository({
handleCallback: () =>
Promise.resolve(callbackResult(session, 'https://evil.example/steal-session')),
Promise.resolve(success(callbackResult(session, 'https://evil.example/steal-session'))),
})

const result = await new HandleAuthCallback(authRepository).execute(
'https://admin.example.com/callback?code=abc',
)

expect(result.returnTo).toBe('/dashboard')
expect(result.success).toBe(true)
if (!result.success) return
expect(result.value.returnTo).toBe('/dashboard')
})
})
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import type { AuthRepository } from '@/features/auth/application/repositories/AuthRepository'
import type { AuthFlowError } from '@/features/auth/application/errors/AuthFlowError'
import {
toTenantContext,
type TenantContext,
} from '@/features/auth/application/context/TenantContext'
import { resolvePostLoginPath } from '@/features/auth/application/navigation/postLoginPath'
import { success, type Result } from '@/shared/application/Result'

export interface CompletedAuthCallback {
tenantContext: TenantContext
Expand All @@ -12,7 +14,7 @@ export interface CompletedAuthCallback {

interface CachedCallback {
url: string
promise: Promise<CompletedAuthCallback>
promise: Promise<Result<CompletedAuthCallback, AuthFlowError>>
}

// Single-flight per callback URL: an OAuth code is single-use, and
Expand All @@ -26,20 +28,25 @@ export class HandleAuthCallback {
this.authRepository = authRepository
}

async execute(callbackUrl: string): Promise<CompletedAuthCallback> {
async execute(callbackUrl: string): Promise<Result<CompletedAuthCallback, AuthFlowError>> {
if (this.cached?.url !== callbackUrl) {
this.cached = { url: callbackUrl, promise: this.performCallback(callbackUrl) }
}

return this.cached.promise
}

private async performCallback(callbackUrl: string): Promise<CompletedAuthCallback> {
const { session, returnTo } = await this.authRepository.handleCallback(callbackUrl)

return {
tenantContext: toTenantContext(session.user),
returnTo: resolvePostLoginPath(returnTo),
private async performCallback(
callbackUrl: string,
): Promise<Result<CompletedAuthCallback, AuthFlowError>> {
const result = await this.authRepository.handleCallback(callbackUrl)
if (!result.success) {
return result
}

return success({
tenantContext: toTenantContext(result.value.session.user),
returnTo: resolvePostLoginPath(result.value.returnTo),
})
Comment on lines +31 to +50

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟠 Major | 🏗️ Heavy lift

Keep the established frontend error contract.

This migration introduces a second error-handling model in apps/admin-frontend. Existing frontend flows use typed thrown errors and presentation-layer error mapping. The Result contract also requires temporary Catalog compatibility adapters, which confirms the contract split.

  • apps/admin-frontend/src/features/auth/application/use-cases/HandleAuthCallback.ts#L31-L50: restore the throw-and-catch callback contract with typed AuthFlowError failures.
  • apps/admin-frontend/src/features/auth/application/use-cases/Logout.ts#L12-L13: restore the Promise<void> use-case contract and propagate typed failures through exceptions.
  • apps/admin-frontend/src/features/auth/presentation/AuthContext.ts#L15-L17: expose the existing action contracts instead of Result unions.
  • apps/admin-frontend/src/features/auth/presentation/CallbackPage/CallbackPage.tsx#L17-L21: restore error catching and toAuthFlowFeedback mapping.
  • apps/admin-frontend/src/features/auth/application/test-helpers/createFakeAuthRepository.ts#L14-L17: update fake defaults to match the restored repository contract.

Based on learnings, apps/admin-frontend must use the established throw-and-catch convention for domain and HTTP failures and must not apply the backend-only Result policy to frontend code.

📍 Affects 5 files
  • apps/admin-frontend/src/features/auth/application/use-cases/HandleAuthCallback.ts#L31-L50 (this comment)
  • apps/admin-frontend/src/features/auth/application/use-cases/Logout.ts#L12-L13
  • apps/admin-frontend/src/features/auth/presentation/AuthContext.ts#L15-L17
  • apps/admin-frontend/src/features/auth/presentation/CallbackPage/CallbackPage.tsx#L17-L21
  • apps/admin-frontend/src/features/auth/application/test-helpers/createFakeAuthRepository.ts#L14-L17
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@apps/admin-frontend/src/features/auth/application/use-cases/HandleAuthCallback.ts`
around lines 31 - 50, Restore the established throw-and-catch frontend error
contract across the auth flow: in
apps/admin-frontend/src/features/auth/application/use-cases/HandleAuthCallback.ts:31-50,
throw typed AuthFlowError failures instead of returning Result unions; in
apps/admin-frontend/src/features/auth/application/use-cases/Logout.ts:12-13,
restore Promise<void> and propagate typed exceptions; in
apps/admin-frontend/src/features/auth/presentation/AuthContext.ts:15-17, expose
the existing action contracts; in
apps/admin-frontend/src/features/auth/presentation/CallbackPage/CallbackPage.tsx:17-21,
catch failures and map them with toAuthFlowFeedback; and in
apps/admin-frontend/src/features/auth/application/test-helpers/createFakeAuthRepository.ts:14-17,
align fake defaults with the restored repository contract.

Source: Learnings

}
}
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import { describe, it, expect, vi } from 'vitest'
import { InitiateLogin } from '@/features/auth/application/use-cases/InitiateLogin'
import { createFakeAuthRepository } from '@/features/auth/application/test-helpers/createFakeAuthRepository'
import { success } from '@/shared/application/Result'

describe('InitiateLogin', () => {
it('delegates with the page that should be restored after login', async () => {
const initiateLoginSpy = vi.fn(() => Promise.resolve())
const initiateLoginSpy = vi.fn(() => Promise.resolve(success(undefined)))
const authRepository = createFakeAuthRepository({ initiateLogin: initiateLoginSpy })

const initiateLogin = new InitiateLogin(authRepository)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ import type {
AuthRepository,
LoginTheme,
} from '@/features/auth/application/repositories/AuthRepository'
import type { AuthFlowError } from '@/features/auth/application/errors/AuthFlowError'
import type { Result } from '@/shared/application/Result'
import { resolvePostLoginPath } from '@/features/auth/application/navigation/postLoginPath'

export class InitiateLogin {
Expand All @@ -11,7 +13,10 @@ export class InitiateLogin {
this.authRepository = authRepository
}

async execute(returnTo: string | undefined, theme: LoginTheme): Promise<void> {
await this.authRepository.initiateLogin(resolvePostLoginPath(returnTo), theme)
async execute(
returnTo: string | undefined,
theme: LoginTheme,
): Promise<Result<void, AuthFlowError>> {
return this.authRepository.initiateLogin(resolvePostLoginPath(returnTo), theme)
}
}
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import { describe, it, expect, vi } from 'vitest'
import { Logout } from '@/features/auth/application/use-cases/Logout'
import { createFakeAuthRepository } from '@/features/auth/application/test-helpers/createFakeAuthRepository'
import { success } from '@/shared/application/Result'

describe('Logout', () => {
it('delegates to the auth repository to clear the session and end the provider session', async () => {
const logoutSpy = vi.fn(() => Promise.resolve())
const logoutSpy = vi.fn(() => Promise.resolve(success(undefined)))
const authRepository = createFakeAuthRepository({ logout: logoutSpy })

const logout = new Logout(authRepository)
Expand Down
Loading
Loading