diff --git a/src/main/content-api-client.js b/src/main/content-api-client.js index d4c0d26..4f47f03 100644 --- a/src/main/content-api-client.js +++ b/src/main/content-api-client.js @@ -35,6 +35,20 @@ import { buildHttpError } from './http-request-error.js'; const LIST_MAX_PAGES = 50000; +/** + * Builds a 401 error that keeps the {@link DA_UNAUTHORIZED_MESSAGE} prefix + * (session-expiry detection matches on it) while carrying the request, + * `x-error` header, and body detail for diagnosis. + * + * @param {string} method + * @param {string} url + * @param {Response} res + * @returns {Promise} + */ +function buildUnauthorizedError(method, url, res) { + return buildHttpError(method, url, res, DA_UNAUTHORIZED_MESSAGE); +} + /** * HTTP client for da.live admin and api.aem.live (helix6) content APIs. */ @@ -88,7 +102,7 @@ export class ContentApiClient { } const res = await this.fetch(url, { headers, cache: 'reload' }); // eslint-disable-line no-await-in-loop if (res.status === 401) { - throw new Error(DA_UNAUTHORIZED_MESSAGE); + throw await buildUnauthorizedError('GET', url, res); // eslint-disable-line no-await-in-loop } if (!res.ok) { throw await buildHttpError('GET', url, res, `List failed for ${normalized}`); // eslint-disable-line no-await-in-loop @@ -120,7 +134,7 @@ export class ContentApiClient { const url = buildAemApiListUrl(org, repo, normalized); const res = await this.fetch(url, { headers: this.authHeader, cache: 'reload' }); if (res.status === 401) { - throw new Error(DA_UNAUTHORIZED_MESSAGE); + throw await buildUnauthorizedError('GET', url, res); } if (!res.ok) { throw await buildHttpError('GET', url, res, `List failed for ${normalized}`); @@ -147,7 +161,7 @@ export class ContentApiClient { : buildDaLiveSourceUrl(org, repo, normalized); const res = await this.fetch(url, { headers: this.authHeader, cache: 'reload' }); if (res.status === 401) { - throw new Error(DA_UNAUTHORIZED_MESSAGE); + throw await buildUnauthorizedError('GET', url, res); } if (res.status === 404) { return null; @@ -176,7 +190,7 @@ export class ContentApiClient { : buildDaLiveSourceUrl(org, repo, normalized); const res = await this.fetch(url, { headers: this.authHeader, cache: 'reload' }); if (res.status === 401) { - throw new Error(DA_UNAUTHORIZED_MESSAGE); + throw await buildUnauthorizedError('GET', url, res); } if (res.status === 404) { return null; @@ -222,7 +236,7 @@ export class ContentApiClient { body, }); if (putRes.status === 401) { - throw new Error(DA_UNAUTHORIZED_MESSAGE); + throw await buildUnauthorizedError('PUT', url, putRes); } if (putRes.ok) { return; @@ -235,7 +249,7 @@ export class ContentApiClient { body: post.body, }); if (postRes.status === 401) { - throw new Error(DA_UNAUTHORIZED_MESSAGE); + throw await buildUnauthorizedError('POST', url, postRes); } if (postRes.ok) { return; @@ -263,7 +277,7 @@ export class ContentApiClient { headers: this.authHeader, }); if (res.status === 401) { - throw new Error(DA_UNAUTHORIZED_MESSAGE); + throw await buildUnauthorizedError('DELETE', url, res); } if (!res.ok && res.status !== 404) { throw await buildHttpError('DELETE', url, res, `Delete failed for ${normalized}`); @@ -285,7 +299,7 @@ export class ContentApiClient { body: JSON.stringify({ paths, forceAsync: true }), }); if (res.status === 401) { - throw new Error(DA_UNAUTHORIZED_MESSAGE); + throw await buildUnauthorizedError('POST', url, res); } if (!res.ok && res.status !== 202) { throw await buildHttpError('POST', url, res, 'Bulk preview failed'); @@ -308,7 +322,7 @@ export class ContentApiClient { body: JSON.stringify({ paths, forceAsync: true }), }); if (res.status === 401) { - throw new Error(DA_UNAUTHORIZED_MESSAGE); + throw await buildUnauthorizedError('POST', url, res); } if (!res.ok && res.status !== 202) { throw await buildHttpError('POST', url, res, 'Bulk publish failed'); @@ -328,7 +342,7 @@ export class ContentApiClient { const url = buildAemApiJobUrl(org, repo, topic, jobName); const res = await this.fetch(url, { headers: this.authHeader }); if (res.status === 401) { - throw new Error(DA_UNAUTHORIZED_MESSAGE); + throw await buildUnauthorizedError('GET', url, res); } if (!res.ok && res.status !== 202) { throw await buildHttpError('GET', url, res, 'Job status failed'); diff --git a/src/main/da-session.js b/src/main/da-session.js new file mode 100644 index 0000000..7e7e76f --- /dev/null +++ b/src/main/da-session.js @@ -0,0 +1,167 @@ +/* + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import { clearStoredToken, isTokenExpired, loadStoredToken } from './da-auth.js'; +import { DA_UNAUTHORIZED_MESSAGE } from './content-api-shared.js'; +import { PREVIEW_WEBVIEW_PARTITION } from './content-da-live-auth.js'; +import { saveSiteTokens } from './site-token-store.js'; + +/** Origins that may retain IMS or DA credentials in Electron storage. */ +export const DA_AUTH_STORAGE_ORIGINS = [ + 'https://ims-na1.adobelogin.com', + 'https://admin.da.live', + 'https://content.da.live', + 'https://api.aem.live', +]; + +/** + * Decodes the payload of an IMS access token (a JWT) without verifying it — + * for diagnostics only. + * + * @param {string} accessToken + * @returns {Record|null} + */ +export function decodeImsTokenClaims(accessToken) { + try { + const payload = accessToken.split('.')[1]; + const claims = JSON.parse(Buffer.from(payload, 'base64url').toString('utf8')); + return claims && typeof claims === 'object' ? claims : null; + } catch { + return null; + } +} + +/** + * One-line description of a stored token for error messages and logs: + * which client/user it was minted for and when it was issued/expires. + * Never includes the token value itself. + * + * @param {{ access_token?: string, expires_at?: number|null }|null} stored + * @returns {string} + */ +export function describeTokenDiagnostics(stored) { + if (!stored?.access_token) { + return 'no stored token'; + } + const parts = []; + const claims = decodeImsTokenClaims(stored.access_token); + if (claims) { + if (claims.client_id) { + parts.push(`client_id=${claims.client_id}`); + } + if (claims.user_id) { + parts.push(`user=${claims.user_id}`); + } + const createdAt = Number(claims.created_at); + if (createdAt) { + parts.push(`issued ${new Date(createdAt).toISOString()}`); + const expiresIn = Number(claims.expires_in); + if (expiresIn) { + parts.push(`token expires ${new Date(createdAt + expiresIn).toISOString()}`); + } + } + } + if (stored.expires_at) { + parts.push(`stored expiry ${new Date(stored.expires_at).toISOString()}`); + } + return parts.length > 0 ? parts.join(', ') : 'token present but not a decodable JWT'; +} + +/** @type {import('electron').ClearStorageDataOptions['storages']} */ +export const DA_AUTH_STORAGE_TYPES = [ + 'cookies', + 'localstorage', + 'cachestorage', + 'serviceworkers', + 'indexdb', +]; + +/** + * @param {string} tokenPath + * @returns {Promise} + */ +export async function resolveStoredAccessToken(tokenPath) { + const stored = await loadStoredToken(tokenPath); + if (!stored?.access_token) { + throw new Error( + `${DA_UNAUTHORIZED_MESSAGE} — No DA token file at ${tokenPath}. Sign in to AEM.`, + ); + } + if (isTokenExpired(stored)) { + const expiry = stored.expires_at + ? new Date(stored.expires_at).toISOString() + : 'expiry not recorded'; + throw new Error( + `${DA_UNAUTHORIZED_MESSAGE} — Stored token expired (${expiry}). Sign in again.`, + ); + } + return stored.access_token; +} + +/** + * Clears cookies and web storage for IMS / DA origins on the default session + * and the given partitions. Without this, a persistent IMS session cookie + * (e.g. in the preview-login partition) silently re-mints tokens for the + * previously signed-in user after a sign-out. + * + * @param {typeof import('electron').session} electronSession + * @param {string[]} [partitions] + */ +export async function clearDaAuthStorage( + electronSession, + partitions = [PREVIEW_WEBVIEW_PARTITION], +) { + const options = { + storages: DA_AUTH_STORAGE_TYPES, + origins: DA_AUTH_STORAGE_ORIGINS, + }; + await electronSession.defaultSession.clearStorageData(options); + await Promise.all(partitions.map( + (partition) => electronSession.fromPartition(partition).clearStorageData(options), + )); +} + +/** + * Removes persisted and in-memory DA credentials: the IMS token file, the + * per-site preview tokens, in-memory token caches, and IMS/DA cookies + web + * storage in Electron sessions. + * + * @param {{ + * tokenPath: string, + * siteTokensPath?: string, + * electronSession?: typeof import('electron').session, + * partitions?: string[], + * clearContentAuthCache?: () => void, + * clearPreviewCaches?: () => void, + * resetSiteTokensCache?: () => void, + * }} options + */ +export async function invalidateDaSession({ + tokenPath, + siteTokensPath, + electronSession, + partitions, + clearContentAuthCache, + clearPreviewCaches, + resetSiteTokensCache, +}) { + await clearStoredToken(tokenPath); + clearContentAuthCache?.(); + resetSiteTokensCache?.(); + if (siteTokensPath) { + await saveSiteTokens(siteTokensPath, {}); + } + clearPreviewCaches?.(); + if (electronSession) { + await clearDaAuthStorage(electronSession, partitions); + } +} diff --git a/src/main/index.js b/src/main/index.js index 716ae87..a94f8fc 100644 --- a/src/main/index.js +++ b/src/main/index.js @@ -28,8 +28,13 @@ import { import { ContentApiClient } from './content-api-client.js'; import { HttpRequestError } from './http-request-error.js'; import { - DA_TOKEN_FILENAME, clearStoredToken, getAuthStatus, getValidToken, logout, + DA_TOKEN_FILENAME, getAuthStatus, getValidToken, loadStoredToken, } from './da-auth.js'; +import { + describeTokenDiagnostics, + invalidateDaSession, + resolveStoredAccessToken, +} from './da-session.js'; import { isSiteTokenExpired, loadSiteTokens, @@ -41,7 +46,7 @@ import { adminBaseForApiBackend, parsePreviewRef, } from './preview-login-url.js'; -import { openPreviewLogin } from './preview-login.js'; +import { openPreviewLogin, PREVIEW_LOGIN_PARTITION } from './preview-login.js'; import { addSiteFromUrl, findSite, loadSites, removeSite, saveSites, } from './site-store.js'; @@ -51,6 +56,7 @@ import { parseDocumentHtml } from './document-view-html.js'; import { diffDocumentHtml } from './document-view-diff.js'; import { initContentDaLiveAuth, + PREVIEW_WEBVIEW_PARTITION, } from './content-da-live-auth.js'; import { buildPreviewUrl, buildProxyPreviewUrl } from './preview-url.js'; import { startPreviewServer } from './preview-server.js'; @@ -228,21 +234,71 @@ async function loginPreviewSite(site) { } } -async function withContentClient(site, fn) { - const accessToken = await getValidToken({ +/** + * Removes every trace of the DA sign-in: token file, per-site preview tokens, + * in-memory caches, and IMS/DA cookies + storage in all Electron sessions. + */ +async function invalidateCurrentDaSession() { + await invalidateDaSession({ tokenPath: tokenPath(), - openBrowser: (url) => shell.openExternal(url), + siteTokensPath: siteTokensPath(), + electronSession: session, + partitions: [PREVIEW_WEBVIEW_PARTITION, PREVIEW_LOGIN_PARTITION], + clearContentAuthCache: () => contentDaLiveAuth?.clearCache(), + clearPreviewCaches: () => previewRegistry?.clearHeadCache(), + resetSiteTokensCache: () => { + siteTokensCache = null; + }, }); +} + +/** + * Enriches an unauthorized error with token diagnostics, wipes the now-known- + * bad session, and tells the renderer to fall back to the sign-in screen. + * + * @param {Error} err + * @param {{ org: string, repo: string }} site + * @param {string} backend + * @returns {Promise} the error to rethrow + */ +async function handleDaUnauthorized(err, site, backend) { + const stored = await loadStoredToken(tokenPath()); + const diagnostics = describeTokenDiagnostics(stored); + log.scope('da-auth').warn( + `unauthorized for ${site.org}/${site.repo} via ${backend}: ${err.message} [${diagnostics}]`, + ); + + try { + await invalidateCurrentDaSession(); + } catch (cleanupErr) { + log.scope('da-auth').warn(`session cleanup failed: ${cleanupErr.message}`); + } + + if (mainWindow && !mainWindow.isDestroyed()) { + mainWindow.webContents.send('da:session-expired', { message: err.message }); + } + + const detail = `${err.message}\n` + + `Site: ${site.org}/${site.repo} via ${backend}\n` + + `Token: ${diagnostics}\n` + + 'The stored sign-in was removed — use "Sign in to AEM" to start a fresh session.'; + if (err instanceof HttpRequestError) { + return new HttpRequestError(detail, err); + } + return new Error(detail); +} + +async function withContentClient(site, fn) { const backend = site.apiBackend || API_BACKEND_DA_LIVE; try { + // Never trigger a silent browser login from a background request: a + // missing/expired token throws an unauthorized error with the reason, + // and the renderer routes the user to the explicit Sign in button. + const accessToken = await resolveStoredAccessToken(tokenPath()); return await fn(new ContentApiClient(accessToken, backend)); } catch (err) { if (isDaUnauthorizedError(err)) { - await clearStoredToken(tokenPath()); - contentDaLiveAuth?.clearCache(); - if (mainWindow && !mainWindow.isDestroyed()) { - mainWindow.webContents.send('da:session-expired'); - } + throw await handleDaUnauthorized(err, site, backend); } throw err; } @@ -374,8 +430,7 @@ ipcMain.handle('da:login', async () => { }); ipcMain.handle('da:logout', async () => { - await logout(tokenPath()); - contentDaLiveAuth?.clearCache(); + await invalidateCurrentDaSession(); return getAuthStatus(tokenPath()); }); diff --git a/src/preload/index.cjs b/src/preload/index.cjs index 85b3481..fb50688 100644 --- a/src/preload/index.cjs +++ b/src/preload/index.cjs @@ -30,7 +30,7 @@ contextBridge.exposeInMainWorld('aemDesktop', { loginDa: () => ipcRenderer.invoke('da:login'), logoutDa: () => ipcRenderer.invoke('da:logout'), onDaSessionExpired: (callback) => { - const handler = () => callback(); + const handler = (_event, data) => callback(data); ipcRenderer.on('da:session-expired', handler); return () => ipcRenderer.removeListener('da:session-expired', handler); }, diff --git a/src/renderer/renderer.js b/src/renderer/renderer.js index 0c45701..70ea6a5 100644 --- a/src/renderer/renderer.js +++ b/src/renderer/renderer.js @@ -2922,8 +2922,15 @@ async function init() { await refreshAuthStatus(); await loadSites(); window.aemDesktop.onPreviewAuthRequired(handlePreviewAuthRequired); - window.aemDesktop.onDaSessionExpired(() => { - refreshAuthStatus(); + window.aemDesktop.onDaSessionExpired(async (info) => { + if (info?.message) { + console.warn(`[da] session expired: ${info.message}`); + } + await refreshAuthStatus(); + if (!state.authenticated) { + els.authStatus.textContent = 'AEM sign-in expired or was rejected — sign in again'; + els.authStatus.title = info?.message || ''; + } }); showView('home'); } diff --git a/test/content-api-client.test.js b/test/content-api-client.test.js index 31545f0..536352c 100644 --- a/test/content-api-client.test.js +++ b/test/content-api-client.test.js @@ -11,9 +11,13 @@ */ import { test } from 'node:test'; import assert from 'node:assert/strict'; -import { API_BACKEND_AEM_API, API_BACKEND_DA_LIVE } from '../src/main/content-api-shared.js'; +import { + API_BACKEND_AEM_API, + API_BACKEND_DA_LIVE, + isDaUnauthorizedError, +} from '../src/main/content-api-shared.js'; import { ContentApiClient } from '../src/main/content-api-client.js'; -import { composeHttpErrorMessage } from '../src/main/http-request-error.js'; +import { composeHttpErrorMessage, HttpRequestError } from '../src/main/http-request-error.js'; test('composeHttpErrorMessage includes x-error header', () => { const message = composeHttpErrorMessage({ @@ -29,6 +33,28 @@ test('composeHttpErrorMessage includes x-error header', () => { assert.match(message, /400 Bad Request/); }); +test('401 errors keep the unauthorized prefix and carry request detail', async () => { + const fetchImpl = async () => new Response('token rejected', { + status: 401, + statusText: 'Unauthorized', + headers: { 'x-error': 'IMS token validation failed' }, + }); + const client = new ContentApiClient('token', API_BACKEND_DA_LIVE, fetchImpl); + + await assert.rejects( + () => client.list('org', 'site', '/'), + (err) => { + assert.ok(isDaUnauthorizedError(err)); + assert.ok(err instanceof HttpRequestError); + assert.equal(err.status, 401); + assert.match(err.message, /GET https:\/\/admin\.da\.live\/list\/org\/site\//); + assert.match(err.message, /x-error: IMS token validation failed/); + assert.match(err.message, /token rejected/); + return true; + }, + ); +}); + test('uploadSource retries with POST when PUT returns 400', async () => { let calls = 0; const fetchImpl = async (_url, init) => { diff --git a/test/da-session.test.js b/test/da-session.test.js new file mode 100644 index 0000000..ee860ce --- /dev/null +++ b/test/da-session.test.js @@ -0,0 +1,176 @@ +/* + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtemp, readFile, writeFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { + clearDaAuthStorage, + DA_AUTH_STORAGE_ORIGINS, + DA_AUTH_STORAGE_TYPES, + decodeImsTokenClaims, + describeTokenDiagnostics, + invalidateDaSession, + resolveStoredAccessToken, +} from '../src/main/da-session.js'; +import { saveToken } from '../src/main/da-auth.js'; +import { DA_UNAUTHORIZED_MESSAGE } from '../src/main/content-api-shared.js'; +import { PREVIEW_WEBVIEW_PARTITION } from '../src/main/content-da-live-auth.js'; +import { loadSiteTokens } from '../src/main/site-token-store.js'; + +function fakeJwt(claims) { + const payload = Buffer.from(JSON.stringify(claims)).toString('base64url'); + return `header.${payload}.signature`; +} + +function fakeElectronSession() { + const calls = []; + return { + calls, + defaultSession: { + clearStorageData: async (options) => { + calls.push({ partition: 'default', options }); + }, + }, + fromPartition: (partition) => ({ + clearStorageData: async (options) => { + calls.push({ partition, options }); + }, + }), + }; +} + +test('resolveStoredAccessToken explains a missing token file', async () => { + const dir = await mkdtemp(join(tmpdir(), 'aem-desktop-session-')); + const tokenPath = join(dir, '.da-token.json'); + + await assert.rejects( + () => resolveStoredAccessToken(tokenPath), + (err) => { + assert.match(err.message, new RegExp(DA_UNAUTHORIZED_MESSAGE)); + assert.ok(err.message.includes(tokenPath)); + return true; + }, + ); +}); + +test('resolveStoredAccessToken explains an expired token', async () => { + const dir = await mkdtemp(join(tmpdir(), 'aem-desktop-session-')); + const tokenPath = join(dir, '.da-token.json'); + const expiresAt = Date.now() - 1000; + await saveToken(tokenPath, { access_token: 'stale', expires_at: expiresAt }); + + await assert.rejects( + () => resolveStoredAccessToken(tokenPath), + (err) => { + assert.match(err.message, new RegExp(DA_UNAUTHORIZED_MESSAGE)); + assert.ok(err.message.includes(new Date(expiresAt).toISOString())); + return true; + }, + ); +}); + +test('resolveStoredAccessToken returns a valid token', async () => { + const dir = await mkdtemp(join(tmpdir(), 'aem-desktop-session-')); + const tokenPath = join(dir, '.da-token.json'); + await saveToken(tokenPath, { access_token: 'good', expires_at: Date.now() + 3600_000 }); + + assert.equal(await resolveStoredAccessToken(tokenPath), 'good'); +}); + +test('decodeImsTokenClaims decodes a JWT payload and rejects garbage', () => { + const claims = { user_id: 'ABC@AdobeID', client_id: 'darkalley' }; + assert.deepEqual(decodeImsTokenClaims(fakeJwt(claims)), claims); + assert.equal(decodeImsTokenClaims('not-a-jwt'), null); + assert.equal(decodeImsTokenClaims(''), null); +}); + +test('describeTokenDiagnostics summarizes claims without leaking the token', () => { + const createdAt = Date.UTC(2026, 0, 2, 3, 4, 5); + const token = fakeJwt({ + user_id: 'ABC@AdobeID', + client_id: 'darkalley', + created_at: String(createdAt), + expires_in: '86400000', + }); + const storedExpiry = createdAt + 86_400_000; + + const text = describeTokenDiagnostics({ access_token: token, expires_at: storedExpiry }); + + assert.ok(text.includes('client_id=darkalley')); + assert.ok(text.includes('user=ABC@AdobeID')); + assert.ok(text.includes(new Date(createdAt).toISOString())); + assert.ok(text.includes(new Date(storedExpiry).toISOString())); + assert.ok(!text.includes(token)); +}); + +test('describeTokenDiagnostics handles missing and opaque tokens', () => { + assert.equal(describeTokenDiagnostics(null), 'no stored token'); + assert.equal(describeTokenDiagnostics({}), 'no stored token'); + assert.equal( + describeTokenDiagnostics({ access_token: 'opaque' }), + 'token present but not a decodable JWT', + ); +}); + +test('clearDaAuthStorage clears default session and given partitions', async () => { + const ses = fakeElectronSession(); + + await clearDaAuthStorage(ses, [PREVIEW_WEBVIEW_PARTITION, 'persist:aem-preview-login']); + + const partitions = ses.calls.map((c) => c.partition); + assert.deepEqual( + partitions.sort(), + ['default', PREVIEW_WEBVIEW_PARTITION, 'persist:aem-preview-login'].sort(), + ); + for (const call of ses.calls) { + assert.deepEqual(call.options.origins, DA_AUTH_STORAGE_ORIGINS); + assert.deepEqual(call.options.storages, DA_AUTH_STORAGE_TYPES); + } +}); + +test('invalidateDaSession removes every stored and cached credential', async () => { + const dir = await mkdtemp(join(tmpdir(), 'aem-desktop-session-')); + const tokenPath = join(dir, '.da-token.json'); + const siteTokensPath = join(dir, '.site-tokens.json'); + await saveToken(tokenPath, { access_token: 'tok', expires_at: Date.now() + 3600_000 }); + await writeFile( + siteTokensPath, + JSON.stringify({ 'https://main--site--org.aem.page': { token: 'site-tok', expiresAt: null } }), + 'utf8', + ); + + const ses = fakeElectronSession(); + const called = { content: 0, preview: 0, siteCache: 0 }; + + await invalidateDaSession({ + tokenPath, + siteTokensPath, + electronSession: ses, + partitions: [PREVIEW_WEBVIEW_PARTITION, 'persist:aem-preview-login'], + clearContentAuthCache: () => { called.content += 1; }, + clearPreviewCaches: () => { called.preview += 1; }, + resetSiteTokensCache: () => { called.siteCache += 1; }, + }); + + await assert.rejects(() => readFile(tokenPath, 'utf8')); + assert.deepEqual(await loadSiteTokens(siteTokensPath), {}); + assert.deepEqual(called, { content: 1, preview: 1, siteCache: 1 }); + assert.equal(ses.calls.length, 3); +}); + +test('invalidateDaSession tolerates a missing token file', async () => { + const dir = await mkdtemp(join(tmpdir(), 'aem-desktop-session-')); + + await invalidateDaSession({ tokenPath: join(dir, '.da-token.json') }); +});