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
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,8 @@ after(async () => {
await server.close();
});

test('router.html: the #result panel is actually invisible on load, not just marked [hidden]', async () => {
await page.goto(`${server.origin}/router.html`, { waitUntil: 'networkidle0' });
test('local.html: the #result panel is actually invisible on load, not just marked [hidden]', async () => {
await page.goto(`${server.origin}/local.html`, { waitUntil: 'networkidle0' });

const { hasAttribute, renderedHeight } = await page.evaluate(() => {
const result = document.getElementById('result') as HTMLElement;
Expand Down
73 changes: 73 additions & 0 deletions e2e/local-to-app-embed.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
/** Real, navigation-free handoff from local.html to the embedded 0x67 app —
* jsdom tests each page in isolation and can't exercise a real iframe.
*
* Regression coverage for the fix that replaced router.html's link-based
* handoff — which discarded the File object on navigation, forcing the user
* to reselect the same file on 0x67.html's own upload screen — with an
* in-page iframe embed that hands the already-read bytes straight to the app
* over postMessage. */
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('dropping a file on local.html embeds a working 0x67 app that unlocks the same file, with no reselection', 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, 'a recognized file embeds the app in an iframe');
assert.equal(
page.url(),
`${server.origin}/local.html`,
'still on local.html — recognizing the file did not navigate away',
);

const iframeFrame = await iframeElement.contentFrame();
assert.ok(iframeFrame, 'the iframe has a content frame');

// No second file input here: the bytes local.html already read are handed
// straight to the embedded app, so it opens directly on its unlock screen.
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');
const titleText = await iframeFrame.$eval('.entry-table-title', (el) => el.textContent);
assert.ok(
titleText?.includes(fixture.entryTitle),
`unlocked vault shows the fixture entry, got "${titleText}"`,
);
});
73 changes: 0 additions & 73 deletions e2e/router-to-app-navigation.test.ts

This file was deleted.

14 changes: 14 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
},
"scripts": {
"clean": "rm -rf build dist",
"build:deps": "npm run build --workspace=argon2 --workspace=chacha20 --workspace=kdbx",
"build:deps": "npm run build --workspace=argon2 --workspace=chacha20 --workspace=kdbx --workspace=router --workspace=embed-protocol",
"build": "npm run build:deps && npm run build --workspace=pages",
"typecheck": "npm run build:deps && npm run typecheck --workspaces --if-present && tsc --noEmit -p tools/serve-dist/tsconfig.json",
"lint": "biome check .",
Expand Down
13 changes: 13 additions & 0 deletions packages/embed-protocol/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
{
"name": "embed-protocol",
"version": "0.0.0",
"private": true,
"type": "module",
"description": "The same-origin postMessage protocol between a keepass-web implementation (e.g. 0x67) and whatever host page embeds it in an iframe.",
"sideEffects": false,
"scripts": {
"build": "tsc --project tsconfig.build.json",
"typecheck": "tsc --noEmit",
"test": "node --experimental-strip-types --experimental-test-coverage --test-coverage-lines=100 --test-coverage-branches=100 --test-coverage-functions=100 --test-coverage-include='src/**/*.ts' --test 'tests/**/*.test.ts'"
}
}
134 changes: 134 additions & 0 deletions packages/embed-protocol/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
/**
* `embed-protocol` — the same-origin postMessage contract between a
* keepass-web implementation (currently only `0x67`) and whatever host page
* embeds it in an iframe (the local-file chooser, the Google Drive
* connector, and future sources).
*
* Message shapes and guards used to be hand-written twice: once inline in
* 0x67/page.ts (the app side) and once in each host's own logic.ts (the host
* side). Nothing but manual care kept those two hand-written copies in sync.
* Centralizing both the guards *and* the builders here means the two ends of
* the protocol are provably using the same wire format, not just similarly
* shaped code.
*
* Six message types, each read by exactly one side and built by the other:
* app → host : kw-ready (built by the app, read by the host)
* host → app : kw-open (built by the host, read by the app)
* app → host : kw-save (built by the app, read by the host)
* host → app : kw-saved (built by the host, read by the app)
* host → app : kw-close-request (built by the host, read by the app)
* app → host : kw-close-ack (built by the app, read by the host)
* app → host : kw-close (built by the app, read by the host)
*/

export interface ReadyMessage {
type: 'kw-ready';
}

export interface OpenMessage {
type: 'kw-open';
filename: string;
bytes: ArrayBuffer;
}

export interface SaveMessage {
type: 'kw-save';
filename: string;
bytes: ArrayBuffer;
}

export interface SavedMessage {
type: 'kw-saved';
ok: boolean;
error?: string;
}

export interface CloseRequestMessage {
type: 'kw-close-request';
}

export interface CloseAckMessage {
type: 'kw-close-ack';
}

export interface CloseMessage {
type: 'kw-close';
}

function hasType(data: unknown, type: string): data is { type: string } {
return (
data !== null && typeof data === 'object' && (data as Record<string, unknown>).type === type
);
}

function isFileMessage(
data: unknown,
type: 'kw-open' | 'kw-save',
): data is { type: string; filename: string; bytes: ArrayBuffer } {
if (!hasType(data, type)) return false;
const rec = data as Record<string, unknown>;
return typeof rec.filename === 'string' && rec.bytes instanceof ArrayBuffer;
}

// --- Guards ------------------------------------------------------------

export function isReadyMessage(data: unknown): data is ReadyMessage {
return hasType(data, 'kw-ready');
}

export function isOpenMessage(data: unknown): data is OpenMessage {
return isFileMessage(data, 'kw-open');
}

export function isSaveMessage(data: unknown): data is SaveMessage {
return isFileMessage(data, 'kw-save');
}

export function isSavedMessage(data: unknown): data is SavedMessage {
if (!hasType(data, 'kw-saved')) return false;
const rec = data as Record<string, unknown>;
if (typeof rec.ok !== 'boolean') return false;
return rec.error === undefined || typeof rec.error === 'string';
}

export function isCloseRequestMessage(data: unknown): data is CloseRequestMessage {
return hasType(data, 'kw-close-request');
}

export function isCloseAckMessage(data: unknown): data is CloseAckMessage {
return hasType(data, 'kw-close-ack');
}

export function isCloseMessage(data: unknown): data is CloseMessage {
return hasType(data, 'kw-close');
}

// --- Builders ------------------------------------------------------------

export function readyMessage(): ReadyMessage {
return { type: 'kw-ready' };
}

export function openMessage(filename: string, bytes: ArrayBuffer): OpenMessage {
return { type: 'kw-open', filename, bytes };
}

export function saveMessage(filename: string, bytes: ArrayBuffer): SaveMessage {
return { type: 'kw-save', filename, bytes };
}

export function savedMessage(ok: boolean, error?: string): SavedMessage {
return error === undefined ? { type: 'kw-saved', ok } : { type: 'kw-saved', ok, error };
}

export function closeRequestMessage(): CloseRequestMessage {
return { type: 'kw-close-request' };
}

export function closeAckMessage(): CloseAckMessage {
return { type: 'kw-close-ack' };
}

export function closeMessage(): CloseMessage {
return { type: 'kw-close' };
}
37 changes: 37 additions & 0 deletions packages/embed-protocol/tests/coverage.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { readdir } from 'node:fs/promises';
import { join } from 'node:path';
import { test } from 'node:test';
import { fileURLToPath, pathToFileURL } from 'node:url';

/**
* node:test's coverage collector only reports on files V8 actually loads
* during the run — a source file with zero tests doesn't show up as 0%, it
* doesn't show up at all, and the overall percentage is computed only over
* what *is* loaded. That means a completely untested file can't fail the
* --test-coverage-* thresholds; it just silently doesn't count. See
* docs/CONTRIBUTING.md.
*
* Importing every file under src/ here closes that hole: once a file is
* loaded, any of its untested lines/branches/functions are visible in the
* report and do fail the threshold, the same as an untested branch in a file
* that's already exercised elsewhere.
*/

const srcDir = fileURLToPath(new URL('../src', import.meta.url));

async function* walk(dir: string): AsyncGenerator<string> {
for (const entry of await readdir(dir, { withFileTypes: true })) {
const full = join(dir, entry.name);
if (entry.isDirectory()) {
yield* walk(full);
} else if (entry.name.endsWith('.ts') && !entry.name.endsWith('.d.ts')) {
yield full;
}
}
}

test('every src module loads, so an untested file cannot hide from coverage', async () => {
for await (const file of walk(srcDir)) {
await import(pathToFileURL(file).href);
}
});
Loading