The e2e stack shared production's Postgres — same server, same database, same table. Seeded robots sat in `users` beside real clinicians, and anything a test wrote, or a migration under test changed, landed on real data. Nothing about "run the tests" should be able to reach an account belonging to a person. Now it has a Postgres and a Redis of its own, both on tmpfs: created empty on every run, held in RAM, gone on teardown. scripts/e2e.sh is one command that recreates the stack, seeds it, runs the browser and leaves the app up at 127.0.0.1:3553 so it can be clicked around in, with the report served at :3554. Two bugs fell out of it immediately, both of which only a database that did not already exist could have found: The schema could not be built from nothing. The entrypoint migrated before the app created its baseline tables, so the first migration failed on saved_encounters not existing. It never showed because every database this has ever run against already had the baseline. Then, one layer down, 1777800000000_generated-images creates a table with a foreign key to learning_content — which the baseline stopped creating when Learning Hub was removed. Restoring into a brand-new database could not have booted. The entrypoint now stands aside when the database is empty and lets the app do it in the order it already gets right, and the foreign key is only created where its target is. All 20 migrations replay from empty, producing the same 23 tables production has. Configuration lives in the database, so a throwaway one starts at defaults — 14 settings against production's 49. That is why every model picker was empty: models.custom did not exist. The tests were right and the environment was incomplete, so the seed now states what the suite depends on, with fictional model ids: a test should not pass because of a setting somebody changed on the live system last week. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
123 lines
5.6 KiB
JavaScript
123 lines
5.6 KiB
JavaScript
// ============================================================
|
|
// E2E ACCOUNT SEED
|
|
// ============================================================
|
|
// Run inside the app container, which is where the database credentials live:
|
|
//
|
|
// docker exec pediatric-ai-scribe-e2e node e2e/seed.js
|
|
//
|
|
// Before this existed the e2e user was a registration someone did by hand once
|
|
// and the shared Postgres happened to keep. That was enough to log in and no
|
|
// more: there was no admin account, so nothing under /api/admin could be tested
|
|
// through a real request at all, and the Search Sources screen had to be
|
|
// checked by reading its markup.
|
|
//
|
|
// Reconciles rather than only creating. An account left over from an earlier
|
|
// run with the wrong role, an unverified address, a disabled flag or a
|
|
// different password is repaired in place, so the suite cannot fail for a
|
|
// reason that has nothing to do with the code under test.
|
|
//
|
|
// The domain guard is the important part. This script updates passwords and
|
|
// grants the admin role, so it refuses to touch any address outside
|
|
// @ped-ai.test — a mistyped environment variable can then do nothing worse
|
|
// than create another test account.
|
|
// ============================================================
|
|
|
|
// The entrypoint fetches secrets from OpenBao and exports them into the server
|
|
// process, and nowhere else — not into the image config, not into an env file.
|
|
// `docker exec` therefore starts with none of them and the database connection
|
|
// refuses on localhost. Borrowing PID 1's environment is what makes this
|
|
// runnable the documented way; without it the script only works on a stack
|
|
// whose credentials happen to be in plain compose environment.
|
|
require('fs').readFileSync('/proc/1/environ', 'utf8').split('\0').forEach(function (pair) {
|
|
var i = pair.indexOf('=');
|
|
if (i > 0 && !process.env[pair.slice(0, i)]) process.env[pair.slice(0, i)] = pair.slice(i + 1);
|
|
});
|
|
|
|
var db = require('../src/db/database');
|
|
// The app's own hasher, not bcrypt directly: production writes argon2id, and a
|
|
// seeded account hashed any other way exercises a path real users do not take.
|
|
var passwords = require('../src/utils/passwords');
|
|
|
|
var TEST_DOMAIN = '@ped-ai.test';
|
|
var PASSWORD = process.env.E2E_TEST_PASSWORD || 'E2E-testPassword123!';
|
|
|
|
var ACCOUNTS = [
|
|
{ email: process.env.E2E_TEST_EMAIL || 'e2e-user' + TEST_DOMAIN, name: 'E2E User', role: 'user' },
|
|
{ email: process.env.E2E_ADMIN_EMAIL || 'e2e-admin' + TEST_DOMAIN, name: 'E2E Admin', role: 'admin' }
|
|
];
|
|
|
|
// ── Configuration ─────────────────────────────────────────────────────
|
|
// Settings live in the database, so a throwaway database starts at defaults
|
|
// rather than at whatever production happens to be configured with. That is
|
|
// the point — a test should not pass because of a setting somebody changed on
|
|
// the live system last week — but it does mean anything the suite depends on
|
|
// has to be stated here.
|
|
//
|
|
// This is what made the model pickers empty when the e2e stack stopped sharing
|
|
// production's database: models.custom did not exist, so there was nothing to
|
|
// put in the <select>. The tests were right; the environment was incomplete.
|
|
//
|
|
// Fictional ids on purpose. Nothing here reaches a gateway — the specs mock
|
|
// the model calls — and a real model name would invite someone to believe a
|
|
// green run says something about that model.
|
|
var SETTINGS = {
|
|
'models.custom': JSON.stringify([
|
|
{ id: 'e2e-model-a', name: 'E2E Model A' },
|
|
{ id: 'e2e-model-b', name: 'E2E Model B' }
|
|
]),
|
|
'models.default': 'e2e-model-a',
|
|
'models.disabled': '[]',
|
|
'stt.model': 'e2e-stt',
|
|
'tts.model': 'e2e-tts',
|
|
'tts.voice': 'e2e-voice',
|
|
// Registration closed and invite-only, which is what production runs and
|
|
// what the auth-screen spec asserts the sign-in page reflects.
|
|
'registration_enabled': 'true',
|
|
'registration_invite_only': 'true'
|
|
};
|
|
|
|
async function seedSettings() {
|
|
var keys = Object.keys(SETTINGS);
|
|
for (var i = 0; i < keys.length; i++) {
|
|
await db.setSetting(keys[i], SETTINGS[keys[i]]);
|
|
}
|
|
console.log('settings seeded (' + keys.length + ' keys)');
|
|
}
|
|
|
|
async function seed(account) {
|
|
var email = String(account.email || '').toLowerCase().trim();
|
|
if (email.slice(-TEST_DOMAIN.length) !== TEST_DOMAIN) {
|
|
throw new Error('refusing to seed ' + email + ': only ' + TEST_DOMAIN + ' addresses may be seeded');
|
|
}
|
|
var hash = await passwords.hash(PASSWORD);
|
|
var existing = await db.get('SELECT id, role, email_verified, disabled FROM users WHERE email = ?', [email]);
|
|
if (!existing) {
|
|
await db.run(
|
|
'INSERT INTO users (email, password, name, role, email_verified, disabled) VALUES (?, ?, ?, ?, true, false)',
|
|
[email, hash, account.name, account.role]
|
|
);
|
|
console.log('created ' + email + ' (' + account.role + ')');
|
|
return;
|
|
}
|
|
await db.run(
|
|
'UPDATE users SET password = ?, name = ?, role = ?, email_verified = true, disabled = false WHERE id = ?',
|
|
[hash, account.name, account.role, existing.id]
|
|
);
|
|
var drift = [];
|
|
if (existing.role !== account.role) drift.push('role ' + existing.role + '→' + account.role);
|
|
if (!existing.email_verified) drift.push('verified');
|
|
if (existing.disabled) drift.push('re-enabled');
|
|
console.log('repaired ' + email + ' (' + (drift.length ? drift.join(', ') : 'password reset') + ')');
|
|
}
|
|
|
|
(async function () {
|
|
try {
|
|
for (var i = 0; i < ACCOUNTS.length; i++) await seed(ACCOUNTS[i]);
|
|
await seedSettings();
|
|
console.log('e2e accounts ready');
|
|
process.exit(0);
|
|
} catch (err) {
|
|
console.error('e2e seed failed: ' + err.message);
|
|
process.exit(1);
|
|
}
|
|
})();
|