diff --git a/projects/kit/offline/src/lib/offline-coordinator.service.spec.ts b/projects/kit/offline/src/lib/offline-coordinator.service.spec.ts index 57bd9b3..669291e 100644 --- a/projects/kit/offline/src/lib/offline-coordinator.service.spec.ts +++ b/projects/kit/offline/src/lib/offline-coordinator.service.spec.ts @@ -162,4 +162,59 @@ describe('OfflineCoordinatorService', () => { await expect(newActivation).resolves.toBe(true); expect(sessionState.userId).toBe(9); }); + + it('preserves user scope 0 from remote activation through the first pull', async () => { + let lastUserId: number | null = null; + let manifest: OfflineSessionManifest | null = null; + const pull = vi.fn(async (_scope: { userId: number; groupId: number }) => undefined); + const repository = { + initialize: vi.fn(async () => undefined), + getLastUserId: vi.fn(async () => lastUserId), + setLastUserId: vi.fn(async (userId: number) => { + lastUserId = userId; + }), + getSessionManifest: vi.fn(async () => manifest), + putSessionManifest: vi.fn(async (_userId: number, value: OfflineSessionManifest) => { + manifest = structuredClone(value); + }), + clearUser: vi.fn(async () => undefined), + clearGroup: vi.fn(async () => undefined), + }; + const network = { + state: signal('connected'), + initialize: vi.fn(async () => undefined), + }; + const sync = { + syncState: signal('idle'), + pendingCount: signal(0), + conflicts: signal([]), + initialize: vi.fn(async () => undefined), + resetSession: vi.fn(async () => undefined), + revokeSession: vi.fn(), + refreshSession: vi.fn(async () => { + const remote = await TestBed.inject(OfflineSessionService).getSession(); + await Promise.all((remote?.scopes ?? []).map((scope) => pull(scope))); + }), + refreshLocalSession: vi.fn(async () => undefined), + discardAllPending: vi.fn(async () => undefined), + flush: vi.fn(async () => undefined), + }; + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + providers: [ + OfflineCoordinatorService, + OfflineSessionService, + { provide: OFFLINE_REPOSITORY, useValue: repository }, + { provide: OfflineNetworkService, useValue: network }, + { provide: OfflineSyncService, useValue: sync }, + ], + }); + const coordinator = TestBed.inject(OfflineCoordinatorService); + + await expect(coordinator.prepareRemoteSession(7, [0], 'subject')).resolves.toBe(true); + await coordinator.resumeRemoteSession(); + + expect(manifest).toMatchObject({ userId: 7, scopeIds: [0], authSubject: 'subject' }); + expect(pull).toHaveBeenCalledWith({ userId: 7, groupId: 0 }); + }); }); diff --git a/projects/kit/offline/src/lib/offline-session.service.spec.ts b/projects/kit/offline/src/lib/offline-session.service.spec.ts index 3e7c3d8..798724b 100644 --- a/projects/kit/offline/src/lib/offline-session.service.spec.ts +++ b/projects/kit/offline/src/lib/offline-session.service.spec.ts @@ -98,6 +98,16 @@ describe('OfflineSessionService shared-device boundary', () => { expect(service.activeManifest()).toMatchObject({ userId: 20, authSubject: 'uid-B' }); }); + it('user-scoped replicaのscope 0をmanifestとremote sessionに保持する', async () => { + await service.activateSession(10, [0], 'uid-A'); + + expect(service.activeManifest()).toMatchObject({ userId: 10, scopeIds: [0], authSubject: 'uid-A' }); + await expect(service.getSession()).resolves.toEqual({ + userId: 10, + scopes: [{ userId: 10, groupId: 0 }], + }); + }); + it('同じuserIdでもauthSubjectが変わると旧主体の全scopeを継承しない', async () => { await service.initialize(); await service.activateSession(10, [2], 'uid-B'); diff --git a/projects/kit/offline/src/lib/offline-session.service.ts b/projects/kit/offline/src/lib/offline-session.service.ts index 55eb9e3..eea455c 100644 --- a/projects/kit/offline/src/lib/offline-session.service.ts +++ b/projects/kit/offline/src/lib/offline-session.service.ts @@ -53,7 +53,7 @@ export class OfflineSessionService { ): Promise { await this.initialize(); if (lease && !lease.isCurrent()) return false; - const normalizedScopeIds = [...new Set(scopeIds)].filter((id) => id !== 0).sort((a, b) => a - b); + const normalizedScopeIds = [...new Set(scopeIds)].sort((a, b) => a - b); const previousUserId = await this.#repository.getLastUserId(); if (lease && !lease.isCurrent()) return false; let previous = previousUserId === userId ? ((await this.#repository.getSessionManifest(userId)) ?? null) : null; diff --git a/projects/kit/src/lib/auth/auth-access.service.spec.ts b/projects/kit/src/lib/auth/auth-access.service.spec.ts index 6dfd49f..658c715 100644 --- a/projects/kit/src/lib/auth/auth-access.service.spec.ts +++ b/projects/kit/src/lib/auth/auth-access.service.spec.ts @@ -6,6 +6,7 @@ import { KIT_AUTH_RECOVERY_CONFIG, KitAuthAccessService, KitAuthRecoveryService, + type KitAuthAccessLease, type KitAuthRecoveryConfig, type KitRemoteAccessRecovery, } from './auth-access.service'; @@ -61,6 +62,68 @@ describe('KitAuthRecoveryService', () => { expect(reauthenticate).toHaveBeenCalledOnce(); }); + it('invalidates the post-grant resume lease when access is revoked during recovery', async () => { + let markResumeStarted!: () => void; + let releaseResume!: () => void; + const resumeStarted = new Promise((resolve) => { + markResumeStarted = resolve; + }); + const resumeGate = new Promise((resolve) => { + releaseResume = resolve; + }); + const userVisibleEffect = vi.fn(); + const { access, recovery } = setup({ + remoteRecovery: { + availability: () => new Subject(), + reauthenticate: async () => ({ + activate: async () => true, + resume: async (lease) => { + markResumeStarted(); + await resumeGate; + if (lease?.isCurrent()) userVisibleEffect(); + }, + }), + }, + }); + access.grantLocal(); + + const pending = recovery.recover(); + await resumeStarted; + access.clear(); + releaseResume(); + await pending; + + expect(userVisibleEffect).not.toHaveBeenCalled(); + expect(access.mode).toBe('none'); + }); + + it('does not reclaim a transition started by a synchronous remote-mode subscriber', async () => { + const userVisibleEffect = vi.fn(); + const resume = vi.fn(async (lease?: KitAuthAccessLease) => { + if (lease?.isCurrent()) userVisibleEffect(); + }); + const { access, recovery } = setup({ + remoteRecovery: { + availability: () => new Subject(), + reauthenticate: async () => ({ + activate: async () => true, + resume, + }), + }, + }); + access.grantLocal(); + const subscription = access.mode$.subscribe((mode) => { + if (mode === 'remote') access.clear(); + }); + + await recovery.recover(); + subscription.unsubscribe(); + + expect(resume).not.toHaveBeenCalled(); + expect(userVisibleEffect).not.toHaveBeenCalled(); + expect(access.mode).toBe('none'); + }); + it('coalesces concurrent recovery attempts into one flight', async () => { let resolveRecovery: ((value: false) => void) | undefined; const reauthenticate = vi.fn( diff --git a/projects/kit/src/lib/auth/auth-access.service.ts b/projects/kit/src/lib/auth/auth-access.service.ts index 3cbd38d..c82a79f 100644 --- a/projects/kit/src/lib/auth/auth-access.service.ts +++ b/projects/kit/src/lib/auth/auth-access.service.ts @@ -26,7 +26,15 @@ export interface KitRemoteAccessRecovery { * @returns `false` when a newer logout or identity transition superseded this activation. */ activate(lease: KitAuthAccessLease): Promise; - resume(): Promise; + /** + * Resume remote-only work after access has been published. + * + * @param lease - Post-grant lease supplied by the kit. Check it after every await before + * navigation or other user-visible side effects, because logout or a newer identity transition + * may supersede this resume while transport work is settling. Optional for source compatibility + * with callers that manually resumed a recovery result before leases were introduced. + */ + resume(lease?: KitAuthAccessLease): Promise; } /** Recovery-specific authentication configuration consumed by {@link KitAuthRecoveryService}. */ @@ -99,9 +107,14 @@ export class KitAuthAccessService { this.#publish('local'); } - /** Publish a remotely authenticated session. */ - grantRemote(): void { - this.#publish('remote'); + /** + * Publish a remotely authenticated session and return the lease that owns that publication. + * + * The revision is fixed before synchronous `mode$` subscribers run. A subscriber that starts a + * newer logout or identity transition therefore makes the returned lease stale. + */ + grantRemote(): KitAuthAccessLease { + return this.#publish('remote'); } /** Revoke both local and remote access. */ @@ -109,9 +122,11 @@ export class KitAuthAccessService { this.#publish('none'); } - #publish(mode: KitAuthAccessMode): void { + #publish(mode: KitAuthAccessMode): KitAuthAccessLease { this.#revision += 1; + const revision = this.#revision; this.#mode.next(mode); + return { isCurrent: () => this.#revision === revision }; } } @@ -211,8 +226,8 @@ export class KitAuthRecoveryService { const recovery = this.#config.remoteRecovery; if (this.#destroyed || !recovery || this.#access.mode !== 'local') return; const lease = this.#access.beginTransition(); - let expectedRevision = this.#access.revision; - const isCurrent = (): boolean => this.#access.revision === expectedRevision; + let currentLease = lease; + const isCurrent = (): boolean => currentLease.isCurrent(); try { const result = await recovery.reauthenticate(lease); if (this.#destroyed || !isCurrent() || this.#access.mode !== 'local') return; @@ -230,9 +245,9 @@ export class KitAuthRecoveryService { return; } this.#clearRetry(); - this.#access.grantRemote(); - expectedRevision = this.#access.revision; - await result.resume(); + currentLease = this.#access.grantRemote(); + if (!currentLease.isCurrent()) return; + await result.resume(currentLease); } catch (error) { if (this.#destroyed || !isCurrent()) return; if (isExplicitAuthDenial(error)) { diff --git a/projects/kit/src/lib/auth/auth-guards.spec.ts b/projects/kit/src/lib/auth/auth-guards.spec.ts index b526beb..f00d729 100644 --- a/projects/kit/src/lib/auth/auth-guards.spec.ts +++ b/projects/kit/src/lib/auth/auth-guards.spec.ts @@ -234,6 +234,59 @@ describe('kitRequireAuthorizedGuard', () => { expect(TestBed.inject(KitAuthAccessService).mode).toBe('remote'); }); + it("'user' → invalidates the post-grant resume lease before stale user-visible effects", async () => { + let markResumeStarted!: () => void; + let releaseResume!: () => void; + const resumeStarted = new Promise((resolve) => { + markResumeStarted = resolve; + }); + const resumeGate = new Promise((resolve) => { + releaseResume = resolve; + }); + const navigateAfterResume = vi.fn(); + const onAuthorized = vi.fn(async () => ({ + activate: async () => true, + resume: async (lease?: KitAuthAccessLease) => { + markResumeStarted(); + await resumeGate; + if (lease?.isCurrent()) navigateAfterResume(); + }, + })); + setup('user', { onAuthorized }); + + const pending = runGuard(TestBed.runInInjectionContext(() => kitRequireAuthorizedGuard(routeStub, stateStub))); + await resumeStarted; + TestBed.inject(KitAuthAccessService).clear(); + releaseResume(); + + await expect(pending).resolves.toBe(false); + expect(navigateAfterResume).not.toHaveBeenCalled(); + }); + + it("'user' → does not reclaim a transition started by a synchronous remote-mode subscriber", async () => { + const navigateAfterResume = vi.fn(); + const resume = vi.fn(async (lease?: KitAuthAccessLease) => { + if (lease?.isCurrent()) navigateAfterResume(); + }); + const onAuthorized = vi.fn(async () => ({ + activate: async () => true, + resume, + })); + setup('user', { onAuthorized }); + const access = TestBed.inject(KitAuthAccessService); + const subscription = access.mode$.subscribe((mode) => { + if (mode === 'remote') access.clear(); + }); + + const result = await runGuard(TestBed.runInInjectionContext(() => kitRequireAuthorizedGuard(routeStub, stateStub))); + subscription.unsubscribe(); + + expect(result).toBe(false); + expect(resume).not.toHaveBeenCalled(); + expect(navigateAfterResume).not.toHaveBeenCalled(); + expect(access.mode).toBe('none'); + }); + it("'user' → keeps verified remote access when only phased resume loses transport", async () => { const networkError = { status: 0 }; const onAuthorized = vi.fn(async () => ({ diff --git a/projects/kit/src/lib/auth/auth-guards.ts b/projects/kit/src/lib/auth/auth-guards.ts index a94e7a5..3c3fab9 100644 --- a/projects/kit/src/lib/auth/auth-guards.ts +++ b/projects/kit/src/lib/auth/auth-guards.ts @@ -302,19 +302,19 @@ export const kitRequireAuthorizedGuard: CanActivateFn = (_route, state) => { if (!lease.isCurrent()) return false; if (isRemoteAccessActivation(result)) { if (!(await result.activate(lease)) || !lease.isCurrent()) return false; - access.grantRemote(); - const remoteRevision = access.revision; + const resumeLease = access.grantRemote(); + if (!resumeLease.isCurrent()) return false; try { - await result.resume(); + await result.resume(resumeLease); } catch (error) { - if (access.revision !== remoteRevision) return false; + if (!resumeLease.isCurrent()) return false; if (isExplicitAuthDenial(error)) { access.clear(); throw error; } if (!isUnavailableError?.(error)) throw error; } - return access.revision === remoteRevision; + return resumeLease.isCurrent(); } if (result === true) access.grantRemote(); else access.clear();