fix(settings): tighten update card and pin auth
- Show checkedAt on the up-to-date panel and only claim currency when versions actually match, with a matching neutral toast path for unreleased builds - Add aria-expanded to the changelog toggle - Reword a review-referencing comment/test title and pin the /app route's settings.update permission gate in tests
This commit is contained in:
@@ -10,8 +10,17 @@ vi.mock('../utils/logger', () => ({
|
||||
vi.mock('../middleware/auth', () => ({
|
||||
authenticate: (_req: unknown, _res: unknown, next: () => void) => next(),
|
||||
}));
|
||||
const requirePermissionCalls = vi.hoisted(() => [] as string[]);
|
||||
vi.mock('../middleware/rbac', () => ({
|
||||
requirePermission: () => (_req: unknown, _res: unknown, next: () => void) => next(),
|
||||
requirePermission: (permission: string) => {
|
||||
requirePermissionCalls.push(permission);
|
||||
// Tag the returned middleware with the permission it was built for, so
|
||||
// tests can confirm which gate a specific route was registered behind
|
||||
// instead of only that some permission was requested somewhere.
|
||||
return Object.assign((_req: unknown, _res: unknown, next: () => void) => next(), {
|
||||
permission,
|
||||
});
|
||||
},
|
||||
}));
|
||||
vi.mock('../middleware/activityLogger', () => ({
|
||||
logActivity: () => (_req: unknown, _res: unknown, next: () => void) => next(),
|
||||
@@ -48,6 +57,17 @@ const INFO = {
|
||||
lastCheckFailed: false,
|
||||
};
|
||||
|
||||
interface RouteMiddlewareLayer {
|
||||
handle: { permission?: string };
|
||||
}
|
||||
|
||||
interface RouteLayer {
|
||||
route?: {
|
||||
path: string;
|
||||
stack: RouteMiddlewareLayer[];
|
||||
};
|
||||
}
|
||||
|
||||
let app: Express;
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -86,4 +106,14 @@ describe('GET /api/updates/app', () => {
|
||||
expect(getUpdateInfo).toHaveBeenNthCalledWith(2, false);
|
||||
expect(getUpdateInfo).toHaveBeenNthCalledWith(3, false);
|
||||
});
|
||||
|
||||
it('is registered behind the settings.update permission', () => {
|
||||
const stack = (updatesRouter as unknown as { stack: RouteLayer[] }).stack;
|
||||
const appLayer = stack.find((layer) => layer.route?.path === '/app');
|
||||
|
||||
expect(appLayer).toBeDefined();
|
||||
const permissions = appLayer?.route?.stack.map((layer) => layer.handle.permission) ?? [];
|
||||
expect(permissions).toContain('settings.update');
|
||||
expect(requirePermissionCalls).toContain('settings.update');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -223,10 +223,9 @@ describe('AppUpdateService failures', () => {
|
||||
expect(info.latestVersion).toBeNull();
|
||||
});
|
||||
|
||||
// Adjusted from the brief: with the failure floor in place, a retry right
|
||||
// after a failure is now suppressed rather than firing immediately, so the
|
||||
// clock has to move past FORCED_REFRESH_FLOOR_MS before the recovery call
|
||||
// is allowed to actually reach the network.
|
||||
// The failure floor suppresses an immediate retry, so the clock has to
|
||||
// move past FORCED_REFRESH_FLOOR_MS before a recovery attempt is allowed
|
||||
// to actually reach the network.
|
||||
it('clears the failure flag once a later check succeeds', async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
@@ -246,7 +245,7 @@ describe('AppUpdateService failures', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('does not retry within 60 seconds of a failure, even so the check is still flagged failed', async () => {
|
||||
it('does not retry within 60 seconds of a failure, and the check is still flagged failed', async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
vi.setSystemTime(new Date('2026-08-12T00:00:00Z'));
|
||||
|
||||
@@ -20,6 +20,11 @@ import { updatesApi, type AppUpdateInfo } from '@/lib/api';
|
||||
|
||||
const UPGRADE_COMMAND = 'docker compose pull && docker compose up -d';
|
||||
|
||||
/** True once the running build is exactly the latest known release. */
|
||||
function versionsMatch(currentVersion: string | null, latestVersion: string | null): boolean {
|
||||
return currentVersion !== null && currentVersion === latestVersion;
|
||||
}
|
||||
|
||||
export function AppUpdateCard() {
|
||||
const { showToast } = useDialog();
|
||||
const queryClient = useQueryClient();
|
||||
@@ -47,8 +52,15 @@ export function AppUpdateCard() {
|
||||
showToast('Could not reach GitHub to check for updates', 'error');
|
||||
} else if (result.updateAvailable) {
|
||||
showToast(`Flashpoint Web ${result.latestVersion} is available`, 'success');
|
||||
} else if (!result.isUnreleasedBuild) {
|
||||
} else if (result.isUnreleasedBuild) {
|
||||
showToast(
|
||||
`Update checks are off for this build (latest release: ${result.latestVersion ?? 'unknown'})`,
|
||||
'info'
|
||||
);
|
||||
} else if (versionsMatch(result.currentVersion, result.latestVersion)) {
|
||||
showToast('Flashpoint Web is up to date', 'success');
|
||||
} else {
|
||||
showToast(`Latest release: ${result.latestVersion ?? 'unknown'}`, 'info');
|
||||
}
|
||||
},
|
||||
onError: () => showToast('Could not check for updates', 'error'),
|
||||
@@ -63,6 +75,9 @@ export function AppUpdateCard() {
|
||||
|
||||
const checkFailedEntirely =
|
||||
isLoadingError || (info?.lastCheckFailed === true && info.latestVersion === null);
|
||||
const currentVersionMatchesLatest = info
|
||||
? versionsMatch(info.currentVersion, info.latestVersion)
|
||||
: false;
|
||||
|
||||
return (
|
||||
<div className="bg-card rounded-lg p-6 border border-border shadow-md">
|
||||
@@ -90,7 +105,11 @@ export function AppUpdateCard() {
|
||||
<Label className="text-sm text-muted-foreground">Current Version</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-lg font-semibold">{info?.currentVersion ?? 'Unknown'}</span>
|
||||
{info && !info.isUnreleasedBuild && !info.updateAvailable && !info.lastCheckFailed ? (
|
||||
{info &&
|
||||
!info.isUnreleasedBuild &&
|
||||
!info.updateAvailable &&
|
||||
!info.lastCheckFailed &&
|
||||
currentVersionMatchesLatest ? (
|
||||
<CheckCircle2 className="h-5 w-5 text-green-500" aria-hidden="true" />
|
||||
) : null}
|
||||
</div>
|
||||
@@ -136,7 +155,9 @@ export function AppUpdateCard() {
|
||||
<p className="font-medium">
|
||||
{info.updateAvailable
|
||||
? `Update available: ${info.latestVersion}`
|
||||
: "You're up to date!"}
|
||||
: currentVersionMatchesLatest
|
||||
? "You're up to date!"
|
||||
: `Latest release: ${info.latestVersion ?? 'unknown'}`}
|
||||
</p>
|
||||
{info.publishedAt ? (
|
||||
<div className="flex items-center gap-1 mt-2 text-xs text-muted-foreground">
|
||||
@@ -146,6 +167,11 @@ export function AppUpdateCard() {
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
{!info.lastCheckFailed && info.checkedAt ? (
|
||||
<p className="text-xs text-muted-foreground mt-2">
|
||||
Checked <FormattedDate date={info.checkedAt} type="datetime" />
|
||||
</p>
|
||||
) : null}
|
||||
{info.lastCheckFailed && info.checkedAt ? (
|
||||
<p className="text-xs text-muted-foreground mt-2">
|
||||
The last check failed. Showing the result from{' '}
|
||||
@@ -183,6 +209,7 @@ export function AppUpdateCard() {
|
||||
<div className="border-t border-border pt-3">
|
||||
<button
|
||||
onClick={() => setShowChangelog(!showChangelog)}
|
||||
aria-expanded={showChangelog}
|
||||
className="flex items-center gap-2 text-sm font-medium hover:text-primary transition-colors w-full"
|
||||
>
|
||||
{showChangelog ? (
|
||||
|
||||
Reference in New Issue
Block a user