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:
Darkraise
2026-08-13 00:44:02 +07:00
parent 1b9b1ce933
commit e9613362c2
3 changed files with 65 additions and 9 deletions
+31 -1
View File
@@ -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 ? (