diff --git a/docs/architecture/pwa-self-hosted.md b/docs/architecture/pwa-self-hosted.md index 16aa9b28a..67c5ac4b1 100644 --- a/docs/architecture/pwa-self-hosted.md +++ b/docs/architecture/pwa-self-hosted.md @@ -336,7 +336,10 @@ settings and guards read them in every build. before sign-in sends the token. So does a page that went offline before its next read: until then it sends neither the token nor writes (a replaced volume at the same address is another database), and it checks - as soon as it goes offline, so a blip costs no retry interval. Both turn + the instance as soon as it goes offline, so a blip costs no retry + interval (only the instance: a read that keeps failing waits for the + retry). Sign-out always navigates to sign-in, also when a guard kept the + page the first time. Both turn the guard's 401 (`isServerAccessChallenge`) into `reportAccessDenied()`, as the storage API client does with any 401, so a token changed on the server sends an open page back to sign-in; a late 401 for a token diff --git a/libs/services/src/lib/server-storage/server-storage-session.spec.ts b/libs/services/src/lib/server-storage/server-storage-session.spec.ts index d679c17fb..7f6ae4b8c 100644 --- a/libs/services/src/lib/server-storage/server-storage-session.spec.ts +++ b/libs/services/src/lib/server-storage/server-storage-session.spec.ts @@ -1,6 +1,6 @@ import { TestBed } from '@angular/core/testing'; import { MatSnackBar } from '@angular/material/snack-bar'; -import { provideRouter } from '@angular/router'; +import { provideRouter, Router } from '@angular/router'; import { TranslateService } from '@ngx-translate/core'; import { PlaylistStorageBackend } from '../playlist-storage-backend'; import { RuntimeCapabilitiesService } from '../runtime-capabilities.service'; @@ -187,6 +187,21 @@ describe('ServerStorageSession', () => { delete (document as { visibilityState?: unknown }).visibilityState; }); + it('goes to sign-in on every sign-out, also when already signed out', async () => { + const { session } = setup('none'); + await session.start(); + const navigate = jest + .spyOn(TestBed.inject(Router), 'navigateByUrl') + .mockResolvedValue(false); + + session.signOut(); + // A guard kept the page (unsaved Settings); the user signs out again. + session.signOut(); + + expect(navigate).toHaveBeenCalledTimes(2); + expect(navigate).toHaveBeenLastCalledWith('/server-sign-in'); + }); + describe('after losing the server', () => { const answer = (body: unknown) => ({ status: 200, @@ -226,6 +241,30 @@ describe('ServerStorageSession', () => { ); }); + it('checks only the instance at once when it goes offline', async () => { + const { session, status } = setup('none'); + await session.start(); + // The instance answers, its user data does not. + fetchMock.mockImplementation(async (url: string) => + url.endsWith('/instance') + ? answer({ instanceId: 'i-1' }) + : { status: 503, ok: false, headers: new Headers() } + ); + fetchMock.mockClear(); + + status.reportOffline(); + TestBed.tick(); + for (let turn = 0; turn < 10; turn += 1) { + await Promise.resolve(); + } + + expect(status.isVerified()).toBe(true); + // A failing read waits for the retry interval, not a loop. + expect(fetchMock.mock.calls.map(([url]) => url)).toEqual([ + '/api/v1/instance', + ]); + }); + it('reloads when another instance answers at the address', async () => { const { session, status } = setup('none'); const reload = jest diff --git a/libs/services/src/lib/server-storage/server-storage-session.ts b/libs/services/src/lib/server-storage/server-storage-session.ts index 2cefadd30..b48829460 100644 --- a/libs/services/src/lib/server-storage/server-storage-session.ts +++ b/libs/services/src/lib/server-storage/server-storage-session.ts @@ -74,8 +74,14 @@ export class ServerStorageSession { effect(() => { // Gone offline, the token and writes wait for the instance // check: after a blip it passes at once, not at the next retry. - if (this.status.state() === 'offline') { - untracked(() => void this.refresh()); + // Only the check: a read failing on its own waits for the retry. + if ( + this.status.state() === 'offline' && + !this.config.startedOffline + ) { + untracked( + () => void this.ensureVerified().catch(() => undefined) + ); } }); } @@ -164,6 +170,9 @@ export class ServerStorageSession { signOut(): void { clearServerAccessToken(); this.status.reportUnauthorized(); + // Also when signed out already: a guard (unsaved Settings) may have + // kept the page here the first time. + void this.router.navigateByUrl(SERVER_SIGN_IN_PATH); } private listen(): void {