mirror of
https://github.com/kevinveenbirkenbach/computer-playbook.git
synced 2026-11-08 11:46:35 +00:00
feat(web-app-pgadmin): bring the corporate design to pgAdmin and converge its administrator
templates/style.css.j2 maps page, panes, object tree, tabs and primary actions onto the design tokens and makes the menu bar the brand frame; templates/design.js.j2 follows pgAdmin's native theme switch. The logo and login-logo slots meta/services.yml declares sit on frame chrome, and with design on templates/env.j2 sets PGADMIN_CONFIG_APP_NAME to the configured title. pgAdmin keeps its internal user in its own database, and PGADMIN_DEFAULT_PASSWORD only seeds the first start. tasks/main.yml now flushes the compose handlers and, on the stack host outside server mode, runs tasks/administrator.yml with files/python/administrator.py: it creates the internal administrator if missing, otherwise realigns password, active and locked state, failed login attempts and the Administrator role. The identity is the platform administrator as on feature/store-i18n: PGADMIN_ADMIN_EMAIL and PGADMIN_ADMIN_PASSWORD in vars/main.yml, meta/users.yml declares the user, and meta/secrets.yml, whose only credential the role no longer reads, is gone. tests/unit/python/roles/web-app-pgadmin/files/test_administrator.py covers the script. meta/services.yml names the container pgadmin for PGADMIN_CONTAINER_ADDRESS. files/playwright/test-design.js checks tokens, that the administrator signs in with that password and holds admin rights, panes, tree, tabs, the menu bar frame and its focus indicator, primary actions, the theme switch, logo, favicon and title, and captures the gallery against the role's own database server; templates/playwright.env.j2 hands the platform administrator, the design URLs and the database to it. README.md links the gallery; TODO.md records the Gravatar request and the memory pressure at the 256m limit. The design was verified green on the compose stack (10 passed); the switch to the platform administrator is checked statically only, the first live run is the verification on the merged state. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
9a797a805a
commit
0709de6fa1
18 files changed
+921
-9
No files matched your search
@@ -115,6 +115,7 @@ docker run --rm -it \
|
||||
|
||||
- [pgAdmin Official Homepage](https://www.pgadmin.org/)
|
||||
- [pgAdmin Documentation](https://www.pgadmin.org/docs/)
|
||||
- [Corporate design review: screenshots in light, dark and desktop](https://claude.ai/artifact/QvGNAugdfpWe5Ed7SqeDRX)
|
||||
|
||||
## Persona contract opt-outs
|
||||
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
# Todos
|
||||
|
||||
- The user menu loads `https://www.gravatar.com/avatar/<md5 of the administrator e-mail>` on every signed-in page (measured in the browser; the role CSP allows `img-src *`). pgAdmin's default `SHOW_GRAVATAR_IMAGE = True` sends the e-mail hash to a third party. Options: set `PGADMIN_CONFIG_SHOW_GRAVATAR_IMAGE=False` in `templates/env.j2`, or whitelist and accept it explicitly.
|
||||
- The pgAdmin container runs at its `mem_limit: 256m`: `memory.current` 252 to 265 MiB with `memory.events max` at 1098 to 1774 within minutes of a start, no OOM kill. The administrator convergence script (`/venv/bin/python3` with `create_app`) peaks at 197 MiB RSS and takes about 27 s at `cpus: "0.2"`. Options: raise `mem_limit` to 512m and `cpus` to 0.5 in `meta/services.yml`, or keep the limits and accept the reclaim pressure.
|
||||
@@ -13,6 +13,8 @@ test.beforeEach(async ({ page }) => {
|
||||
await page.context().clearCookies();
|
||||
});
|
||||
|
||||
require("./test-design").register();
|
||||
|
||||
test("pgAdmin front page is served under canonical domain with TLS", async ({ page }) => {
|
||||
const response = await gotoOnion(page, `${appBaseUrl}/`);
|
||||
expect(response, "Expected pgAdmin response").toBeTruthy();
|
||||
|
||||
@@ -0,0 +1,347 @@
|
||||
const { test, expect } = require("@playwright/test");
|
||||
|
||||
const {
|
||||
assertDesignTokens,
|
||||
assertLightAndDark,
|
||||
assertReadable,
|
||||
assertToken,
|
||||
captureDesignGallery,
|
||||
galleryEnabled,
|
||||
tokenValue,
|
||||
} = require("./design");
|
||||
const { apiGetOnion, decodeDotenvQuotedValue, gotoOnion, performKeycloakLoginForm } = require("./personas");
|
||||
const { skipUnlessServiceEnabled } = require("./service-gating");
|
||||
const { resolveTimeout } = require("./timeouts");
|
||||
|
||||
const MODES = ["light", "dark"];
|
||||
const MENU_BAR = "[data-test='app-menu-bar']";
|
||||
const MENU_BUTTON = `${MENU_BAR} button[data-label]`;
|
||||
const USER_MENU = "[data-test='loggedin-username']";
|
||||
const LOGO = ".AppMenuBar-logo";
|
||||
const LOGIN_LOGO = ".BasePage-logo";
|
||||
const LOGIN_CARD = ".BasePage-pageContent";
|
||||
const LOGIN_BUTTON = "button[name='internal_button']";
|
||||
const TREE = ".file-tree";
|
||||
const ENTRY = ".file-entry";
|
||||
const PANE = ".dock-layout .dock-tabpane-active";
|
||||
const TAB = ".dock-layout .dock-tab";
|
||||
const ACTIVE_TAB = ".dock-layout .dock-tab-active";
|
||||
const OPEN_MENU = ".szh-menu--state-open";
|
||||
const DIALOG = ".dock-panel.dock-style-dialogs, .MuiDialog-paper";
|
||||
const SERVER_NAME = "Infinito Postgres";
|
||||
const READY = 10_000;
|
||||
|
||||
const base = decodeDotenvQuotedValue(process.env.APP_BASE_URL).replace(/\/$/, "");
|
||||
const adminEmail = decodeDotenvQuotedValue(process.env.ADMIN_EMAIL);
|
||||
const adminPassword = decodeDotenvQuotedValue(process.env.ADMIN_PASSWORD);
|
||||
const ssoUsername = decodeDotenvQuotedValue(process.env.ADMIN_USERNAME);
|
||||
const db = {
|
||||
host: decodeDotenvQuotedValue(process.env.PGADMIN_DB_HOST),
|
||||
name: decodeDotenvQuotedValue(process.env.PGADMIN_DB_NAME),
|
||||
password: decodeDotenvQuotedValue(process.env.PGADMIN_DB_PASSWORD),
|
||||
port: decodeDotenvQuotedValue(process.env.PGADMIN_DB_PORT),
|
||||
user: decodeDotenvQuotedValue(process.env.PGADMIN_DB_USER),
|
||||
};
|
||||
|
||||
/**
|
||||
* Args:
|
||||
* page: Playwright page; opens /login, passing the identity provider first when sso gates the proxy.
|
||||
*/
|
||||
async function openLogin(page) {
|
||||
await gotoOnion(page, `${base}/login`);
|
||||
if (page.url().includes("openid-connect/auth")) {
|
||||
await performKeycloakLoginForm(page, ssoUsername, adminPassword);
|
||||
await expect.poll(() => page.url(), { timeout: resolveTimeout(60_000) }).toContain(base);
|
||||
await gotoOnion(page, `${base}/login`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Args:
|
||||
* page: Playwright page; ends signed in on /browser/ with the menu bar and the tree rendered.
|
||||
*/
|
||||
async function signIn(page) {
|
||||
await openLogin(page);
|
||||
if (page.url().includes("/login")) {
|
||||
const email = page.locator("input[name='email']");
|
||||
await expect(email).toBeEditable({ timeout: resolveTimeout(READY) });
|
||||
await email.fill(adminEmail);
|
||||
const password = page.locator("input[name='password']");
|
||||
await expect(password).toBeEditable({ timeout: resolveTimeout(READY) });
|
||||
await password.fill(adminPassword);
|
||||
await page.locator(LOGIN_BUTTON).click();
|
||||
}
|
||||
await expect(page.locator(MENU_BAR)).toBeVisible({ timeout: resolveTimeout(60_000) });
|
||||
await expect(page.locator(TREE).first()).toBeVisible({ timeout: resolveTimeout(30_000) });
|
||||
}
|
||||
|
||||
async function onBrowser(page) {
|
||||
if (page.url().includes("/login") || page.url().includes("openid-connect")) await signIn(page);
|
||||
await expect(page.locator(TREE).first()).toBeVisible({ timeout: resolveTimeout(30_000) });
|
||||
}
|
||||
|
||||
async function openMenu(page, menu, item) {
|
||||
await page.locator(`${MENU_BAR} button[data-label='${menu}']`).click({ timeout: resolveTimeout(READY) });
|
||||
const panel = page.locator(OPEN_MENU).first();
|
||||
await expect(panel).toBeVisible({ timeout: resolveTimeout(READY) });
|
||||
if (item) await panel.getByRole("menuitem", { name: item }).first().click({ timeout: resolveTimeout(READY) });
|
||||
}
|
||||
|
||||
async function openSubMenu(page, menu, group, item) {
|
||||
await openMenu(page, menu);
|
||||
await page.locator(OPEN_MENU).getByRole("menuitem", { name: group }).first().hover({ timeout: resolveTimeout(READY) });
|
||||
await page.locator(OPEN_MENU).getByRole("menuitem", { name: item }).first().click({ timeout: resolveTimeout(READY) });
|
||||
}
|
||||
|
||||
function entry(page, text) {
|
||||
return page.locator(ENTRY).filter({ has: page.locator(".file-name", { hasText: new RegExp(`^${text}$`) }) }).first();
|
||||
}
|
||||
|
||||
async function selectEntry(page, text) {
|
||||
const node = entry(page, text);
|
||||
await node.click({ timeout: resolveTimeout(READY) });
|
||||
await expect(node).toHaveClass(/active/, { timeout: resolveTimeout(READY) });
|
||||
}
|
||||
|
||||
async function openTab(page, title) {
|
||||
await page.locator(`${TAB}:visible`, { hasText: title }).first().click({ timeout: resolveTimeout(READY) });
|
||||
await expect(page.locator(ACTIVE_TAB, { hasText: title }).first()).toBeVisible({ timeout: resolveTimeout(READY) });
|
||||
}
|
||||
|
||||
async function openDialog(page) {
|
||||
const dialog = page.locator(DIALOG).last();
|
||||
await expect(dialog).toBeVisible({ timeout: resolveTimeout(READY) });
|
||||
return dialog;
|
||||
}
|
||||
|
||||
/**
|
||||
* Args:
|
||||
* page: signed-in Playwright page on /browser/; registers the role's own database with the role's database user once.
|
||||
*/
|
||||
async function ensureServer(page) {
|
||||
await selectEntry(page, "Servers");
|
||||
const server = entry(page, SERVER_NAME);
|
||||
if (await server.count()) return;
|
||||
await page.keyboard.press("ArrowRight");
|
||||
const listed = await server.waitFor({ state: "visible", timeout: resolveTimeout(5_000) }).then(() => true, () => false);
|
||||
if (listed) return;
|
||||
await openSubMenu(page, "Object", /^Register/, "Server...");
|
||||
const dialog = await openDialog(page);
|
||||
await expect(dialog.locator("input[name='name']")).toBeEditable({ timeout: resolveTimeout(READY) });
|
||||
await dialog.locator("input[name='name']").fill(SERVER_NAME);
|
||||
await dialog.locator("[data-test='Connection']").click();
|
||||
await dialog.locator("input[name='host']").fill(db.host);
|
||||
await dialog.locator("input[name='port']").fill(db.port);
|
||||
await dialog.locator("input[name='db']").fill(db.name);
|
||||
await dialog.locator("input[name='username']").fill(db.user);
|
||||
const secret = dialog.locator("input[name='password']");
|
||||
await expect(secret).toBeEditable({ timeout: resolveTimeout(READY) });
|
||||
await secret.fill(db.password);
|
||||
await dialog.locator("input[name='save_password']").check({ force: true });
|
||||
await dialog.getByRole("button", { name: "Save", exact: true }).click();
|
||||
await expect(server).toBeVisible({ timeout: resolveTimeout(30_000) });
|
||||
}
|
||||
|
||||
async function onServers(page, tab) {
|
||||
await onBrowser(page);
|
||||
await selectEntry(page, "Servers");
|
||||
if (tab) await openTab(page, tab);
|
||||
}
|
||||
|
||||
async function registerDialog(page, tab) {
|
||||
await onServers(page);
|
||||
await openSubMenu(page, "Object", /^Register/, "Server...");
|
||||
const dialog = await openDialog(page);
|
||||
if (tab) await dialog.getByRole("tab", { name: tab, exact: true }).click({ timeout: resolveTimeout(READY) });
|
||||
return dialog;
|
||||
}
|
||||
|
||||
async function userMenu(page, item) {
|
||||
await onBrowser(page);
|
||||
await page.locator(USER_MENU).click({ timeout: resolveTimeout(READY) });
|
||||
const panel = page.locator(OPEN_MENU).first();
|
||||
await expect(panel).toBeVisible({ timeout: resolveTimeout(READY) });
|
||||
if (item) await panel.getByRole("menuitem", { name: item }).click({ timeout: resolveTimeout(READY) });
|
||||
}
|
||||
|
||||
async function signedOut(page) {
|
||||
await page.context().clearCookies();
|
||||
await gotoOnion(page, `${base}/login`);
|
||||
await expect(page.locator(LOGIN_CARD)).toBeVisible({ timeout: resolveTimeout(READY) });
|
||||
}
|
||||
|
||||
function views() {
|
||||
const browser = `${base}/browser/`;
|
||||
return [
|
||||
{ name: "login", url: `${base}/login`, prepare: signedOut },
|
||||
{
|
||||
name: "forgot-password",
|
||||
url: `${base}/login`,
|
||||
prepare: async (page) => {
|
||||
await signedOut(page);
|
||||
await page.getByRole("link", { name: /Forgotten your password/ }).click();
|
||||
await expect(page.locator("input[name='email']")).toBeVisible({ timeout: resolveTimeout(READY) });
|
||||
},
|
||||
},
|
||||
{ name: "welcome", url: browser, prepare: onBrowser },
|
||||
{ name: "servers-dashboard", url: browser, prepare: (page) => onServers(page, "Dashboard") },
|
||||
{ name: "servers-properties", url: browser, prepare: (page) => onServers(page, "Properties") },
|
||||
{ name: "servers-sql", url: browser, prepare: (page) => onServers(page, "SQL") },
|
||||
{ name: "servers-statistics", url: browser, prepare: (page) => onServers(page, "Statistics") },
|
||||
{ name: "processes", url: browser, prepare: (page) => onServers(page, "Processes") },
|
||||
{ name: "register-server", url: browser, prepare: (page) => registerDialog(page) },
|
||||
{ name: "register-server-connection", url: browser, prepare: async (page) => { const dialog = await registerDialog(page, "Connection"); await dialog.locator("input[name='host']").focus(); } },
|
||||
{ name: "register-server-ssh", url: browser, prepare: (page) => registerDialog(page, "SSH Tunnel") },
|
||||
{ name: "register-server-advanced", url: browser, prepare: (page) => registerDialog(page, "Advanced") },
|
||||
{ name: "create-server-group", url: browser, prepare: async (page) => { await onServers(page); await openSubMenu(page, "Object", /^Create/, "Server Group..."); await openDialog(page); } },
|
||||
{ name: "preferences", url: browser, prepare: async (page) => { await onBrowser(page); await openMenu(page, "File", "Preferences"); await expect(page.locator(ACTIVE_TAB, { hasText: "Preferences" }).first()).toBeVisible({ timeout: resolveTimeout(READY) }); } },
|
||||
{ name: "about", url: browser, prepare: async (page) => { await onBrowser(page); await openMenu(page, "Help", /About/); await openDialog(page); } },
|
||||
{ name: "user-management", url: browser, prepare: async (page) => { await userMenu(page, /User Management/); await expect(page.locator(ACTIVE_TAB, { hasText: "User Management" }).first()).toBeVisible({ timeout: resolveTimeout(READY) }); } },
|
||||
{ name: "change-password", url: browser, prepare: async (page) => { await userMenu(page, /Change Password/); await openDialog(page); } },
|
||||
{ name: "user-menu", url: browser, prepare: (page) => userMenu(page) },
|
||||
{ name: "file-menu", url: browser, prepare: async (page) => { await onBrowser(page); await openMenu(page, "File"); } },
|
||||
{ name: "tools-menu", url: browser, prepare: async (page) => { await onBrowser(page); await openMenu(page, "Tools"); } },
|
||||
{ name: "help-menu", url: browser, prepare: async (page) => { await onBrowser(page); await openMenu(page, "Help"); } },
|
||||
{ name: "object-menu", url: browser, prepare: async (page) => { await onServers(page); await openMenu(page, "Object"); await page.locator(OPEN_MENU).getByRole("menuitem", { name: /^Register/ }).first().hover({ timeout: resolveTimeout(READY) }); } },
|
||||
{ name: "context-menu", url: browser, prepare: async (page) => { await onServers(page); await entry(page, "Servers").click({ button: "right", timeout: resolveTimeout(READY) }); await expect(page.locator(OPEN_MENU).first()).toBeVisible({ timeout: resolveTimeout(READY) }); } },
|
||||
];
|
||||
}
|
||||
|
||||
exports.register = function () {
|
||||
test("design: corporate tokens apply in light and dark mode", async ({ page }) => {
|
||||
skipUnlessServiceEnabled("design");
|
||||
await signIn(page);
|
||||
await assertDesignTokens(page, "pgadmin");
|
||||
});
|
||||
|
||||
test("design: the administrator signs in with the users password and holds admin rights", async ({ page }) => {
|
||||
skipUnlessServiceEnabled("design");
|
||||
await signIn(page);
|
||||
await expect(page.locator(USER_MENU)).toContainText(adminEmail);
|
||||
await page.locator(USER_MENU).click();
|
||||
await expect(page.locator(OPEN_MENU).getByRole("menuitem", { name: /User Management/ })).toBeVisible({ timeout: resolveTimeout(READY) });
|
||||
});
|
||||
|
||||
test("design: page, panes, tree and tabs take the palette", async ({ page }) => {
|
||||
skipUnlessServiceEnabled("design");
|
||||
await signIn(page);
|
||||
for (const mode of MODES) {
|
||||
await page.emulateMedia({ colorScheme: mode });
|
||||
await assertToken(page, "body", "background-color", "--design-surface-1", `page ${mode}`);
|
||||
await assertToken(page, PANE, "background-color", "--design-surface-1", `pane ${mode}`);
|
||||
await assertToken(page, TREE, "color", "--design-text", `tree text ${mode}`);
|
||||
await assertToken(page, ACTIVE_TAB, "color", "--design-link", `active tab ${mode}`);
|
||||
}
|
||||
await page.emulateMedia({ colorScheme: null });
|
||||
await assertLightAndDark(page, PANE, "pgadmin pane");
|
||||
await assertReadable(page, [`${ENTRY} .file-name`, TAB, ACTIVE_TAB], "pgadmin browser");
|
||||
});
|
||||
|
||||
test("design: the menu bar is the frame and keeps its focus indicator", async ({ page }) => {
|
||||
skipUnlessServiceEnabled("design");
|
||||
await signIn(page);
|
||||
for (const mode of MODES) {
|
||||
await page.emulateMedia({ colorScheme: mode });
|
||||
await assertToken(page, MENU_BAR, "background-color", "--design-frame", `menu bar ${mode}`);
|
||||
await assertToken(page, MENU_BUTTON, "color", "--design-on-frame", `menu entry ${mode}`);
|
||||
const ring = await tokenValue(page, "--design-on-frame", "outline-color");
|
||||
const count = await page.locator(MENU_BUTTON).count();
|
||||
await page.locator(MENU_BUTTON).nth(1).focus();
|
||||
await page.keyboard.press("Shift+Tab");
|
||||
for (let stop = 0; stop < count; stop += 1) {
|
||||
const drawn = await page.evaluate(() => {
|
||||
const style = getComputedStyle(document.activeElement);
|
||||
return { label: document.activeElement.dataset.label, color: style.outlineColor, width: style.outlineWidth, line: style.outlineStyle };
|
||||
});
|
||||
expect(drawn.line, `focus on ${drawn.label} ${mode}`).not.toBe("none");
|
||||
expect(drawn.color, `focus on ${drawn.label} ${mode}`).toBe(ring);
|
||||
await page.keyboard.press("Tab");
|
||||
}
|
||||
}
|
||||
await page.emulateMedia({ colorScheme: null });
|
||||
await assertReadable(page, [MENU_BUTTON, USER_MENU], "pgadmin menu bar");
|
||||
});
|
||||
|
||||
test("design: primary actions carry the on-primary text", async ({ page }) => {
|
||||
skipUnlessServiceEnabled("design");
|
||||
await openLogin(page);
|
||||
const email = page.locator("input[name='email']");
|
||||
await expect(email).toBeEditable({ timeout: resolveTimeout(READY) });
|
||||
await email.fill(adminEmail);
|
||||
const password = page.locator("input[name='password']");
|
||||
await expect(password).toBeEditable({ timeout: resolveTimeout(READY) });
|
||||
await password.fill(`probe-${Date.now()}`);
|
||||
for (const mode of MODES) {
|
||||
await page.emulateMedia({ colorScheme: mode });
|
||||
await assertToken(page, LOGIN_BUTTON, "background-color", "--design-primary", `login button ${mode}`);
|
||||
await assertToken(page, LOGIN_BUTTON, "color", "--design-on-primary", `login button text ${mode}`);
|
||||
await assertToken(page, LOGIN_CARD, "color", "--design-on-frame", `login card text ${mode}`);
|
||||
}
|
||||
await page.emulateMedia({ colorScheme: null });
|
||||
await assertReadable(page, [LOGIN_BUTTON], "pgadmin login");
|
||||
});
|
||||
|
||||
test("design: the native theme switch drives the tokens", async ({ page }) => {
|
||||
skipUnlessServiceEnabled("design");
|
||||
await signIn(page);
|
||||
const html = page.locator("html");
|
||||
expect(await page.evaluate(() => window.theme), "a fresh account follows the browser").toBe("system");
|
||||
await expect(html).not.toHaveAttribute("data-design-theme", /./);
|
||||
await page.emulateMedia({ colorScheme: "dark" });
|
||||
const dark = await tokenValue(page, "--design-surface-1", "background-color");
|
||||
await page.emulateMedia({ colorScheme: "light" });
|
||||
const pick = (value) => page.evaluate((theme) => {
|
||||
const channel = new BroadcastChannel("preference-change");
|
||||
channel.postMessage({ data: [{ module: "misc", name: "theme", value: theme }], version: Date.now() });
|
||||
channel.close();
|
||||
}, value);
|
||||
await pick("dark");
|
||||
await expect(html).toHaveAttribute("data-design-theme", "dark");
|
||||
await assertToken(page, PANE, "background-color", "--design-surface-1", "pane after picking dark");
|
||||
expect(await tokenValue(page, "--design-surface-1", "background-color"), "picking dark switches the tokens").toBe(dark);
|
||||
await pick("high_contrast");
|
||||
await expect(html).toHaveAttribute("data-pgadmin-theme", "high_contrast");
|
||||
await expect(html).not.toHaveAttribute("data-design-theme", /./);
|
||||
await pick("system");
|
||||
await expect(html).not.toHaveAttribute("data-pgadmin-theme", /./);
|
||||
await page.emulateMedia({ colorScheme: null });
|
||||
});
|
||||
|
||||
test("design: logo, favicon and title are the configured ones", async ({ page }) => {
|
||||
skipUnlessServiceEnabled("design");
|
||||
const logoUrl = decodeDotenvQuotedValue(process.env.DESIGN_LOGO_URL);
|
||||
const loginLogoUrl = decodeDotenvQuotedValue(process.env.DESIGN_LOGIN_LOGO_URL);
|
||||
const faviconUrl = decodeDotenvQuotedValue(process.env.DESIGN_FAVICON_URL);
|
||||
const title = decodeDotenvQuotedValue(process.env.DESIGN_TITLE);
|
||||
test.skip(!logoUrl && !title, "logo and title replacement are disabled for this role");
|
||||
await openLogin(page);
|
||||
if (title) await expect(page).toHaveTitle(title);
|
||||
if (loginLogoUrl) await expect(page.locator(LOGIN_LOGO)).toHaveCSS("background-image", `url("${loginLogoUrl}")`);
|
||||
await signIn(page);
|
||||
if (title) await expect(page).toHaveTitle(title);
|
||||
if (!logoUrl) return;
|
||||
await expect(page.locator(LOGO)).toHaveCSS("background-image", `url("${logoUrl}")`);
|
||||
const box = await page.locator(LOGO).boundingBox();
|
||||
expect(box.width, "the lockup in the menu bar is wider than high").toBeGreaterThan(box.height * 1.5);
|
||||
for (const icon of await page.locator("link[rel~='icon']").all()) {
|
||||
await expect(icon).toHaveAttribute("href", faviconUrl);
|
||||
}
|
||||
for (const url of [logoUrl, loginLogoUrl, faviconUrl]) {
|
||||
expect((await apiGetOnion(page.request, url)).ok(), `${url} is published on the CDN`).toBe(true);
|
||||
}
|
||||
const channels = (await tokenValue(page, "--design-on-frame", "color")).match(/\d+/g).slice(0, 3);
|
||||
const onFrame = `#${channels.map((channel) => Number(channel).toString(16).padStart(2, "0")).join("")}`;
|
||||
for (const url of [logoUrl, loginLogoUrl]) {
|
||||
const svg = (await (await apiGetOnion(page.request, url)).text()).toLowerCase();
|
||||
expect(svg, `${url} renders its text in the frame text color ${onFrame}`).toContain(`fill="${onFrame}"`);
|
||||
}
|
||||
});
|
||||
|
||||
test("design: gallery of login, object explorer, panels, dialogs, query tool and settings", async ({ page }) => {
|
||||
skipUnlessServiceEnabled("design");
|
||||
test.skip(!galleryEnabled(), "INFINITO_PLAYWRIGHT_KEEP is not true");
|
||||
test.setTimeout(resolveTimeout(2_400_000));
|
||||
await signIn(page);
|
||||
await ensureServer(page);
|
||||
await captureDesignGallery(page, views());
|
||||
});};
|
||||
@@ -0,0 +1,78 @@
|
||||
"""Converge the pgAdmin administrator account on every deploy.
|
||||
|
||||
Args:
|
||||
PGADMIN_DEFAULT_EMAIL: login e-mail of the administrator (container env).
|
||||
PGADMIN_DEFAULT_PASSWORD: password the account must accept (container env).
|
||||
|
||||
Prints ``PGADMIN_ADMINISTRATOR=changed`` when it created or repaired the
|
||||
account and ``PGADMIN_ADMINISTRATOR=ok`` when the account already matched.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
|
||||
def converge(email: str, password: str) -> str:
|
||||
"""Create or repair the internal administrator inside an app context.
|
||||
|
||||
Args:
|
||||
email: login e-mail of the administrator.
|
||||
password: password the account must accept.
|
||||
|
||||
Returns:
|
||||
``"changed"`` after a create or repair, ``"ok"`` when nothing drifted.
|
||||
"""
|
||||
from flask_security.utils import hash_password, verify_password
|
||||
from pgadmin.model import Role, User, db
|
||||
from pgadmin.tools.user_management import create_user, normalise_password
|
||||
from pgadmin.utils.constants import INTERNAL
|
||||
|
||||
admin = Role.query.filter_by(name="Administrator").one()
|
||||
user = User.query.filter_by(email=email, auth_source=INTERNAL).first()
|
||||
if user is None:
|
||||
created, message = create_user(
|
||||
{
|
||||
"email": email,
|
||||
"role": admin.id,
|
||||
"active": True,
|
||||
"auth_source": INTERNAL,
|
||||
"newPassword": password,
|
||||
"confirmPassword": password,
|
||||
}
|
||||
)
|
||||
if not created:
|
||||
sys.exit(f"creating the administrator failed: {message}")
|
||||
return "changed"
|
||||
secret = normalise_password(password)
|
||||
drift = (
|
||||
not verify_password(secret, user.password)
|
||||
or not user.active
|
||||
or bool(user.locked)
|
||||
or admin not in user.roles
|
||||
)
|
||||
if not drift:
|
||||
return "ok"
|
||||
user.password = hash_password(secret)
|
||||
user.active = True
|
||||
user.locked = False
|
||||
user.login_attempts = 0
|
||||
if admin not in user.roles:
|
||||
user.roles.append(admin)
|
||||
db.session.commit()
|
||||
return "changed"
|
||||
|
||||
|
||||
def main() -> None:
|
||||
import config
|
||||
from pgadmin import create_app
|
||||
|
||||
app = create_app(config.APP_NAME + "-cli")
|
||||
with app.test_request_context():
|
||||
state = converge(
|
||||
os.environ["PGADMIN_DEFAULT_EMAIL"], os.environ["PGADMIN_DEFAULT_PASSWORD"]
|
||||
)
|
||||
print(f"PGADMIN_ADMINISTRATOR={state}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,6 +0,0 @@
|
||||
---
|
||||
credentials:
|
||||
administrator_password:
|
||||
description: Initial password for the pgAdmin administrator login
|
||||
algorithm: sha256
|
||||
validation: ^[a-f0-9]{64}$
|
||||
@@ -13,6 +13,7 @@ pgadmin:
|
||||
image: dpage/pgadmin4
|
||||
version: "9.18"
|
||||
min_storage: 378MB
|
||||
name: pgadmin
|
||||
server_mode: false
|
||||
master_password_required: true
|
||||
ports:
|
||||
@@ -68,6 +69,15 @@ design:
|
||||
bond: 1
|
||||
enabled: "{{ 'web-svc-design' in group_names }}"
|
||||
shared: "{{ 'web-svc-design' in group_names }}"
|
||||
slots:
|
||||
logo:
|
||||
width: 96
|
||||
height: 30
|
||||
frame: true
|
||||
login-logo:
|
||||
width: 96
|
||||
height: 40
|
||||
frame: true
|
||||
prometheus:
|
||||
bond: 1
|
||||
enabled: "{{ 'web-app-prometheus' in group_names }}"
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
---
|
||||
administrator: {}
|
||||
platform_administrator: {}
|
||||
@@ -0,0 +1,22 @@
|
||||
---
|
||||
- name: "⏳ Wait until pgAdmin is healthy before converging its administrator"
|
||||
ansible.builtin.shell:
|
||||
cmd: >-
|
||||
container inspect --type container --format '{% raw %}{{.State.Health.Status}}{% endraw %}' {{ PGADMIN_CONTAINER_ADDRESS }}
|
||||
register: pgadmin_health
|
||||
until: pgadmin_health.stdout | trim == "healthy"
|
||||
retries: 60
|
||||
delay: 5
|
||||
changed_when: false
|
||||
|
||||
- name: "🔑 Converge the pgAdmin administrator account"
|
||||
ansible.builtin.shell:
|
||||
cmd: >-
|
||||
container exec -i {{ PGADMIN_CONTAINER_ADDRESS }} /venv/bin/python3 -
|
||||
stdin: "{{ lookup('ansible.builtin.file', role_path ~ '/files/python/administrator.py') }}"
|
||||
register: pgadmin_administrator
|
||||
changed_when: "'PGADMIN_ADMINISTRATOR=changed' in pgadmin_administrator.stdout"
|
||||
failed_when: >-
|
||||
pgadmin_administrator.rc != 0
|
||||
or pgadmin_administrator.stdout_lines | select('match', '^PGADMIN_ADMINISTRATOR=(changed|ok)$') | list | length != 1
|
||||
timeout: "{{ lookup('timeout', 600) | int }}"
|
||||
@@ -6,3 +6,12 @@
|
||||
- name: "🔧 configure pgadmin servers"
|
||||
include_tasks: configuration.yml
|
||||
when: PGADMIN_SERVER_MODE | bool
|
||||
|
||||
- name: "🚿 Flush compose handlers for {{ application_id }}"
|
||||
ansible.builtin.meta: flush_handlers
|
||||
|
||||
- name: "🔑 converge the pgadmin administrator"
|
||||
include_tasks: administrator.yml
|
||||
when:
|
||||
- IS_STACK_HOST | bool
|
||||
- not PGADMIN_SERVER_MODE | bool
|
||||
@@ -0,0 +1,59 @@
|
||||
{% set design = lookup('design', application_id) %}
|
||||
(function () {
|
||||
var root = document.documentElement;
|
||||
function mirror(theme) {
|
||||
if (theme === "light" || theme === "dark") {
|
||||
root.setAttribute("data-design-theme", theme);
|
||||
} else {
|
||||
root.removeAttribute("data-design-theme");
|
||||
}
|
||||
if (theme === "high_contrast") {
|
||||
root.setAttribute("data-pgadmin-theme", theme);
|
||||
} else {
|
||||
root.removeAttribute("data-pgadmin-theme");
|
||||
}
|
||||
}
|
||||
function picked(message) {
|
||||
var data = message && message.data;
|
||||
if (!data || typeof data !== "object") {
|
||||
return;
|
||||
}
|
||||
var entries = Array.isArray(data) ? data : Object.keys(data).map(function (key) { return data[key]; });
|
||||
for (var index = 0; index < entries.length; index += 1) {
|
||||
var entry = entries[index];
|
||||
if (entry && entry.module === "misc" && entry.name === "theme") {
|
||||
mirror(entry.value);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (typeof window.theme === "string") {
|
||||
mirror(window.theme);
|
||||
}
|
||||
document.addEventListener("DOMContentLoaded", function () {
|
||||
if (typeof window.theme === "string") {
|
||||
mirror(window.theme);
|
||||
}
|
||||
});
|
||||
if (typeof window.BroadcastChannel === "function") {
|
||||
var channel = new window.BroadcastChannel("preference-change");
|
||||
channel.onmessage = function (event) {
|
||||
picked(event.data);
|
||||
};
|
||||
channel.postMessage("sync");
|
||||
}
|
||||
{% if design.logo is string %}
|
||||
var favicon = {{ design.urls.favicon_ico | to_json }};
|
||||
function brandIcon() {
|
||||
var icons = document.querySelectorAll("link[rel~='icon']");
|
||||
for (var index = 0; index < icons.length; index += 1) {
|
||||
if (icons[index].getAttribute("href") !== favicon) {
|
||||
icons[index].setAttribute("type", "image/x-icon");
|
||||
icons[index].setAttribute("href", favicon);
|
||||
}
|
||||
}
|
||||
}
|
||||
new MutationObserver(brandIcon).observe(document.head, { childList: true, subtree: true, attributes: true, attributeFilter: ["href"] });
|
||||
brandIcon();
|
||||
{% endif %}
|
||||
})();
|
||||
@@ -6,7 +6,7 @@ PGADMIN_DEFAULT_EMAIL={{ PGADMIN_ADMIN_EMAIL }}
|
||||
|
||||
PGADMIN_CONFIG_ALLOW_SPECIAL_EMAIL_DOMAINS=['{{ PGADMIN_ADMIN_EMAIL_TLD }}']
|
||||
|
||||
PGADMIN_DEFAULT_PASSWORD={{ lookup('config', application_id, 'secrets.credentials.administrator_password') | dotenv_quote }}
|
||||
PGADMIN_DEFAULT_PASSWORD={{ PGADMIN_ADMIN_PASSWORD | dotenv_quote }}
|
||||
|
||||
{% if PGADMIN_SERVER_MODE | bool %}
|
||||
PGADMIN_SERVER_JSON_FILE={{ pgadmin_docker_server_file }}
|
||||
@@ -26,3 +26,8 @@ PGADMIN_CONFIG_MAIL_USERNAME={{ email.username | tojson | dotenv_quote }}
|
||||
PGADMIN_CONFIG_MAIL_PASSWORD={{ email.password | tojson | dotenv_quote }}
|
||||
PGADMIN_CONFIG_MAIL_DEFAULT_SENDER={{ email['from'] | tojson | dotenv_quote }}
|
||||
{% endif %}
|
||||
|
||||
{% set design = lookup('design', application_id) %}
|
||||
{% if lookup('config', application_id, 'services.design.enabled') | bool and design.title is string %}
|
||||
PGADMIN_CONFIG_APP_NAME={{ design.title | tojson | dotenv_quote }}
|
||||
{% endif %}
|
||||
@@ -1,10 +1,24 @@
|
||||
ADMIN_EMAIL={{ lookup('users', 'platform_administrator').email | dotenv_quote }}
|
||||
|
||||
ADMIN_PASSWORD={{ lookup('users', 'platform_administrator').password | dotenv_quote }}
|
||||
|
||||
ADMIN_USERNAME={{ lookup('users', 'platform_administrator').username | dotenv_quote }}
|
||||
|
||||
APP_BASE_URL={{ lookup('tls', application_id, 'url.base') | dotenv_quote }}
|
||||
|
||||
CANONICAL_DOMAIN={{ lookup('domain', application_id) | dotenv_quote }}
|
||||
|
||||
DESIGN_FAVICON_URL={{ (lookup('design', application_id).urls.favicon_ico if lookup('design', application_id).logo is string else '') | dotenv_quote }}
|
||||
|
||||
DESIGN_LOGIN_LOGO_URL={{ (lookup('design', application_id).urls['login-logo'].svg if lookup('design', application_id).logo is string else '') | dotenv_quote }}
|
||||
|
||||
DESIGN_LOGO_URL={{ (lookup('design', application_id).urls.logo.svg if lookup('design', application_id).logo is string else '') | dotenv_quote }}
|
||||
|
||||
# nocheck: playwright-service-gate — the CSP helper gates design through a computed loop variable (personas/utils/csp.js:87-93), never a literal argument, so test_spec_env_gates.py's literal-only regex cannot see the gate
|
||||
DESIGN_SERVICE_ENABLED={{ lookup('config', application_id, 'services.design.enabled') | string | lower }}
|
||||
|
||||
DESIGN_TITLE={{ (lookup('design', application_id).title if lookup('design', application_id).title is string else '') | dotenv_quote }}
|
||||
|
||||
# nocheck: playwright-service-gate — SMTP backs only pgAdmin password-reset mail, and no persona scenario triggers a mail send
|
||||
EMAIL_SERVICE_ENABLED={{ lookup('config', application_id, 'services.email.enabled') | string | lower }}
|
||||
|
||||
@@ -20,6 +34,16 @@ PERSONA_ADMINISTRATOR_BLOCKED=true
|
||||
# nocheck: persona-block-rationale — meta/services.yml admits only the web-app-pgadmin administrator RBAC group to the oauth2-proxy (sso.oauth2.allowed_groups) and biber carries no roles, so biber is denied at the proxy before pgAdmin is reached (see README "Persona contract opt-outs").
|
||||
PERSONA_BIBER_BLOCKED=true
|
||||
|
||||
PGADMIN_DB_HOST={{ lookup('database', application_id, 'host') | dotenv_quote }}
|
||||
|
||||
PGADMIN_DB_NAME={{ lookup('database', application_id, 'name') | dotenv_quote }}
|
||||
|
||||
PGADMIN_DB_PASSWORD={{ lookup('database', application_id, 'password') | dotenv_quote }}
|
||||
|
||||
PGADMIN_DB_PORT={{ lookup('database', application_id, 'port') | string | dotenv_quote }}
|
||||
|
||||
PGADMIN_DB_USER={{ lookup('database', application_id, 'username') | dotenv_quote }}
|
||||
|
||||
# nocheck: playwright-service-gate — pgAdmin keeps its own config in embedded SQLite and only reaches the cluster from the server dialog no persona opens
|
||||
POSTGRES_SERVICE_ENABLED={{ lookup('config', application_id, 'services.postgres.enabled') | string | lower }}
|
||||
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
{% set design = lookup('design', application_id) %}
|
||||
{% set scope = 'html:not([data-pgadmin-theme="high_contrast"]) body' %}
|
||||
{{ scope }} {
|
||||
background-color: var(--design-surface-1);
|
||||
color: var(--design-text);
|
||||
}
|
||||
|
||||
{{ scope }} .dock-layout .dock-content,
|
||||
{{ scope }} .dock-layout .dock-bar,
|
||||
{{ scope }} .dock-layout .dock-panel .dock-tabpane {
|
||||
background-color: var(--design-surface-1);
|
||||
color: var(--design-text);
|
||||
}
|
||||
|
||||
{{ scope }} .dock-layout .dock-tab {
|
||||
color: var(--design-text-muted);
|
||||
}
|
||||
|
||||
{{ scope }} .dock-layout .dock-tab.dock-tab-active {
|
||||
color: var(--design-link);
|
||||
}
|
||||
|
||||
{{ scope }} .dock-layout .dock-ink-bar {
|
||||
background-color: var(--design-primary);
|
||||
}
|
||||
|
||||
{{ scope }} .dock-layout .dock-divider::before {
|
||||
background-color: var(--design-border);
|
||||
}
|
||||
|
||||
{{ scope }} .file-tree {
|
||||
color: var(--design-text) !important;
|
||||
background-color: var(--design-surface-1) !important;
|
||||
}
|
||||
|
||||
{{ scope }} .file-entry:hover,
|
||||
{{ scope }} .file-entry.pseudo-active {
|
||||
color: var(--design-text) !important;
|
||||
background-color: var(--design-surface-hover) !important;
|
||||
}
|
||||
|
||||
{{ scope }} .file-entry.active,
|
||||
{{ scope }} .file-entry.prompt {
|
||||
color: var(--design-text) !important;
|
||||
background-color: var(--design-surface-active) !important;
|
||||
}
|
||||
|
||||
{{ scope }} [data-test="app-menu-bar"] {
|
||||
--design-focus: var(--design-on-frame);
|
||||
background-color: var(--design-frame);
|
||||
color: var(--design-on-frame);
|
||||
}
|
||||
|
||||
{{ scope }} [data-test="app-menu-bar"] .MuiButton-root {
|
||||
background-color: var(--design-frame);
|
||||
color: var(--design-on-frame);
|
||||
border-color: var(--design-frame);
|
||||
}
|
||||
|
||||
{{ scope }} [data-test="app-menu-bar"] .MuiButton-root:hover {
|
||||
background-color: var(--design-frame-hover);
|
||||
}
|
||||
|
||||
{{ scope }} [data-test="app-menu-bar"] .MuiButton-root[aria-expanded="true"] {
|
||||
background-color: var(--design-frame-active);
|
||||
}
|
||||
|
||||
{{ scope }} .szh-menu {
|
||||
--design-focus: var(--design-link);
|
||||
background-color: var(--design-surface-2);
|
||||
color: var(--design-text);
|
||||
border-color: var(--design-border);
|
||||
}
|
||||
|
||||
{{ scope }} .MuiPaper-root,
|
||||
{{ scope }} .MuiDialog-paper {
|
||||
background-color: var(--design-surface-2);
|
||||
color: var(--design-text);
|
||||
}
|
||||
|
||||
{{ scope }} .MuiTabs-root,
|
||||
{{ scope }} .MuiAccordionSummary-root,
|
||||
{{ scope }} .MuiCardHeader-root {
|
||||
background-color: var(--design-surface-2);
|
||||
border-color: var(--design-border);
|
||||
}
|
||||
|
||||
{{ scope }} .MuiTab-root.Mui-selected {
|
||||
color: var(--design-link);
|
||||
}
|
||||
|
||||
{{ scope }} .MuiTabs-indicator {
|
||||
background-color: var(--design-primary);
|
||||
}
|
||||
|
||||
{{ scope }} .MuiInputBase-root,
|
||||
{{ scope }} .MuiInputBase-input {
|
||||
background-color: var(--design-surface-2);
|
||||
color: var(--design-text);
|
||||
}
|
||||
|
||||
{{ scope }} .MuiOutlinedInput-notchedOutline {
|
||||
border-color: var(--design-border-strong);
|
||||
}
|
||||
|
||||
{{ scope }} .MuiInputBase-root.Mui-disabled,
|
||||
{{ scope }} .MuiInputBase-input[readonly] {
|
||||
background-color: var(--design-surface-3);
|
||||
color: var(--design-text-muted);
|
||||
}
|
||||
|
||||
{{ scope }} .MuiFormLabel-root {
|
||||
color: var(--design-text);
|
||||
}
|
||||
|
||||
{{ scope }} .MuiFormHelperText-root,
|
||||
{{ scope }} .text-muted {
|
||||
color: var(--design-text-muted);
|
||||
}
|
||||
|
||||
{{ scope }} .MuiButton-containedPrimary {
|
||||
background-color: var(--design-primary);
|
||||
color: var(--design-on-primary);
|
||||
border-color: var(--design-primary);
|
||||
}
|
||||
|
||||
{{ scope }} .MuiButton-containedPrimary:hover {
|
||||
background-color: var(--design-primary-hover);
|
||||
border-color: var(--design-primary-hover);
|
||||
}
|
||||
|
||||
{{ scope }} .MuiButton-outlined {
|
||||
background-color: var(--design-surface-2);
|
||||
color: var(--design-text);
|
||||
border-color: var(--design-border-strong);
|
||||
}
|
||||
|
||||
{{ scope }} .MuiButton-outlined:hover {
|
||||
background-color: var(--design-surface-hover);
|
||||
}
|
||||
|
||||
{{ scope }} .MuiListItemButton-root.Mui-selected,
|
||||
{{ scope }} .MuiToggleButton-root.Mui-selected {
|
||||
background-color: var(--design-surface-active);
|
||||
color: var(--design-text);
|
||||
}
|
||||
|
||||
{{ scope }} .MuiTooltip-tooltip {
|
||||
background-color: var(--design-text);
|
||||
color: var(--design-surface-1);
|
||||
}
|
||||
|
||||
{{ scope }} a {
|
||||
color: var(--design-link);
|
||||
}
|
||||
|
||||
{{ scope }} .cm-editor {
|
||||
background-color: var(--design-surface-1);
|
||||
}
|
||||
|
||||
{{ scope }} .cm-editor .cm-gutters {
|
||||
background-color: var(--design-surface-3);
|
||||
color: var(--design-text-muted);
|
||||
border-color: var(--design-border);
|
||||
}
|
||||
|
||||
{{ scope }} .cm-editor .cm-activeLine {
|
||||
background-color: var(--design-surface-hover);
|
||||
}
|
||||
|
||||
{{ scope }} [data-test="app-menu-bar"] .MuiButton-root:focus-visible,
|
||||
{{ scope }} [data-test="app-menu-bar"] .MuiButton-root.Mui-focusVisible {
|
||||
outline: 2px solid var(--design-focus);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
html body #root > div:has(> div > .BasePage-pageContent) {
|
||||
background-color: var(--design-frame);
|
||||
color: var(--design-on-frame);
|
||||
}
|
||||
|
||||
html body #root .BasePage-pageContent {
|
||||
background-color: var(--design-frame-active);
|
||||
color: var(--design-on-frame);
|
||||
}
|
||||
|
||||
html body #root .BasePage-pageContent a {
|
||||
color: var(--design-on-frame);
|
||||
}
|
||||
|
||||
html body #root .BasePage-pageContent .MuiButton-root.MuiButton-outlinedPrimary {
|
||||
background-color: var(--design-primary);
|
||||
border-color: var(--design-primary);
|
||||
color: var(--design-on-primary);
|
||||
}
|
||||
|
||||
html body #root .BasePage-pageContent .MuiButton-root.MuiButton-outlinedPrimary:hover {
|
||||
background-color: var(--design-primary-hover);
|
||||
}
|
||||
{% if design.logo is string %}
|
||||
|
||||
html body #root [data-test="app-menu-bar"] .AppMenuBar-logo {
|
||||
background: url("{{ design.urls.logo.svg }}") center / contain no-repeat;
|
||||
}
|
||||
|
||||
html body #root .BasePage-logo {
|
||||
background: url("{{ design.urls['login-logo'].svg }}") center / contain no-repeat;
|
||||
}
|
||||
{% endif %}
|
||||
@@ -2,8 +2,11 @@
|
||||
application_id: "web-app-pgadmin"
|
||||
entity_name: "{{ application_id | get_entity_name }}"
|
||||
|
||||
PGADMIN_ADMIN_EMAIL: "{{ lookup('config', application_id, 'users.administrator.email') }}"
|
||||
PGADMIN_ADMIN_EMAIL: "{{ lookup('config', application_id, 'users.platform_administrator.email') }}"
|
||||
PGADMIN_ADMIN_EMAIL_TLD: "{{ PGADMIN_ADMIN_EMAIL.split('@')[1].split('.')[-1] }}"
|
||||
PGADMIN_ADMIN_PASSWORD: "{{ lookup('users', 'platform_administrator').password }}"
|
||||
|
||||
PGADMIN_CONTAINER_ADDRESS: "{{ lookup('container_address', application_id, entity_name) }}"
|
||||
|
||||
PGADMIN_SERVER_MODE: "{{ lookup('config', application_id, 'services.' ~ entity_name ~ '.server_mode') | bool }}"
|
||||
PGADMIN_MASTER_PASSWORD_REQUIRED: "{{ lookup('config', application_id, 'services.' ~ entity_name ~ '.master_password_required') | bool }}"
|
||||
|
||||
Whitespace-only changes.
@@ -0,0 +1,3 @@
|
||||
from pathlib import Path
|
||||
|
||||
PROJECT_ROOT: Path = Path(__file__).resolve().parents[6]
|
||||
@@ -0,0 +1,142 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import types
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
from . import PROJECT_ROOT
|
||||
|
||||
SCRIPT_PATH = PROJECT_ROOT / "roles/web-app-pgadmin/files/python/administrator.py"
|
||||
EMAIL = "administrator@example.org"
|
||||
PASSWORD = "p$ssw0rd-from-the-users-spot"
|
||||
|
||||
|
||||
def load_script() -> object:
|
||||
spec = importlib.util.spec_from_file_location("administrator", SCRIPT_PATH)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
class FakeQuery:
|
||||
def __init__(self, rows):
|
||||
self.rows = rows
|
||||
self.matched = []
|
||||
|
||||
def filter_by(self, **criteria):
|
||||
self.matched = [
|
||||
row
|
||||
for row in self.rows
|
||||
if all(getattr(row, key) == value for key, value in criteria.items())
|
||||
]
|
||||
return self
|
||||
|
||||
def first(self):
|
||||
return self.matched[0] if self.matched else None
|
||||
|
||||
def one(self):
|
||||
assert len(self.matched) == 1
|
||||
return self.matched[0]
|
||||
|
||||
|
||||
class FakeBackend:
|
||||
def __init__(self, user=None):
|
||||
self.admin = types.SimpleNamespace(id=1, name="Administrator")
|
||||
self.user = user
|
||||
self.created = []
|
||||
self.commits = 0
|
||||
|
||||
def modules(self) -> dict:
|
||||
session = types.SimpleNamespace(commit=self._commit)
|
||||
entries = {
|
||||
"flask_security": {},
|
||||
"flask_security.utils": {
|
||||
"hash_password": lambda plain: f"hashed-{plain}",
|
||||
"verify_password": lambda plain, hashed: hashed == f"hashed-{plain}",
|
||||
},
|
||||
"pgadmin": {},
|
||||
"pgadmin.model": {
|
||||
"Role": types.SimpleNamespace(query=FakeQuery([self.admin])),
|
||||
"User": types.SimpleNamespace(
|
||||
query=FakeQuery([self.user] if self.user else [])
|
||||
),
|
||||
"db": types.SimpleNamespace(session=session),
|
||||
},
|
||||
"pgadmin.tools": {},
|
||||
"pgadmin.tools.user_management": {
|
||||
"create_user": self._create_user,
|
||||
"normalise_password": lambda plain: plain,
|
||||
},
|
||||
"pgadmin.utils": {},
|
||||
"pgadmin.utils.constants": {"INTERNAL": "internal"},
|
||||
}
|
||||
out = {}
|
||||
for name, attrs in entries.items():
|
||||
module = types.ModuleType(name)
|
||||
for attr, value in attrs.items():
|
||||
setattr(module, attr, value)
|
||||
out[name] = module
|
||||
return out
|
||||
|
||||
def _create_user(self, data):
|
||||
self.created.append(data)
|
||||
return True, ""
|
||||
|
||||
def _commit(self):
|
||||
self.commits += 1
|
||||
|
||||
|
||||
def account(**overrides) -> types.SimpleNamespace:
|
||||
fields = {
|
||||
"email": EMAIL,
|
||||
"auth_source": "internal",
|
||||
"password": f"hashed-{PASSWORD}",
|
||||
"active": True,
|
||||
"locked": False,
|
||||
"login_attempts": 0,
|
||||
"roles": [],
|
||||
}
|
||||
fields.update(overrides)
|
||||
return types.SimpleNamespace(**fields)
|
||||
|
||||
|
||||
class TestConvergeAdministrator(unittest.TestCase):
|
||||
def run_script(self, backend: FakeBackend) -> str:
|
||||
module = load_script()
|
||||
with patch.dict("sys.modules", backend.modules()):
|
||||
return module.converge(EMAIL, PASSWORD)
|
||||
|
||||
def test_a_missing_administrator_is_created_with_the_spot_password(self) -> None:
|
||||
backend = FakeBackend()
|
||||
self.assertEqual(self.run_script(backend), "changed")
|
||||
self.assertEqual(backend.created[0]["email"], EMAIL)
|
||||
self.assertEqual(backend.created[0]["newPassword"], PASSWORD)
|
||||
self.assertEqual(backend.created[0]["role"], backend.admin.id)
|
||||
|
||||
def test_a_rotated_password_reaches_the_existing_administrator(self) -> None:
|
||||
backend = FakeBackend()
|
||||
backend.user = account(password="hashed-an-older-secret", roles=[backend.admin])
|
||||
self.assertEqual(self.run_script(backend), "changed")
|
||||
self.assertEqual(backend.user.password, f"hashed-{PASSWORD}")
|
||||
self.assertEqual(backend.commits, 1)
|
||||
|
||||
def test_a_locked_administrator_without_the_role_is_repaired(self) -> None:
|
||||
backend = FakeBackend()
|
||||
backend.user = account(locked=True, login_attempts=3, active=False)
|
||||
self.assertEqual(self.run_script(backend), "changed")
|
||||
self.assertEqual(
|
||||
(backend.user.locked, backend.user.login_attempts, backend.user.active),
|
||||
(False, 0, True),
|
||||
)
|
||||
self.assertIn(backend.admin, backend.user.roles)
|
||||
|
||||
def test_a_converged_administrator_is_left_alone(self) -> None:
|
||||
backend = FakeBackend()
|
||||
backend.user = account(roles=[backend.admin])
|
||||
self.assertEqual(self.run_script(backend), "ok")
|
||||
self.assertEqual((backend.created, backend.commits), ([], 0))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in new issue
Block a user