feat: signed releases, release feed, notification (read-only)
This commit is contained in:
1 parent
a49eec909c
commit
834493a4f5
49 files changed
+4191
-18
No files matched your search
@@ -0,0 +1,138 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { ApiError, NetworkError, useApi } from '@/composables/useApi'
|
||||
import { useSessionStore } from '@/stores/session'
|
||||
import { DISMISSED_VERSION_KEY, useUpdateStore } from '@/stores/update'
|
||||
import { CHECK_PATH, STATUS_PATH, newerAvailable, upToDate } from '@/views/__tests__/updateTestKit'
|
||||
|
||||
vi.mock('@/composables/useApi', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/composables/useApi')>()
|
||||
return {
|
||||
...actual,
|
||||
useApi: vi.fn(),
|
||||
}
|
||||
})
|
||||
|
||||
let requestMock: ReturnType<typeof vi.fn>
|
||||
|
||||
function setup(role: 'owner' | 'admin', handlers: Record<string, () => Promise<unknown>>) {
|
||||
setActivePinia(createPinia())
|
||||
const session = useSessionStore()
|
||||
session.session = { verified: true, displayName: '', role }
|
||||
session.status = 'ready'
|
||||
requestMock = vi.fn((path: string) => handlers[path]!())
|
||||
vi.mocked(useApi).mockReturnValue({ getMe: vi.fn(), request: requestMock })
|
||||
return useUpdateStore()
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear()
|
||||
})
|
||||
|
||||
describe('load', () => {
|
||||
it('shares one request between concurrent callers', async () => {
|
||||
const store = setup('owner', { [STATUS_PATH]: () => Promise.resolve(upToDate()) })
|
||||
|
||||
await Promise.all([store.load(), store.load()])
|
||||
|
||||
expect(requestMock).toHaveBeenCalledTimes(1)
|
||||
expect(store.loadState).toBe('ready')
|
||||
expect(store.status?.latest).toBe('v2.2.0-beta')
|
||||
})
|
||||
|
||||
it('reports an error when nothing was ever loaded, and keeps the last status otherwise', async () => {
|
||||
const store = setup('owner', { [STATUS_PATH]: () => Promise.reject(new ApiError(500)) })
|
||||
await store.load()
|
||||
expect(store.loadState).toBe('error')
|
||||
|
||||
const kept = setup('owner', { [STATUS_PATH]: () => Promise.resolve(upToDate()) })
|
||||
await kept.load()
|
||||
requestMock.mockImplementation(() => Promise.reject(new NetworkError()))
|
||||
await kept.load()
|
||||
expect(kept.loadState).toBe('ready')
|
||||
expect(kept.status).not.toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('check', () => {
|
||||
it('stores the result and returns to idle', async () => {
|
||||
const store = setup('owner', { [CHECK_PATH]: () => Promise.resolve(newerAvailable()) })
|
||||
|
||||
const result = await store.check()
|
||||
|
||||
expect(result).toBe('updated')
|
||||
expect(store.phase).toBe('idle')
|
||||
expect(store.available).toBe(true)
|
||||
expect(requestMock).toHaveBeenCalledWith(CHECK_PATH, { method: 'POST' })
|
||||
})
|
||||
|
||||
it('answers busy and sends nothing while a check is running', async () => {
|
||||
const store = setup('owner', { [CHECK_PATH]: () => new Promise(() => undefined) })
|
||||
store.check()
|
||||
|
||||
expect(await store.check()).toBe('busy')
|
||||
expect(requestMock).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it.each([
|
||||
{
|
||||
name: '429 with Retry-After',
|
||||
error: new ApiError(429, 42),
|
||||
result: 'limited',
|
||||
phase: 'limited',
|
||||
retry: 42,
|
||||
},
|
||||
{
|
||||
name: '429 without Retry-After',
|
||||
error: new ApiError(429),
|
||||
result: 'limited',
|
||||
phase: 'limited',
|
||||
retry: 60,
|
||||
},
|
||||
{ name: '403', error: new ApiError(403), result: 'forbidden', phase: 'idle', retry: 0 },
|
||||
{ name: '500', error: new ApiError(500), result: 'failed', phase: 'failed', retry: 0 },
|
||||
{
|
||||
name: 'a network failure',
|
||||
error: new NetworkError(),
|
||||
result: 'failed',
|
||||
phase: 'failed',
|
||||
retry: 0,
|
||||
},
|
||||
])('maps $name', async ({ error, result, phase, retry }) => {
|
||||
const store = setup('owner', { [CHECK_PATH]: () => Promise.reject(error) })
|
||||
|
||||
expect(await store.check()).toBe(result)
|
||||
expect(store.phase).toBe(phase)
|
||||
expect(store.retryAfter).toBe(retry)
|
||||
})
|
||||
})
|
||||
|
||||
describe('banner dismissal', () => {
|
||||
it('hides the banner for the dismissed version only and keeps the cue', async () => {
|
||||
const store = setup('owner', { [STATUS_PATH]: () => Promise.resolve(newerAvailable()) })
|
||||
await store.load()
|
||||
expect(store.bannerVisible).toBe(true)
|
||||
|
||||
store.dismissBanner()
|
||||
|
||||
expect(store.bannerVisible).toBe(false)
|
||||
expect(store.showCue).toBe(true)
|
||||
expect(localStorage.getItem(DISMISSED_VERSION_KEY)).toBe('v2.3.0-beta')
|
||||
})
|
||||
|
||||
it('reads the remembered version when created', async () => {
|
||||
localStorage.setItem(DISMISSED_VERSION_KEY, 'v2.3.0-beta')
|
||||
const store = setup('owner', { [STATUS_PATH]: () => Promise.resolve(newerAvailable()) })
|
||||
await store.load()
|
||||
|
||||
expect(store.bannerVisible).toBe(false)
|
||||
})
|
||||
|
||||
it('shows no cue for an admin', async () => {
|
||||
const store = setup('admin', { [STATUS_PATH]: () => Promise.resolve(newerAvailable()) })
|
||||
await store.load()
|
||||
|
||||
expect(store.showCue).toBe(false)
|
||||
expect(store.bannerVisible).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -51,6 +51,15 @@ export const useSessionStore = defineStore('session', () => {
|
||||
return check()
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
const { getMe } = useApi()
|
||||
try {
|
||||
session.value = await getMe()
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
function clear() {
|
||||
session.value = null
|
||||
status.value = 'ready'
|
||||
@@ -62,5 +71,5 @@ export const useSessionStore = defineStore('session', () => {
|
||||
}
|
||||
}
|
||||
|
||||
return { status, session, check, retry, clear, setDisplayName }
|
||||
return { status, session, check, retry, refresh, clear, setDisplayName }
|
||||
})
|
||||
@@ -0,0 +1,113 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { computed, ref } from 'vue'
|
||||
import { ApiError } from '@/composables/useApi'
|
||||
import { useUpdate, type UpdateStatus } from '@/composables/useUpdate'
|
||||
import { useSessionStore } from '@/stores/session'
|
||||
|
||||
export type UpdateLoadState = 'idle' | 'loading' | 'ready' | 'error'
|
||||
export type UpdateCheckPhase = 'idle' | 'checking' | 'failed' | 'limited'
|
||||
export type UpdateCheckResult = 'updated' | 'failed' | 'limited' | 'forbidden' | 'busy'
|
||||
|
||||
export const DISMISSED_VERSION_KEY = 'renewcms:update-dismissed'
|
||||
const DEFAULT_RETRY_SECONDS = 60
|
||||
|
||||
function readDismissedVersion() {
|
||||
try {
|
||||
return localStorage.getItem(DISMISSED_VERSION_KEY)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function writeDismissedVersion(version: string) {
|
||||
try {
|
||||
localStorage.setItem(DISMISSED_VERSION_KEY, version)
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
export const useUpdateStore = defineStore('update', () => {
|
||||
const session = useSessionStore()
|
||||
const status = ref<UpdateStatus | null>(null)
|
||||
const loadState = ref<UpdateLoadState>('idle')
|
||||
const phase = ref<UpdateCheckPhase>('idle')
|
||||
const retryAfter = ref(0)
|
||||
const dismissedVersion = ref<string | null>(readDismissedVersion())
|
||||
let pendingLoad: Promise<void> | null = null
|
||||
|
||||
const isOwner = computed(() => session.session?.role === 'owner')
|
||||
const available = computed(() => status.value?.available === true)
|
||||
const showCue = computed(() => isOwner.value && available.value)
|
||||
const bannerVisible = computed(
|
||||
() => showCue.value && status.value?.latest !== dismissedVersion.value,
|
||||
)
|
||||
|
||||
function load() {
|
||||
if (pendingLoad) {
|
||||
return pendingLoad
|
||||
}
|
||||
const { getUpdateStatus } = useUpdate()
|
||||
loadState.value = 'loading'
|
||||
pendingLoad = Promise.resolve()
|
||||
.then(getUpdateStatus)
|
||||
.then((result) => {
|
||||
status.value = result
|
||||
loadState.value = 'ready'
|
||||
})
|
||||
.catch(() => {
|
||||
loadState.value = status.value === null ? 'error' : 'ready'
|
||||
})
|
||||
.finally(() => {
|
||||
pendingLoad = null
|
||||
})
|
||||
return pendingLoad
|
||||
}
|
||||
|
||||
async function check(): Promise<UpdateCheckResult> {
|
||||
if (phase.value === 'checking') {
|
||||
return 'busy'
|
||||
}
|
||||
const { checkForUpdate } = useUpdate()
|
||||
phase.value = 'checking'
|
||||
try {
|
||||
status.value = await checkForUpdate()
|
||||
loadState.value = 'ready'
|
||||
phase.value = 'idle'
|
||||
return 'updated'
|
||||
} catch (error) {
|
||||
if (error instanceof ApiError && error.status === 429) {
|
||||
retryAfter.value = error.retryAfter ?? DEFAULT_RETRY_SECONDS
|
||||
phase.value = 'limited'
|
||||
return 'limited'
|
||||
}
|
||||
if (error instanceof ApiError && error.status === 403) {
|
||||
phase.value = 'idle'
|
||||
return 'forbidden'
|
||||
}
|
||||
phase.value = 'failed'
|
||||
return 'failed'
|
||||
}
|
||||
}
|
||||
|
||||
function dismissBanner() {
|
||||
if (status.value) {
|
||||
dismissedVersion.value = status.value.latest
|
||||
writeDismissedVersion(status.value.latest)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
status,
|
||||
loadState,
|
||||
phase,
|
||||
retryAfter,
|
||||
isOwner,
|
||||
available,
|
||||
showCue,
|
||||
bannerVisible,
|
||||
load,
|
||||
check,
|
||||
dismissBanner,
|
||||
}
|
||||
})
|
||||
Reference in new issue
Block a user