Skip to content

Commit ca12150

Browse files
committed
Degraded auth
1 parent 4ae25f0 commit ca12150

12 files changed

Lines changed: 306 additions & 27 deletions

File tree

app/common/src/text/english.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -515,6 +515,9 @@
515515
"cloudUnavailableOffline": "Enso Cloud is unavailable offline.",
516516
"cloudUnavailableOfflineDescription": "Please connect to the internet to access the cloud.",
517517
"cloudUnavailableOfflineDescriptionOfferLocal": "Alternatively, you can work on your local projects.",
518+
"cloudDataUnavailable": "Enso Cloud is temporarily unavailable.",
519+
"cloudDataUnavailableTitle": "Enso Cloud is unavailable",
520+
"cloudDataUnavailableSubtitle": "Cannot reach Enso Cloud. Retry or work on local projects.",
518521
"loginUnavailableOffline": "Login functionality is unavailable offline. Please connect to the internet to log in.",
519522
"loginUnavailableOfflineLocal": "After logging in, you can work offline with your local projects.",
520523
"productionOnlyFeatures": "Production-only features",
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
/** @file Verify the degraded-auth mode triggered when `users/me` fails for a non-auth reason. */
2+
import { expect, test } from 'integration-test/base'
3+
4+
import { TEXT } from '../actions'
5+
6+
const HTTP_INTERNAL_SERVER_ERROR = 500
7+
const HTTP_UNAUTHORIZED = 401
8+
9+
// Sign in with fresh storage state so every test exercises the post-login flow.
10+
test.use({ storageState: { cookies: [], origins: [] } })
11+
12+
test('cloud 500 lands in degraded mode and switching to local works', async ({
13+
loginPage,
14+
cloudApi,
15+
}) => {
16+
cloudApi.setUsersMeFailureStatus(HTTP_INTERNAL_SERVER_ERROR)
17+
18+
await loginPage
19+
.login()
20+
.do(async (page) => {
21+
await expect(page.getByTestId('cloud-unavailable-stub')).toBeVisible({ timeout: 15_000 })
22+
await expect(
23+
page.getByRole('button', { name: TEXT.retry, exact: true }),
24+
).toBeVisible()
25+
await expect(
26+
page.getByRole('button', { name: TEXT.switchToLocal, exact: true }),
27+
).toBeVisible()
28+
// The cloud sidebar entry is still rendered but the button is disabled.
29+
await expect(
30+
page.getByRole('button', { name: TEXT.cloudCategory }).first(),
31+
).toBeDisabled()
32+
})
33+
.goToCategory.local()
34+
.withDriveView(async (driveView) => {
35+
await expect(driveView).toBeVisible()
36+
})
37+
})
38+
39+
test('retry exits degraded mode once the backend recovers', async ({ loginPage, cloudApi }) => {
40+
cloudApi.setUsersMeFailureStatus(HTTP_INTERNAL_SERVER_ERROR)
41+
42+
await loginPage
43+
.login()
44+
.do(async (page) => {
45+
await expect(page.getByTestId('cloud-unavailable-stub')).toBeVisible({ timeout: 15_000 })
46+
cloudApi.setUsersMeFailureStatus(null)
47+
await page.getByRole('button', { name: TEXT.retry, exact: true }).click()
48+
await expect(page.getByTestId('cloud-unavailable-stub')).toBeHidden({ timeout: 15_000 })
49+
await expect(page.getByTestId('drive-view')).toBeVisible()
50+
})
51+
})
52+
53+
test('401 keeps the existing recovery + logout path, no degraded UI', async ({
54+
loginPage,
55+
cloudApi,
56+
}) => {
57+
cloudApi.setUsersMeFailureStatus(HTTP_UNAUTHORIZED)
58+
59+
await loginPage.login().do(async (page) => {
60+
// The unauthorized-recovery flow eventually logs the user out, which makes the
61+
// login form visible again. The degraded `cloud-unavailable` stub must never show.
62+
await expect(
63+
page.getByRole('button', { name: TEXT.login, exact: true }),
64+
).toBeVisible({ timeout: 30_000 })
65+
await expect(page.getByTestId('cloud-unavailable-stub')).toHaveCount(0)
66+
})
67+
})

app/gui/integration-test/mock/cloudApi.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -188,6 +188,12 @@ export async function mockCloudApi(page: Page) {
188188
let currentPassword = defaultPassword
189189
let currentOrganization: backend.OrganizationInfo | null = defaultOrganization
190190
let currentOrganizationProfilePicture: string | null = null
191+
/**
192+
* When non-null, the `users/me` endpoint responds with this HTTP status instead of the
193+
* normal user payload. Used by integration tests to simulate cloud-data-unavailable
194+
* (5xx) or unauthorized (401) flows.
195+
*/
196+
let usersMeFailureStatus: number | null = null
191197

192198
const assetMap = new Map<backend.AssetId, backend.AnyAsset>()
193199
const deletedAssets = new Set<backend.AssetId>()
@@ -1294,6 +1300,9 @@ export async function mockCloudApi(page: Page) {
12941300
})
12951301
await get(paths.USERS_ME_PATH, (route) => {
12961302
called('usersMe', {})
1303+
if (usersMeFailureStatus != null) {
1304+
return route.fulfill({ status: usersMeFailureStatus })
1305+
}
12971306
if (currentUser == null) {
12981307
return route.fulfill({ status: HTTP_STATUS_NOT_FOUND })
12991308
}
@@ -1444,6 +1453,14 @@ export async function mockCloudApi(page: Page) {
14441453
goOnline: () => {
14451454
isOnline = true
14461455
},
1456+
/**
1457+
* Force `users/me` to respond with the given HTTP status. Pass `null` to restore the
1458+
* default behavior. Use 500 to exercise the degraded-auth flow; use 401 to drive the
1459+
* unauthorized-recovery flow.
1460+
*/
1461+
setUsersMeFailureStatus: (status: number | null) => {
1462+
usersMeFailureStatus = status
1463+
},
14471464
setPlan: (plan: backend.Plan) => {
14481465
if (currentUser) {
14491466
object.unsafeMutable(currentUser).plan = plan

app/gui/src/components/AppContainer/LeftPanel.vue

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,8 @@ const driveToolbarReact: React.MutableRefObject<HTMLElement | null> = { current:
5555
const cloudDisabledReason = computed(() => {
5656
if (!isOnline.value) {
5757
return getText('unavailableOffline')
58+
} else if (auth.session?.isCloudDataUnavailable) {
59+
return getText('cloudDataUnavailable')
5860
} else if (!auth.session?.user.isEnabled) {
5961
return getText('notEnabledSubtitle')
6062
} else {

app/gui/src/components/AppContainerLayout.vue

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -60,9 +60,16 @@ export const dataLoader: DataLoader<Props> = {
6060
}
6161
6262
const needsOrganizationSetup = PLANS_TO_SPECIFY_ORG_NAME.includes(plan)
63-
64-
const organizationQuery = useQuery(backendQueryOptions('getOrganization', [], backend))
65-
await waitForData(organizationQuery)
63+
const cloudDataUnavailable = computed(() => auth.session?.isCloudDataUnavailable ?? false)
64+
65+
const organizationQuery = useQuery({
66+
...backendQueryOptions('getOrganization', [], backend),
67+
// In degraded-auth mode the backend would reject this with a 5xx/network error;
68+
// leave the query idle so cloud-dependent modals stay hidden. Reactive so the
69+
// query auto-fires once `users/me` recovers without re-running the data loader.
70+
enabled: computed(() => !cloudDataUnavailable.value),
71+
})
72+
if (!cloudDataUnavailable.value) await waitForData(organizationQuery)
6673
6774
const acceptInvitationModalProps = computed(() => (invitation ? { invitation } : undefined))
6875

app/gui/src/dashboard/layouts/Drive.tsx

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,9 @@ export const Drive = React.memo(function Drive(props: DriveProperties) {
4949
function DriveInner(props: DriveProperties) {
5050
const { isOffline } = offlineHooks.useOffline()
5151
const toastAndLog = toastAndLogHooks.useToastAndLog()
52-
const { user } = authProvider.useFullUserSession()
52+
const session = authProvider.useFullUserSession()
53+
const { user } = session
54+
const auth = authProvider.useAuth()
5355
const { localBackend } = useBackends()
5456
const { getText } = useText()
5557
const [category, setCategory] = useDriveCurrentCategory()
@@ -58,13 +60,41 @@ function DriveInner(props: DriveProperties) {
5860
const isCloud = isCloudCategory(category)
5961

6062
const supportLocalBackend = localBackend != null
63+
const switchToLocal = useEventCallback(() => {
64+
setCategory({ type: 'local' })
65+
})
66+
const retryUsersMe = useEventCallback(() => {
67+
void auth.refetchSession()
68+
})
6169

6270
const status =
6371
isCloud && isOffline ? 'offline'
72+
: isCloud && session.isCloudDataUnavailable ? 'cloud-unavailable'
6473
: isCloud && !user.isEnabled ? 'not-enabled'
6574
: 'ok'
6675

6776
switch (status) {
77+
case 'cloud-unavailable': {
78+
return (
79+
<result.Result
80+
status="error"
81+
title={getText('cloudDataUnavailableTitle')}
82+
testId="cloud-unavailable-stub"
83+
subtitle={getText('cloudDataUnavailableSubtitle')}
84+
>
85+
<Button.Group align="center">
86+
<Button variant="primary" size="medium" onPress={retryUsersMe}>
87+
{getText('retry')}
88+
</Button>
89+
{supportLocalBackend && (
90+
<Button size="medium" variant="outline" onPress={switchToLocal}>
91+
{getText('switchToLocal')}
92+
</Button>
93+
)}
94+
</Button.Group>
95+
</result.Result>
96+
)
97+
}
6898
case 'not-enabled': {
6999
return (
70100
<result.Result

app/gui/src/dashboard/layouts/Settings/Settings.tsx

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,6 @@ import { includesPredicate } from '$/utils/data/array'
2121
import { useQuery, useQueryClient } from '@tanstack/react-query'
2222
import * as React from 'react'
2323
import {
24-
ALL_SETTINGS_TABS,
2524
SETTINGS_DATA,
2625
SETTINGS_NO_RESULTS_SECTION_DATA,
2726
SETTINGS_TAB_DATA,
@@ -42,14 +41,18 @@ export function Settings() {
4241
SettingsTabType.account,
4342
includesPredicate(Object.values(SettingsTabType)),
4443
)
45-
const { user, accessToken } = useFullUserSession()
44+
const session = useFullUserSession()
45+
const { user, accessToken } = session
46+
const isCloudDataUnavailable = session.isCloudDataUnavailable ?? false
4647
const { changePassword } = useSession()
4748
const { getText } = useText()
4849
const toastAndLog = useToastAndLog()
4950
const [query, setQuery] = React.useState('')
5051
const root = usePortalContext()
5152
const { data: organization = null } = useQuery(
52-
backendQueryOptions(backend, 'getOrganization', []),
53+
backendQueryOptions(backend, 'getOrganization', [], {
54+
enabled: !isCloudDataUnavailable,
55+
}),
5356
)
5457
const isQueryBlank = !/\S/.test(query)
5558
const [preferredTimeZone, setPreferredTimeZone] = useLocalStorageState('preferredTimeZone')
@@ -85,6 +88,7 @@ export function Settings() {
8588
changePassword,
8689
preferredTimeZone,
8790
setPreferredTimeZone,
91+
isCloudDataUnavailable,
8892
}),
8993
[
9094
accessToken,
@@ -103,6 +107,7 @@ export function Settings() {
103107
changePassword,
104108
preferredTimeZone,
105109
setPreferredTimeZone,
110+
isCloudDataUnavailable,
106111
],
107112
)
108113

@@ -129,11 +134,16 @@ export function Settings() {
129134
)
130135

131136
const tabsToShow = React.useMemo<readonly SettingsTabType[]>(() => {
137+
const matchesVisibility = (tabData: SettingsTabData) =>
138+
tabData.visible == null || tabData.visible(context)
132139
if (isQueryBlank) {
133-
return ALL_SETTINGS_TABS
140+
return SETTINGS_DATA.flatMap((tabSection) =>
141+
tabSection.tabs.filter(matchesVisibility).map((tabData) => tabData.settingsTab),
142+
)
134143
} else {
135144
return SETTINGS_DATA.flatMap((tabSection) =>
136145
tabSection.tabs
146+
.filter(matchesVisibility)
137147
.filter((tabData) =>
138148
isMatch(getText(tabData.nameId)) || isMatch(getText(tabSection.nameId)) ?
139149
true
@@ -144,7 +154,7 @@ export function Settings() {
144154
.map((tabData) => tabData.settingsTab),
145155
)
146156
}
147-
}, [isQueryBlank, doesEntryMatchQuery, getText, isMatch])
157+
}, [isQueryBlank, doesEntryMatchQuery, getText, isMatch, context])
148158
const effectiveTab = tabsToShow.includes(tab) ? tab : (tabsToShow[0] ?? SettingsTabType.account)
149159

150160
const data = React.useMemo<SettingsTabData>(() => {

app/gui/src/dashboard/layouts/Settings/data.tsx

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,7 @@ export const SETTINGS_TAB_DATA: Readonly<Record<SettingsTabType, SettingsTabData
7070
nameId: 'accountSettingsTab',
7171
settingsTab: SettingsTabType.account,
7272
icon: 'settings',
73+
visible: ({ isCloudDataUnavailable }) => !isCloudDataUnavailable,
7374
sections: [
7475
{
7576
nameId: 'userAccountSettingsSection',
@@ -558,6 +559,7 @@ export const SETTINGS_TAB_DATA: Readonly<Record<SettingsTabType, SettingsTabData
558559
nameId: 'apiKeysSettingsTab',
559560
settingsTab: SettingsTabType.apiKeys,
560561
icon: 'key',
562+
visible: ({ isCloudDataUnavailable }) => !isCloudDataUnavailable,
561563
sections: [
562564
{
563565
nameId: 'apiKeysSettingsSection',
@@ -651,6 +653,12 @@ export interface SettingsContext {
651653
readonly changePassword: (oldPassword: string, newPassword: string) => Promise<boolean>
652654
readonly preferredTimeZone: string | undefined
653655
readonly setPreferredTimeZone: (preferredTimeZone: string | undefined) => void
656+
/**
657+
* `true` when running in degraded-auth mode — the `user`/`organization` data is a
658+
* placeholder because the Enso Cloud `users/me` call failed. Tabs that depend on the
659+
* real cloud profile should hide themselves.
660+
*/
661+
readonly isCloudDataUnavailable: boolean
654662
}
655663

656664
/**

app/gui/src/dashboard/pages/dashboard/Dashboard.tsx

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -52,10 +52,15 @@ export function Dashboard() {
5252
const { remoteBackend, localBackend } = useBackends()
5353
const inputBindings = inputBindingsProvider.useInputBindings()
5454
const { router } = useRouter()
55+
const session = useFullUserSession()
56+
const { user } = session
5557
const { data: organization = null } = useQuery(
56-
backendQueryOptions(remoteBackend, 'getOrganization', []),
58+
backendQueryOptions(remoteBackend, 'getOrganization', [], {
59+
// In degraded-auth mode the remote `getOrganization` would error; keep the query
60+
// idle and let the existing `organization === null` fallback render the dashboard.
61+
enabled: !session.isCloudDataUnavailable,
62+
}),
5763
)
58-
const { user } = useFullUserSession()
5964
const openedProjects = useOpenedProjects()
6065
const closingOnAppExit = useVueValue(
6166
React.useCallback(() => openedProjects.closingOnAppExit.value, [openedProjects]),

app/gui/src/providers/__tests__/auth.test.ts

Lines changed: 52 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,26 @@
1+
import type { UserSession as CognitoUserSession } from '$/authentication/cognito'
2+
import {
3+
isDirectoryId,
4+
isOrganizationId,
5+
isUserId,
6+
Plan,
7+
} from 'enso-common/src/services/Backend'
8+
import { Rfc3339DateTime } from 'enso-common/src/utilities/data/dateTime'
19
import { describe, expect, it } from 'vitest'
210
import { computed } from 'vue'
3-
import { isUsersMeQueryKey } from '../auth'
11+
import { isUsersMeQueryKey, makeSyntheticUser } from '../auth'
12+
13+
function fakeCognitoSession(overrides: Partial<CognitoUserSession> = {}): CognitoUserSession {
14+
return {
15+
email: 'user@example.com',
16+
accessToken: 'access',
17+
refreshToken: 'refresh',
18+
refreshUrl: 'https://example.com',
19+
expireAt: Rfc3339DateTime(new Date(Date.now() + 60_000).toJSON()),
20+
clientId: 'cognito-client-id',
21+
...overrides,
22+
}
23+
}
424

525
describe('isUsersMeQueryKey', () => {
626
it('matches reactive usersMe query keys', () => {
@@ -11,3 +31,34 @@ describe('isUsersMeQueryKey', () => {
1131
expect(isUsersMeQueryKey(['remote', 'otherQuery', computed(() => 'client-id')])).toBe(false)
1232
})
1333
})
34+
35+
describe('makeSyntheticUser', () => {
36+
it('returns a placeholder user without any features enabled', () => {
37+
const user = makeSyntheticUser(fakeCognitoSession())
38+
expect(user.isEnabled).toBe(false)
39+
expect(user.isOrganizationAdmin).toBe(false)
40+
expect(user.isEnsoTeamMember).toBe(false)
41+
expect(user.plan).toBe(Plan.free)
42+
expect(user.userGroups).toBeNull()
43+
expect(user.groups).toEqual([])
44+
})
45+
46+
it('derives identifiers in the expected newtype shape', () => {
47+
const user = makeSyntheticUser(fakeCognitoSession({ clientId: 'abc' }))
48+
expect(isUserId(user.userId)).toBe(true)
49+
expect(user.userId).toContain('abc')
50+
expect(isOrganizationId(user.organizationId)).toBe(true)
51+
expect(isDirectoryId(user.rootDirectoryId)).toBe(true)
52+
})
53+
54+
it('propagates the Cognito email into name and email fields', () => {
55+
const user = makeSyntheticUser(fakeCognitoSession({ email: 'someone@enso.org' }))
56+
expect(user.email).toBe('someone@enso.org')
57+
expect(user.name).toBe('someone@enso.org')
58+
})
59+
60+
it('handles a missing clientId without producing an empty identifier', () => {
61+
const user = makeSyntheticUser(fakeCognitoSession({ clientId: '' }))
62+
expect(user.userId).toBe('user-cloud-unavailable-unknown')
63+
})
64+
})

0 commit comments

Comments
 (0)