Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
87 changes: 87 additions & 0 deletions e2e/auto-lock.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
/** Auto-lock depends on the embedded app seeing the tab go away — but the app
* runs in an iframe, and only the top-level tab is ever hidden or shown. That
* a frame's visibilityState follows its tab is a real-browser fact jsdom has
* no way to demonstrate, so this drives a second tab in front of the first
* and waits for the database to lock itself. */
import assert from 'node:assert/strict';
import { basename } from 'node:path';
import { after, before, test } from 'node:test';
import { fileURLToPath } from 'node:url';
import puppeteer, { type Browser, type ElementHandle, type Page } from 'puppeteer-core';
import { resolveChromePath } from './support/chrome.ts';
import { type DistServer, startDistServer } from './support/dist-server.ts';
import { writeKdbxFixture } from './support/fixture.ts';
import { resolveLaunchOptions } from './support/launch-options.ts';

const distDir = fileURLToPath(new URL('../dist', import.meta.url));
// The settings dialog's floor, chosen here so the wait below is ten seconds
// rather than the thirty a fresh session defaults to.
const DELAY_SECONDS = 10;

let server: DistServer;
let browser: Browser;
let page: Page;

before(async () => {
server = await startDistServer(distDir);
browser = await puppeteer.launch({
executablePath: resolveChromePath(),
...resolveLaunchOptions(),
args: ['--no-sandbox'],
});
page = await browser.newPage();
});

after(async () => {
await browser.close();
await server.close();
});

test('a tab left hidden locks the embedded database on its own', async () => {
const fixture = await writeKdbxFixture();

await page.goto(`${server.origin}/local.html`, { waitUntil: 'networkidle0' });

// waitForSelector can't infer the element type from an id selector.
const fileInput = (await page.waitForSelector('#file-input')) as ElementHandle<HTMLInputElement>;
assert.ok(fileInput, 'the file input exists');
await fileInput.uploadFile(fixture.path);

const iframeElement = await page.waitForSelector('#app-frame');
assert.ok(iframeElement, 'the app is embedded');
const frame = await iframeElement.contentFrame();
assert.ok(frame, 'the iframe has a content frame');

const passwordInput = await frame.waitForSelector('#master-password');
assert.ok(passwordInput, 'the embedded app is on its unlock screen');
await passwordInput.type(fixture.password);
await frame.click('#unlock-btn');
await frame.waitForSelector('.entry-table');

await frame.click('[data-action="settings"]');
await frame.waitForFunction(
() => document.querySelector<HTMLDialogElement>('#dlg-settings')?.open === true,
);
const delayInput = (await frame.waitForSelector(
'#auto-lock-timeout',
)) as ElementHandle<HTMLInputElement>;
assert.ok(delayInput, 'the settings dialog offers the auto-lock delay');
await delayInput.evaluate((el, seconds: number) => {
el.value = String(seconds);
}, DELAY_SECONDS);
await frame.click('#dlg-settings [data-action="save-settings"]');

// Somewhere else is now in front, so the database's tab is hidden.
const otherTab = await browser.newPage();
await otherTab.bringToFront();

await frame.waitForSelector('#master-password', { timeout: (DELAY_SECONDS + 20) * 1000 });

await page.bringToFront();
await otherTab.close();
assert.equal(
await page.title(),
`🔒 ${basename(fixture.path)} - KeePass Web - Local file`,
'and the tab bar shows it locked, without being opened',
);
});
4 changes: 4 additions & 0 deletions e2e/local-to-app-embed.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,10 @@ before(async () => {
args: ['--no-sandbox'],
});
page = await browser.newPage();
// The second test navigates away from the database the first one left
// unlocked, and an open database now makes Chrome ask first (#66). That
// prompt is the subject of navigation-guard.test.ts; here it is in the way.
page.on('dialog', (dialog) => void dialog.accept());
});

after(async () => {
Expand Down
74 changes: 74 additions & 0 deletions e2e/navigation-guard.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
/** Whether a descendant frame's beforeunload actually blocks a top-level
* reload or back is a real-browser question: the guard lives in the embedded
* 0x67 app, but the navigation belongs to local.html, and jsdom has no
* navigation to block. Chrome also only honors the guard once the frame has
* user activation, which no unit test can produce. */
import assert from 'node:assert/strict';
import { after, before, test } from 'node:test';
import { fileURLToPath } from 'node:url';
import puppeteer, { type Browser, type ElementHandle, type Page } from 'puppeteer-core';
import { resolveChromePath } from './support/chrome.ts';
import { type DistServer, startDistServer } from './support/dist-server.ts';
import { writeKdbxFixture } from './support/fixture.ts';
import { resolveLaunchOptions } from './support/launch-options.ts';

const distDir = fileURLToPath(new URL('../dist', import.meta.url));

let server: DistServer;
let browser: Browser;
let page: Page;

before(async () => {
server = await startDistServer(distDir);
browser = await puppeteer.launch({
executablePath: resolveChromePath(),
...resolveLaunchOptions(),
args: ['--no-sandbox'],
});
page = await browser.newPage();
});

after(async () => {
await browser.close();
await server.close();
});

test('an open database makes the browser ask before it reloads the tab', async () => {
const fixture = await writeKdbxFixture();

await page.goto(`${server.origin}/local.html`, { waitUntil: 'networkidle0' });

// waitForSelector can't infer the element type from an id selector.
const fileInput = (await page.waitForSelector('#file-input')) as ElementHandle<HTMLInputElement>;
assert.ok(fileInput, 'the file input exists');
await fileInput.uploadFile(fixture.path);

const iframeElement = await page.waitForSelector('#app-frame');
assert.ok(iframeElement, 'the app is embedded');
const iframeFrame = await iframeElement.contentFrame();
assert.ok(iframeFrame, 'the iframe has a content frame');

// Typing and clicking here is also what gives the frame the user activation
// Chrome requires before honoring its beforeunload at all.
const passwordInput = await iframeFrame.waitForSelector('#master-password');
assert.ok(passwordInput, 'the embedded app is on its unlock screen');
await passwordInput.type(fixture.password);
await iframeFrame.click('#unlock-btn');
await iframeFrame.waitForSelector('.entry-table');

const prompts: string[] = [];
page.on('dialog', async (dialog) => {
prompts.push(dialog.type());
// Accept, so the reload proceeds and this test never waits on a
// navigation that was cancelled out from under it.
await dialog.accept();
});

await page.reload({ waitUntil: 'networkidle0' });
assert.deepEqual(prompts, ['beforeunload'], 'an open database is worth asking about');

// The reload landed back on an empty chooser, so there is nothing to lose.
await page.waitForSelector('#drop-zone');
await page.reload({ waitUntil: 'networkidle0' });
assert.equal(prompts.length, 1, 'a tab holding no database reloads without a word');
});
89 changes: 89 additions & 0 deletions e2e/tab-title.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
/** The tab title belongs to local.html, but only the embedded 0x67 app knows
* which database is open and whether it is locked — so the title is right
* only if a real cross-document postMessage is delivered and handled. The
* jsdom suites test each page in its own isolated window and cannot show
* that; this drives the built distributables in Chrome, where the two
* documents really are separate. */
import assert from 'node:assert/strict';
import { basename } from 'node:path';
import { after, before, test } from 'node:test';
import { fileURLToPath } from 'node:url';
import puppeteer, { type Browser, type ElementHandle, type Page } from 'puppeteer-core';
import { resolveChromePath } from './support/chrome.ts';
import { type DistServer, startDistServer } from './support/dist-server.ts';
import { writeKdbxFixture } from './support/fixture.ts';
import { resolveLaunchOptions } from './support/launch-options.ts';

const distDir = fileURLToPath(new URL('../dist', import.meta.url));
const BASE_TITLE = 'KeePass Web - Local file';

let server: DistServer;
let browser: Browser;
let page: Page;

before(async () => {
server = await startDistServer(distDir);
browser = await puppeteer.launch({
executablePath: resolveChromePath(),
...resolveLaunchOptions(),
args: ['--no-sandbox'],
});
page = await browser.newPage();
});

after(async () => {
await browser.close();
await server.close();
});

/** The title lands a turn after the DOM change that triggers it, once the
* iframe's message has crossed to the host. On timeout, re-assert so the
* failure names the title that was actually there instead of just "timed out". */
async function waitForTitle(expected: string): Promise<void> {
try {
await page.waitForFunction(
(want: string) => document.title === want,
{ timeout: 5000 },
expected,
);
} catch {
assert.equal(await page.title(), expected);
}
}

test('the tab names the open database and tracks its lock state', async () => {
const fixture = await writeKdbxFixture();
const filename = basename(fixture.path);

await page.goto(`${server.origin}/local.html`, { waitUntil: 'networkidle0' });
assert.equal(await page.title(), BASE_TITLE, 'nothing open, so the tab is just this page');

// waitForSelector can't infer the element type from an id selector.
const fileInput = (await page.waitForSelector('#file-input')) as ElementHandle<HTMLInputElement>;
assert.ok(fileInput, 'the file input exists');
await fileInput.uploadFile(fixture.path);

const iframeElement = await page.waitForSelector('#app-frame');
assert.ok(iframeElement, 'a recognized file embeds the app in an iframe');
const iframeFrame = await iframeElement.contentFrame();
assert.ok(iframeFrame, 'the iframe has a content frame');

await waitForTitle(`🔒 ${filename} - ${BASE_TITLE}`);

const passwordInput = await iframeFrame.waitForSelector('#master-password');
assert.ok(passwordInput, 'the embedded app went straight to its unlock screen');
await passwordInput.type(fixture.password);
await iframeFrame.click('#unlock-btn');

await iframeFrame.waitForSelector('.entry-table');
await waitForTitle(`🔓 ${filename} - ${BASE_TITLE}`);

// Nothing is unsaved, but the app still asks before giving the database up.
await page.click('[data-action="back-to-chooser"]');
await iframeFrame.waitForFunction(
() => document.querySelector<HTMLDialogElement>('#dlg-confirm-discard')?.open === true,
);
await iframeFrame.click('#dlg-confirm-discard [data-action="confirm-discard"]');
await page.waitForSelector('#drop-zone');
await waitForTitle(BASE_TITLE);
});
19 changes: 18 additions & 1 deletion packages/embed-protocol/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
keepass-web implementation and whatever host embeds it in an iframe.
Centralizes shapes/guards/builders (previously duplicated per side) so
both ends provably agree on the wire format: kw-ready, kw-open, kw-create,
kw-save, kw-saved, kw-close-request, kw-close-ack, kw-close. */
kw-save, kw-saved, kw-title, kw-close-request, kw-close-ack, kw-close. */

export interface ReadyMessage {
type: 'kw-ready';
Expand Down Expand Up @@ -31,6 +31,13 @@ export interface SavedMessage {
error?: string;
}

// The host document owns the tab title, so the app reports state rather than setting it (#65).
export interface TitleMessage {
type: 'kw-title';
filename: string;
locked: boolean;
}

export interface CloseRequestMessage {
type: 'kw-close-request';
}
Expand Down Expand Up @@ -83,6 +90,12 @@ export function isSavedMessage(data: unknown): data is SavedMessage {
return rec.error === undefined || typeof rec.error === 'string';
}

export function isTitleMessage(data: unknown): data is TitleMessage {
if (!hasType(data, 'kw-title')) return false;
const rec = data as Record<string, unknown>;
return typeof rec.filename === 'string' && typeof rec.locked === 'boolean';
}

export function isCloseRequestMessage(data: unknown): data is CloseRequestMessage {
return hasType(data, 'kw-close-request');
}
Expand Down Expand Up @@ -117,6 +130,10 @@ export function savedMessage(ok: boolean, error?: string): SavedMessage {
return error === undefined ? { type: 'kw-saved', ok } : { type: 'kw-saved', ok, error };
}

export function titleMessage(filename: string, locked: boolean): TitleMessage {
return { type: 'kw-title', filename, locked };
}

export function closeRequestMessage(): CloseRequestMessage {
return { type: 'kw-close-request' };
}
Expand Down
14 changes: 14 additions & 0 deletions packages/embed-protocol/tests/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,12 @@ import {
isReadyMessage,
isSavedMessage,
isSaveMessage,
isTitleMessage,
openMessage,
readyMessage,
savedMessage,
saveMessage,
titleMessage,
} from '../src/index.ts';

test('readyMessage / isReadyMessage round-trip', () => {
Expand Down Expand Up @@ -70,6 +72,18 @@ test('savedMessage / isSavedMessage round-trip, with and without an error', () =
assert.equal(isSavedMessage({ type: 'kw-saved', ok: true, error: 42 }), false);
});

test('titleMessage / isTitleMessage round-trip', () => {
assert.deepEqual(titleMessage('vault.kdbx', true), {
type: 'kw-title',
filename: 'vault.kdbx',
locked: true,
});
assert.equal(isTitleMessage(titleMessage('vault.kdbx', false)), true);
assert.equal(isTitleMessage(null), false);
assert.equal(isTitleMessage({ type: 'kw-title', filename: 'vault.kdbx' }), false);
assert.equal(isTitleMessage({ type: 'kw-title', filename: 42, locked: true }), false);
});

test('closeRequestMessage / isCloseRequestMessage round-trip', () => {
assert.deepEqual(closeRequestMessage(), { type: 'kw-close-request' });
assert.equal(isCloseRequestMessage(closeRequestMessage()), true);
Expand Down
2 changes: 2 additions & 0 deletions pages/0x67/bundle-iife.json
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@
"applyEntryEdits",
"isCustomField",
"isValidClipboardTimeout",
"isValidAutoLockTimeout",
"generatePassword",
"elementIconId",
"iconEmoji",
Expand All @@ -89,6 +90,7 @@
"isCloseRequestMessage",
"readyMessage",
"saveMessage",
"titleMessage",
"closeAckMessage",
"closeMessage"
]
Expand Down
7 changes: 7 additions & 0 deletions pages/0x67/globals.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,11 @@ interface SavedMessage {
ok: boolean;
error?: string;
}
interface TitleMessage {
type: 'kw-title';
filename: string;
locked: boolean;
}
interface CloseRequestMessage {
type: 'kw-close-request';
}
Expand All @@ -52,6 +57,7 @@ declare function isSavedMessage(data: unknown): data is SavedMessage;
declare function isCloseRequestMessage(data: unknown): data is CloseRequestMessage;
declare function readyMessage(): ReadyMessage;
declare function saveMessage(filename: string, bytes: ArrayBuffer): SaveMessage;
declare function titleMessage(filename: string, locked: boolean): TitleMessage;
declare function closeAckMessage(): CloseAckMessage;
declare function closeMessage(): CloseMessage;

Expand Down Expand Up @@ -195,6 +201,7 @@ interface EditedField {
declare function applyEntryEdits(entry: XmlElement, fields: EditedField[]): void;
declare function isCustomField(key: string): boolean;
declare function isValidClipboardTimeout(seconds: number): boolean;
declare function isValidAutoLockTimeout(seconds: number): boolean;

interface PasswordGeneratorOptions {
length: number;
Expand Down
5 changes: 5 additions & 0 deletions pages/0x67/logic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -309,6 +309,11 @@ export function isValidClipboardTimeout(seconds: number): boolean {
return !Number.isNaN(seconds) && seconds >= 5;
}

/** The settings dialog's minimum accepted auto-lock delay, in seconds. */
export function isValidAutoLockTimeout(seconds: number): boolean {
return !Number.isNaN(seconds) && seconds >= 10;
}

/** Character classes offered by the password generator. */
const GENERATOR_CHARSETS = {
upper: 'ABCDEFGHIJKLMNOPQRSTUVWXYZ',
Expand Down
Loading