- account_internal_test: metadata subtitle reader and writer (NULL, blank, null, [], invalid JSON, non-objects, key preservation, NULL on the last key), the attachOne contract, avatar cap, unique-violation mapping, English fallbacks, and 401 for non-backend principals on all three routes - account_test (Postgres): profile payload incl. copied WinterCMS metadata and avatar rows, trimmed profile saves and metadata keys, the forbidden-key allow-list with unchanged rows, email rules, password rules, the failed attempt log, Bearer and cookie session effects, CSRF, avatar upload, refusals (SVG, HTML as PNG, size, part shape, 413, no bucket) with no row or blob left, replace and remove with blob cleanup - lagoon: TestBackendAdminMetadataColumn covers type, Winter row round trip, rollback of only the column and re-migrate - SPA: AccountView, the user menu account item and avatar image, setCurrentUser and profileInitials; lang fixture gains myaccount keys
172 lines
6.8 KiB
TypeScript
172 lines
6.8 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
import {
|
|
clearUser,
|
|
currentUser,
|
|
login,
|
|
logout,
|
|
me,
|
|
profileInitials,
|
|
setCurrentUser,
|
|
useAuth,
|
|
} from '../../src/state/useAuth'
|
|
import { navigation, setNavigation } from '../../src/state/useNavigation'
|
|
import { setSettings, settings } from '../../src/state/useSettings'
|
|
import { navigationFixture, settingsFixture } from '../fixtures/typed'
|
|
import { API, mockApi, profile, requestsTo, resetState } from '../helpers'
|
|
|
|
const loginOk = (expiresIn: unknown) => ({ body: { data: { token_type: 'cookie', expires_in: expiresIn }, meta: {} } })
|
|
const refreshOk = { body: { data: { token_type: 'cookie', expires_in: 50 }, meta: {} } }
|
|
const unauthorized = { status: 401, body: { error: { code: 'unauthenticated', message: 'no', details: {} } } }
|
|
|
|
beforeEach(() => {
|
|
resetState()
|
|
})
|
|
|
|
afterEach(() => {
|
|
clearUser()
|
|
vi.useRealTimers()
|
|
})
|
|
|
|
describe('login', () => {
|
|
it('posts the credentials and schedules the proactive refresh at 80 percent', async () => {
|
|
vi.useFakeTimers()
|
|
const calls = mockApi({ [`POST ${API}/auth/login`]: loginOk(100), [`POST ${API}/auth/refresh`]: refreshOk })
|
|
|
|
expect(await login('dev', 'secret')).toBe(true)
|
|
const body = (await requestsTo(calls, 'POST', `${API}/auth/login`)[0]!.clone().json()) as Record<string, string>
|
|
expect(body).toEqual({ login: 'dev', password: 'secret' })
|
|
expect(useAuth().expiresIn.value).toBe(100)
|
|
|
|
await vi.advanceTimersByTimeAsync(79_999)
|
|
expect(requestsTo(calls, 'POST', `${API}/auth/refresh`)).toHaveLength(0)
|
|
await vi.advanceTimersByTimeAsync(1)
|
|
expect(requestsTo(calls, 'POST', `${API}/auth/refresh`)).toHaveLength(1)
|
|
// The refresh reschedules from its own lifetime (50 s -> 40 s).
|
|
expect(useAuth().expiresIn.value).toBe(50)
|
|
await vi.advanceTimersByTimeAsync(40_000)
|
|
expect(requestsTo(calls, 'POST', `${API}/auth/refresh`)).toHaveLength(2)
|
|
})
|
|
|
|
it('answers false on rejected credentials and schedules nothing', async () => {
|
|
vi.useFakeTimers()
|
|
const calls = mockApi({ [`POST ${API}/auth/login`]: unauthorized, [`POST ${API}/auth/refresh`]: refreshOk })
|
|
expect(await login('dev', 'bad')).toBe(false)
|
|
await vi.advanceTimersByTimeAsync(10_000_000)
|
|
expect(requestsTo(calls, 'POST', `${API}/auth/refresh`)).toHaveLength(0)
|
|
})
|
|
|
|
it.each([0, -5, 'soon', null])('schedules no refresh for the lifetime %j', async (expiresIn) => {
|
|
vi.useFakeTimers()
|
|
const calls = mockApi({ [`POST ${API}/auth/login`]: loginOk(expiresIn), [`POST ${API}/auth/refresh`]: refreshOk })
|
|
expect(await login('dev', 'secret')).toBe(true)
|
|
await vi.advanceTimersByTimeAsync(10_000_000)
|
|
expect(requestsTo(calls, 'POST', `${API}/auth/refresh`)).toHaveLength(0)
|
|
})
|
|
})
|
|
|
|
describe('me and clearUser', () => {
|
|
it('loads the profile, and clears it on 401', async () => {
|
|
mockApi({ [`GET ${API}/auth/me`]: { body: profile } })
|
|
expect((await me())?.login).toBe('dev')
|
|
expect(currentUser.value?.role?.name).toBe('Developer')
|
|
|
|
mockApi({ [`GET ${API}/auth/me`]: unauthorized })
|
|
expect(await me()).toBeNull()
|
|
expect(currentUser.value).toBeNull()
|
|
})
|
|
|
|
it('clearUser forgets the user and cancels the pending refresh', async () => {
|
|
vi.useFakeTimers()
|
|
const calls = mockApi({
|
|
[`GET ${API}/auth/me`]: { body: profile },
|
|
[`POST ${API}/auth/login`]: loginOk(10),
|
|
[`POST ${API}/auth/refresh`]: refreshOk,
|
|
})
|
|
await login('dev', 'secret')
|
|
await me()
|
|
clearUser()
|
|
expect(currentUser.value).toBeNull()
|
|
expect(useAuth().expiresIn.value).toBeNull()
|
|
await vi.advanceTimersByTimeAsync(60_000)
|
|
expect(requestsTo(calls, 'POST', `${API}/auth/refresh`)).toHaveLength(0)
|
|
})
|
|
})
|
|
|
|
describe('logout (T-10-23)', () => {
|
|
async function signedIn(logoutRoute: Parameters<typeof mockApi>[0][string] | 'throw') {
|
|
const routes: Parameters<typeof mockApi>[0] = { [`GET ${API}/auth/me`]: { body: profile } }
|
|
if (logoutRoute !== 'throw') {
|
|
routes[`POST ${API}/auth/logout`] = logoutRoute
|
|
}
|
|
const calls = mockApi(routes)
|
|
await me()
|
|
setNavigation(navigationFixture.data)
|
|
setSettings(settingsFixture.list.data)
|
|
if (logoutRoute === 'throw') {
|
|
vi.spyOn(globalThis, 'fetch').mockRejectedValue(new TypeError('offline'))
|
|
}
|
|
return calls
|
|
}
|
|
|
|
it.each([
|
|
['succeeds', { body: { data: { status: 'logged_out' }, meta: {} } }],
|
|
['answers 500', { status: 500, body: { error: { code: 'server', message: 'x', details: {} } } }],
|
|
['fails on the network', 'throw' as const],
|
|
])('clears the user, navigation and settings and routes to login when the call %s', async (_label, route) => {
|
|
const calls = await signedIn(route)
|
|
const router = { replace: vi.fn().mockResolvedValue(undefined) }
|
|
|
|
await logout(router)
|
|
|
|
expect(currentUser.value).toBeNull()
|
|
expect(navigation.value).toEqual([])
|
|
expect(settings.value).toEqual([])
|
|
expect(router.replace).toHaveBeenCalledWith({ name: 'login' })
|
|
if (route !== 'throw') {
|
|
const posted = requestsTo(calls, 'POST', `${API}/auth/logout`)
|
|
expect(posted).toHaveLength(1)
|
|
expect(posted[0]!.headers.get('X-Requested-With')).toBe('XMLHttpRequest')
|
|
}
|
|
})
|
|
|
|
it('works without a router and swallows a failed navigation', async () => {
|
|
await signedIn({ body: { data: { status: 'logged_out' }, meta: {} } })
|
|
await expect(logout()).resolves.toBeUndefined()
|
|
expect(currentUser.value).toBeNull()
|
|
|
|
await signedIn({ body: { data: { status: 'logged_out' }, meta: {} } })
|
|
await expect(logout({ replace: vi.fn().mockRejectedValue(new Error('aborted')) })).resolves.toBeUndefined()
|
|
expect(currentUser.value).toBeNull()
|
|
})
|
|
|
|
it('exposes the session API through useAuth', () => {
|
|
const auth = useAuth()
|
|
expect(auth.user).toBe(currentUser)
|
|
expect(auth.login).toBe(login)
|
|
expect(auth.logout).toBe(logout)
|
|
expect(auth.me).toBe(me)
|
|
expect(auth.clearUser).toBe(clearUser)
|
|
})
|
|
})
|
|
|
|
describe('setCurrentUser and profileInitials', () => {
|
|
it('replaces the signed-in profile, as My account does after a save', async () => {
|
|
mockApi({ [`GET ${API}/auth/me`]: { body: profile } })
|
|
await me()
|
|
const next = { ...profile.data, first_name: 'Grace', subtitle: 'Admiral', avatar: { url: '/a.png' } }
|
|
setCurrentUser(next)
|
|
expect(currentUser.value).toEqual(next)
|
|
expect(useAuth().user.value?.subtitle).toBe('Admiral')
|
|
expect(useAuth().setCurrentUser).toBe(setCurrentUser)
|
|
clearUser()
|
|
expect(currentUser.value).toBeNull()
|
|
})
|
|
|
|
it('takes the name initials, else the login letters', () => {
|
|
expect(profileInitials(null)).toBe('')
|
|
expect(profileInitials({ first_name: 'grace', last_name: 'hopper', login: 'gh' })).toBe('GH')
|
|
expect(profileInitials({ first_name: ' ', last_name: 'Hopper', login: 'gh' })).toBe('H')
|
|
expect(profileInitials({ first_name: '', last_name: '', login: 'root' })).toBe('RO')
|
|
})
|
|
})
|