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
3 changes: 3 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,9 @@ jobs:
run: npm ci
- name: Install Playwright browsers
run: npx playwright install --with-deps chromium
# the compatibility specs check the written database with another client
- name: Install keepassxc-cli
run: sudo apt-get update && sudo apt-get install -y --no-install-recommends keepassxc
- name: Build frontend
run: npm run build
- name: Build backend
Expand Down
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -25,3 +25,6 @@ public/fonts/*

# jetbrains
.idea

# compat test scratch database
tests/tmp/
44 changes: 31 additions & 13 deletions playwright.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ const { defineConfig, devices } = require('@playwright/test');

// Keep in sync with `port` in tests/config.test.yml.
const baseURL = 'http://localhost:8181';
// Keep in sync with `port` in tests/config.compat.yml.
const compatURL = 'http://localhost:8182';

module.exports = defineConfig({
testDir: './tests/e2e',
Expand All @@ -25,18 +27,34 @@ module.exports = defineConfig({
},
],

webServer: {
// release build: every test opens the database, and the KDF takes seconds
// in a debug build
command: 'cargo run --release -- --config ./tests/config.test.yml',
url: baseURL,
reuseExistingServer: !process.env.CI,
cwd: process.cwd(),
timeout: 300 * 1000,
stdout: 'pipe',
stderr: 'pipe',
env: {
RUST_LOG: 'info',
webServer: [
{
// release build: every test opens the database, and the KDF takes seconds
// in a debug build
command: 'cargo run --release -- --config ./tests/config.test.yml',
url: baseURL,
reuseExistingServer: !process.env.CI,
cwd: process.cwd(),
timeout: 300 * 1000,
stdout: 'pipe',
stderr: 'pipe',
env: {
RUST_LOG: 'info',
},
},
},
{
// the compatibility specs write to the database, so they get their own
// instance on a copy of the fixture
command: 'cargo run --release -- --config ./tests/config.compat.yml',
url: compatURL,
reuseExistingServer: !process.env.CI,
cwd: process.cwd(),
timeout: 300 * 1000,
stdout: 'pipe',
stderr: 'pipe',
env: {
RUST_LOG: 'info',
},
},
],
});
29 changes: 29 additions & 0 deletions tests/config.compat.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
##################################
### KeePass4Web compat config ###
##################################

# Second instance for the KeePassXC compatibility tests, which write to the
# database and must not touch the fixture the other specs read.

db_backend: 'Filesystem'
auth_backend: 'None'

Filesystem:
db_location: './tests/tmp/compat.kdbx'

db_session_timeout: '5 minutes'
session_lifetime: '30 minutes'
cookie_samesite: 'strict'

listen: '127.0.0.1'
port: 8182

search:
fields:
- title
- username
- tags
- notes
- url
extra_fields: true
allow_regex: false
117 changes: 117 additions & 0 deletions tests/e2e/keepassxc-compat.spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
// A database this app writes has to stay a database other KeePass clients can
// read, and the other way round. These specs drive the app through the browser
// and check the file itself with keepassxc-cli.

const { execFileSync } = require('child_process');
const fs = require('fs');
const path = require('path');

const { test, expect } = require('@playwright/test');
const { LOGIN_TIMEOUT, MASTER_PASSWORD } = require('./helpers');

const FIXTURE = path.join(__dirname, '..', 'test.kdbx');
const DB = path.join(__dirname, '..', 'tmp', 'compat.kdbx');

// keepassxc-cli takes the database before the entry path and reads the
// database password from stdin
function kxc(flags, positional = [], input = `${MASTER_PASSWORD}\n`) {
return execFileSync('keepassxc-cli', [...flags, DB, ...positional], {
input,
encoding: 'utf8',
stdio: ['pipe', 'pipe', 'pipe'],
});
}

const installed = (() => {
try {
execFileSync('keepassxc-cli', ['--version'], { stdio: 'ignore' });
return true;
} catch {
return false;
}
})();

test.use({ baseURL: 'http://localhost:8182' });

test.describe('KeePassXC compatibility', () => {
test.skip(!installed, 'keepassxc-cli is not installed');

test.beforeEach(async () => {
// every spec starts from the untouched fixture, since these ones write
fs.mkdirSync(path.dirname(DB), { recursive: true });
fs.copyFileSync(FIXTURE, DB);
});

async function openVault(page) {
await page.goto('/');
await page.waitForURL(/\/db_login/, { timeout: LOGIN_TIMEOUT });
await page.getByPlaceholder('Master Password').fill(MASTER_PASSWORD);
await page.getByRole('button', { name: 'Open Vault' }).click();
await page.waitForURL(/\/keepass/, { timeout: LOGIN_TIMEOUT });
}

test('keepassxc reads an entry this app wrote', async ({ page }) => {
await openVault(page);

await page.locator('[data-testid="tree-node"]').filter({ hasText: 'group1' }).click();
await page.getByRole('button', { name: 'New Entry' }).click();
await page.locator('#kp-f-title').fill('written here');
await page.locator('#kp-f-username').fill('someone');
await page.locator('#kp-f-password').fill('a-password-worth-keeping');
await page.getByRole('button', { name: 'Save Entry' }).click();
await expect(page.locator('[data-testid="entry-card"]').filter({ hasText: 'written here' }))
.toBeVisible();

await page.locator('.kp-nav-actions .kp-btn-primary').click();
await page.locator('.kp-modal input[type="password"]').fill(MASTER_PASSWORD);
const saved = page.waitForResponse((r) => r.url().includes('/api/v1/save_db'));
await page.locator('.kp-modal button:has-text("Save")').click();
expect((await saved).status()).toBe(200);
await expect(page.locator('.kp-modal')).toHaveCount(0);

const shown = kxc(['show', '-q', '-s'], ['group1/written here']);
expect(shown).toContain('UserName: someone');
expect(shown).toContain('Password: a-password-worth-keeping');

// the entries that were already there survived the rewrite
const listed = kxc(['ls', '-q'], ['group1']);
expect(listed).toContain('entry1');
expect(listed).toContain('entry1 - Clone');
});

test('this app reads an entry keepassxc wrote', async ({ page }) => {
kxc(['add', '-q', '-u', 'kxcuser', '-p'], ['group2/from keepassxc'],
`${MASTER_PASSWORD}\nkxcpass456\n`);

await openVault(page);
await page.locator('[data-testid="tree-node"]').filter({ hasText: 'group2' }).click();

const entry = page.locator('[data-testid="entry-card"]').filter({ hasText: 'from keepassxc' });
await expect(entry).toBeVisible();
await entry.click();
await expect(page.locator('[data-testid="entry-field-value"]').first()).toHaveText('kxcuser');
});

test('saving keeps the key derivation the database was created with', async ({ page }) => {
const before = kxc(['db-info', '-q']);

await openVault(page);
await page.locator('[data-testid="tree-node"]').filter({ hasText: 'group1' }).click();
await page.getByRole('button', { name: 'New Entry' }).click();
await page.locator('#kp-f-title').fill('kdf check');
await page.getByRole('button', { name: 'Save Entry' }).click();
await page.locator('.kp-nav-actions .kp-btn-primary').click();
await page.locator('.kp-modal input[type="password"]').fill(MASTER_PASSWORD);
const saved = page.waitForResponse((r) => r.url().includes('/api/v1/save_db'));
await page.locator('.kp-modal button:has-text("Save")').click();
expect((await saved).status()).toBe(200);
await expect(page.locator('.kp-modal')).toHaveCount(0);

const after = kxc(['db-info', '-q']);
const field = (info, name) => info.split('\n').find((line) => line.startsWith(`${name}:`));

// a weaker kdf or cipher after a save would be a silent downgrade
expect(field(after, 'KDF')).toBe(field(before, 'KDF'));
expect(field(after, 'Cipher')).toBe(field(before, 'Cipher'));
});
});
Loading