Files
adminroot 8b40301f8e Release v0.2.6
Add Justified galleries, albums, uploaded image search, and shared image
menus for desktop and touch with themed information dialogs.

Add administrator setup, account settings, optional passwordless guest
browsing, and OpenList storage. Refine settings, viewer interactions,
and branding.

Rename persistent application data to pixhelf-data and streamline the
README around Docker deployment, including legacy data migration.

Validated: frontend and Rust builds, 112 Rust tests (2 network tests
ignored), 198 layout cases, HTTP/HTTPS authentication and guest browser
checks, and Docker Compose configuration.
2026-09-09 13:19:35 +00:00

554 lines
35 KiB
JavaScript

import assert from "node:assert/strict";
import { spawn, spawnSync } from "node:child_process";
import { mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises";
import http from "node:http";
import https from "node:https";
import net from "node:net";
import { tmpdir } from "node:os";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { deflateSync } from "node:zlib";
import { chromium } from "playwright-core";
// Isolated real backends and browser tests for HTTPS and an ordinary, non-secure HTTP origin.
const project = resolve(dirname(fileURLToPath(import.meta.url)), "../..");
const binary = process.env.PIXHELF_TEST_BINARY ?? join(project, "target/debug/pixhelf");
async function checkAuthentication(protocol) {
const directory = await mkdtemp(join(tmpdir(), "pixhelf-auth-check-"));
const password = "browser-test-only-password";
let backend;
let browser;
let proxy;
const tunnels = new Set();
let backendLog = "";
function png(index) {
const width = 96, height = 72;
const raw = Buffer.alloc((width * 3 + 1) * height);
for (let y = 0; y < height; y++) for (let x = 0; x < width; x++) {
const offset = y * (width * 3 + 1) + 1 + x * 3;
raw[offset] = (index * 17 + x) % 256;
raw[offset + 1] = (index * 31 + y) % 256;
raw[offset + 2] = 120;
}
const chunk = (type, data) => {
const contents = Buffer.concat([Buffer.from(type), data]);
let crc = 0xffffffff;
for (const byte of contents) {
crc ^= byte;
for (let bit = 0; bit < 8; bit++) crc = (crc >>> 1) ^ (crc & 1 ? 0xedb88320 : 0);
}
const length = Buffer.alloc(4), checksum = Buffer.alloc(4);
length.writeUInt32BE(data.length);
checksum.writeUInt32BE((crc ^ 0xffffffff) >>> 0);
return Buffer.concat([length, contents, checksum]);
};
const header = Buffer.alloc(13);
header.writeUInt32BE(width); header.writeUInt32BE(height, 4); header[8] = 8; header[9] = 2;
return Buffer.concat([Buffer.from("89504e470d0a1a0a", "hex"), chunk("IHDR", header), chunk("IDAT", deflateSync(raw)), chunk("IEND", Buffer.alloc(0))]);
}
async function listen(server) {
await new Promise((resolve, reject) => { server.once("error", reject); server.listen(0, "127.0.0.1", resolve); });
return server.address().port;
}
async function eventually(predicate, description, timeout = 15_000) {
const deadline = Date.now() + timeout;
while (Date.now() < deadline) {
if (await predicate()) return;
await new Promise(resolve => setTimeout(resolve, 100));
}
throw new Error(`Timed out: ${description}`);
}
try {
await mkdir(join(directory, "gallery"));
for (let index = 0; index < 32; index++) await writeFile(join(directory, "gallery", `fixture-${index}.png`), png(index));
const hash = spawnSync(binary, ["--hash-password"], { input: `${password}\n`, encoding: "utf8" });
assert.equal(hash.status, 0, hash.stderr);
assert.match(hash.stdout.trim(), /^\$argon2id\$/);
for (const args of [["--hash-password", "disallowed-argument"], ["--hash-password"]]) {
const rejected = spawnSync(binary, args, { input: "short\n", encoding: "utf8" });
assert.notEqual(rejected.status, 0, "unsafe password input accepted");
assert.equal(rejected.stdout, "", "failed password generation printed a hash");
}
const certificate = spawnSync("openssl", ["req", "-x509", "-newkey", "rsa:2048", "-nodes", "-sha256", "-days", "1", "-subj", "/CN=localhost", "-addext", "subjectAltName=IP:127.0.0.1", "-keyout", join(directory, "key.pem"), "-out", join(directory, "cert.pem")], { encoding: "utf8" });
assert.equal(certificate.status, 0, certificate.stderr);
const reservation = net.createServer();
const backendPort = await listen(reservation);
await new Promise(resolve => reservation.close(resolve));
const forward = (request, response) => {
const url = new URL(request.url, "http://pixhelf.test");
const upstream = http.request({ hostname: "127.0.0.1", port: backendPort, path: url.pathname + url.search, method: request.method, headers: { ...request.headers, host: "pixhelf:3002", "x-forwarded-for": request.socket.remoteAddress, "x-forwarded-proto": "https" } }, result => {
response.writeHead(result.statusCode, result.headers);
result.pipe(response);
});
upstream.on("error", () => { response.writeHead(502); response.end(); });
request.pipe(upstream);
};
proxy = protocol === "https"
? https.createServer({ key: await readFile(join(directory, "key.pem")), cert: await readFile(join(directory, "cert.pem")) }, forward)
: http.createServer(forward);
const proxyPort = await listen(proxy);
proxy.on("connect", (request, downstream, head) => {
if (protocol !== "http" || request.url !== `pixhelf.test:${proxyPort}`) { downstream.destroy(); return; }
const upstream = net.connect({ host: "127.0.0.1", port: backendPort });
tunnels.add(downstream);
downstream.on("close", () => { tunnels.delete(downstream); upstream.destroy(); });
downstream.on("error", () => upstream.destroy());
upstream.on("error", () => downstream.destroy());
upstream.on("close", () => downstream.destroy());
upstream.once("connect", () => {
downstream.write("HTTP/1.1 200 Connection Established\r\n\r\n");
if (head.length) upstream.write(head);
downstream.pipe(upstream).pipe(downstream);
});
});
// A virtual host through the local HTTP proxy avoids localhost's secure-context exception.
const contextOptions = protocol === "http" ? { proxy: { server: `http://127.0.0.1:${proxyPort}` } } : {};
const origin = protocol === "https" ? `https://127.0.0.1:${proxyPort}` : `http://pixhelf.test:${proxyPort}`;
const screenshotSuffix = protocol === "https" ? "" : "-http";
const environment = { ...process.env, RUST_LOG: "warn" };
for (const key of Object.keys(environment)) {
if (key.startsWith("PIXHELF_AUTH_") || ["PIXHELF_PUBLIC_URL", "PIXHELF_TRUSTED_PROXIES"].includes(key)) delete environment[key];
}
const startBackend = async () => {
backendLog = "";
backend = spawn(binary, ["--listen", `127.0.0.1:${backendPort}`, "--gallery-dir", join(directory, "gallery"), "--cache-dir", join(directory, "cache"), "--text-search-model", "false", "--workers", "1"], { env: environment, stdio: ["ignore", "pipe", "pipe"] });
for (const stream of [backend.stdout, backend.stderr]) stream.on("data", chunk => { backendLog = (backendLog + chunk.toString()).slice(-16_000); });
await eventually(async () => {
assert.equal(backend.exitCode, null, backendLog);
try { return (await fetch(`http://127.0.0.1:${backendPort}/api/health`)).ok; } catch { return false; }
}, "backend startup");
};
await startBackend();
browser = await chromium.launch({ executablePath: process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE ?? "/root/.cache/ms-playwright/chromium-1234/chrome-linux64/chrome", headless: true, args: ["--no-sandbox", "--disable-dev-shm-usage"] });
assert.equal(/setup code/i.test(backendLog), false, "startup still requires a setup code");
const setupContext = await browser.newContext({ ...contextOptions, ignoreHTTPSErrors: true, viewport: { width: 1440, height: 844 } });
const setupPage = await setupContext.newPage();
const setupErrors = [], setupPrivateRequests = [];
let setupRequests = 0;
setupPage.on("pageerror", error => setupErrors.push(error.message));
setupPage.on("request", request => {
const path = new URL(request.url()).pathname;
if (/^\/api\/(gallery|images|status)/.test(path)) setupPrivateRequests.push(path);
if (path === "/api/auth/setup") setupRequests++;
});
for (const width of [1440, 720, 390, 320]) {
await setupPage.setViewportSize({ width, height: 844 });
await setupPage.goto(origin);
assert.equal(new URL(setupPage.url()).pathname, "/setup");
await setupPage.getByRole("heading", { name: "创建管理员", exact: true }).waitFor();
assert.equal(await setupPage.locator("#setup-username").inputValue(), "admin");
assert.equal(await setupPage.locator("#setup-code").count(), 0);
assert.equal(await setupPage.evaluate(() => isSecureContext), protocol === "https");
assert.deepEqual(setupPrivateRequests, []);
assert.equal(await setupPage.evaluate(() => document.documentElement.scrollWidth > innerWidth), false);
await setupPage.waitForTimeout(250);
await setupPage.screenshot({ path: join(tmpdir(), `pixhelf-setup${screenshotSuffix}-${width}.png`), fullPage: true });
}
await setupPage.locator("#setup-password").fill("short");
await setupPage.locator(".login-submit").click();
assert.equal(await setupPage.locator("#setup-password").evaluate(input => input.validity.tooShort), true);
assert.equal(setupRequests, 0, "client submitted a password that is too short");
await setupPage.locator("#setup-password").fill(password);
await setupPage.getByRole("button", { name: "显示密码", exact: true }).click();
assert.equal(await setupPage.locator("#setup-password").getAttribute("type"), "text");
await setupPage.getByRole("button", { name: "隐藏密码", exact: true }).click();
assert.equal((await setupContext.request.get(`${origin}/api/gallery`)).status(), 401);
const beforeSetup = setupRequests;
await setupPage.evaluate(() => { const form = document.querySelector("form"); form.requestSubmit(); form.requestSubmit(); });
await setupPage.waitForURL(`${origin}/`);
await setupPage.locator(".image-card").nth(24).waitFor();
const setupCookie = (await setupContext.cookies()).find(cookie => cookie.name === "pixhelf-session");
assert.ok(setupCookie?.httpOnly && setupCookie.sameSite === "Lax");
assert.equal(setupCookie.secure, protocol === "https");
assert.equal(setupRequests - beforeSetup, 1);
const savedText = await readFile(join(directory, "cache/auth/account.json"), "utf8");
const saved = JSON.parse(savedText);
assert.equal(saved.publicOrigin, undefined);
assert.equal(saved.username, "admin");
assert.equal(savedText.includes(password), false);
assert.match(saved.passwordHash, /^\$argon2id\$/);
assert.equal((await setupContext.request.post(`${origin}/api/auth/setup`, { headers: { Origin: origin, "X-Pixhelf-Origin": origin }, data: { username: "attacker", password } })).status(), 409);
assert.deepEqual(setupErrors, []);
await setupContext.close();
const exited = new Promise(resolve => backend.once("exit", resolve));
backend.kill("SIGTERM");
await exited;
// Simulate an existing installation initialized at a different domain, port and protocol.
saved.publicOrigin = protocol === "https" ? "http://old-gallery.test:3002" : "https://old-gallery.test:8443";
await writeFile(join(directory, "cache/auth/account.json"), JSON.stringify(saved));
await startBackend();
assert.equal((await (await fetch(`http://127.0.0.1:${backendPort}/api/auth/session`)).json()).setupRequired, undefined, "restart reopened initialization");
console.log(JSON.stringify({ protocol, webSetup: true, noAuthEnvironment: true, noSetupCode: true, automaticAccessAddress: true, persistentAfterRestart: true }));
const openSettings = async (page) => {
const menu = page.locator(".gallery-sidebar-toggle");
if (await menu.getAttribute("aria-expanded") !== "true") await menu.click();
const panel = page.viewportSize().width <= 720 ? ".mobile-sidebar" : ".desktop-sidebar";
await page.locator(`${panel} .sidebar-settings-button`).click();
await page.getByRole("dialog", { name: "设置", exact: true }).waitFor();
};
for (const width of [1440, 720, 390, 320]) {
const context = await browser.newContext({ ...contextOptions, ignoreHTTPSErrors: true, viewport: { width, height: 844 }, hasTouch: width <= 720 });
const page = await context.newPage();
const errors = [], privateRequests = [];
let loginRequests = 0;
page.on("pageerror", error => errors.push(error.message));
page.on("console", message => { if (message.type() === "error" && /Content Security Policy|violates.*directive/i.test(message.text())) errors.push(message.text()); });
page.on("request", request => {
const path = new URL(request.url()).pathname;
if (/^\/api\/(gallery|images|status)/.test(path)) privateRequests.push(path);
if (path === "/api/auth/login") loginRequests++;
});
await page.goto(origin);
await page.getByRole("heading", { name: "登录 Pixhelf", exact: true }).waitFor();
assert.equal(new URL(page.url()).pathname, "/login");
assert.equal(await page.locator(".image-card").count(), 0);
assert.deepEqual(privateRequests, [], "login page fetched private data");
assert.equal(await page.evaluate(() => document.documentElement.scrollWidth > innerWidth), false);
await page.waitForTimeout(250);
await page.screenshot({ path: join(tmpdir(), `pixhelf-login${screenshotSuffix}-${width}.png`) });
await page.getByLabel("用户名", { exact: true }).fill("admin");
await page.getByLabel("密码", { exact: true }).fill(password);
await page.getByRole("button", { name: "显示密码", exact: true }).click();
assert.equal(await page.locator("#login-password").getAttribute("type"), "text");
await page.getByRole("button", { name: "隐藏密码", exact: true }).click();
if (width === 1440) {
await page.locator("#login-password").fill("wrong-password");
await page.locator(".login-submit").click();
await page.getByRole("alert").filter({ hasText: "用户名或密码不正确" }).waitFor();
assert.equal(await page.locator("#login-password").inputValue(), "");
assert.equal(await page.locator("#login-username").inputValue(), "admin");
await page.locator("#login-password").fill(password);
await page.route("**/api/auth/login", route => route.fulfill({ status: 429, contentType: "application/json", headers: { "Retry-After": "1" }, body: JSON.stringify({ error: "尝试次数过多,请稍后再试" }) }), { times: 1 });
await page.locator(".login-submit").click();
await page.getByRole("button", { name: "1 秒后重试", exact: true }).waitFor();
assert.equal(await page.locator(".login-submit").isDisabled(), true);
await page.waitForFunction(() => !document.querySelector(".login-submit").disabled);
await page.route("**/api/auth/login", route => route.fulfill({ status: 200, contentType: "text/html", body: "upstream error" }), { times: 1 });
await page.locator(".login-submit").click();
await page.getByRole("alert").filter({ hasText: "无效登录结果" }).waitFor();
assert.equal(new URL(page.url()).pathname, "/login");
}
const beforeLogin = loginRequests;
await page.evaluate(() => { const form = document.querySelector("form"); form.requestSubmit(); form.requestSubmit(); });
await page.waitForURL(`${origin}/`);
await page.locator(".image-card").nth(24).waitFor();
assert.equal(loginRequests - beforeLogin, 1, "double submission sent multiple password checks");
const cookie = (await context.cookies()).find(cookie => cookie.name === "pixhelf-session");
assert.ok(cookie?.httpOnly && cookie.sameSite === "Lax" && cookie.path === "/");
assert.equal(cookie.secure, protocol === "https");
assert.equal(await page.evaluate(() => document.cookie.includes("pixhelf-session")), false);
const session = await (await context.request.get(`${origin}/api/auth/session`)).json();
assert.equal(session.authenticated, true);
const images = await (await context.request.get(`${origin}/api/images`)).json();
const original = `${origin}/api/images/${images.items[0].id}/original`;
const media = await context.request.get(original, { headers: { Range: "bytes=0-7" } });
assert.equal(media.status(), 206);
assert.equal(media.headers()["cache-control"], "private, no-cache");
assert.equal(session.canChangePassword, true);
await openSettings(page);
const dialog = page.getByRole("dialog", { name: "设置", exact: true });
assert.equal(await dialog.evaluate(element => element.matches(":modal")), true);
assert.equal(await page.evaluate(() => document.documentElement.scrollWidth > innerWidth), false);
await dialog.locator('[name="currentPassword"]').fill("wrong-current-password");
assert.equal(await dialog.locator('[name="confirmPassword"]').count(), 0);
assert.equal(await dialog.locator('[name="username"]').inputValue(), "admin");
await dialog.locator('[name="newPassword"]').fill("short");
await dialog.getByRole("button", { name: "保存账号", exact: true }).click();
await dialog.getByRole("alert").filter({ hasText: "新密码至少需要 15 个字符" }).waitFor();
await dialog.locator('[name="newPassword"]').fill("another-long-test-password");
if (width === 1440) {
await dialog.getByRole("button", { name: "保存账号", exact: true }).click();
await dialog.getByRole("alert").filter({ hasText: "当前密码不正确" }).waitFor();
assert.equal(await dialog.locator('[name="currentPassword"]').inputValue(), "");
assert.equal((await context.request.get(original)).status(), 200);
}
await page.screenshot({ path: join(tmpdir(), `pixhelf-settings${screenshotSuffix}-${width}.png`) });
await page.keyboard.press("Escape");
await dialog.waitFor({ state: "detached" });
await openSettings(page);
assert.equal(await page.locator('[name="newPassword"]').inputValue(), "", "closing settings retained password inputs");
await dialog.locator(".settings-header h2").click();
assert.equal(await dialog.isVisible(), true, "click inside settings closed the dialog");
await page.mouse.click(2, 2);
await dialog.waitFor({ state: "detached" });
await openSettings(page);
await page.getByRole("button", { name: "关闭设置", exact: true }).click();
// Leave an authenticated document in history to exercise back navigation after logout.
await page.goto(`${origin}/?history-check=1`);
await page.locator(".image-card").first().waitFor();
const second = await context.newPage();
await second.goto(origin);
await second.locator(".image-card").first().waitFor();
await page.bringToFront();
const menu = page.locator(".gallery-sidebar-toggle");
if (await menu.getAttribute("aria-expanded") !== "true") await menu.click();
const panel = width <= 720 ? ".mobile-sidebar" : ".desktop-sidebar";
const account = page.locator(`${panel} .sidebar-account-profile`);
const settings = page.locator(`${panel} .sidebar-settings-button`);
const signOut = page.locator(`${panel} .logout-button`);
assert.equal(await signOut.count(), 0, "logout should appear only inside the account menu");
assert.equal((await settings.textContent()).trim(), "", "settings should use only an icon");
await account.click();
await page.getByRole("menu", { name: "账号菜单", exact: true }).waitFor();
await page.waitForFunction(() => document.activeElement?.getAttribute("role") === "menuitem");
await page.keyboard.press("Escape");
await signOut.waitFor({ state: "detached" });
assert.equal(await menu.getAttribute("aria-expanded"), "true", "closing the account menu also closed navigation");
assert.equal(await account.evaluate(element => document.activeElement === element), true);
await account.press("ArrowDown");
await page.waitForFunction(() => document.activeElement?.getAttribute("role") === "menuitem");
await page.keyboard.press("Tab");
await signOut.waitFor({ state: "detached" });
assert.equal(await settings.evaluate(element => document.activeElement === element), true);
await account.click();
await signOut.waitFor();
await settings.click();
await page.getByRole("dialog", { name: "设置", exact: true }).waitFor();
assert.equal(await signOut.count(), 0, "opening settings left the account menu open");
await page.getByRole("button", { name: "关闭设置", exact: true }).click();
if (await menu.getAttribute("aria-expanded") !== "true") await menu.click();
await account.click();
await signOut.waitFor();
await page.waitForTimeout(350);
assert.equal(await signOut.evaluate(button => {
const box = button.getBoundingClientRect();
return button.contains(document.elementFromPoint(box.x + box.width / 2, box.y + box.height / 2));
}), true, "logout is covered by navigation");
assert.equal(await page.evaluate(() => document.documentElement.scrollWidth > innerWidth), false);
assert.equal(await page.locator(`${panel} .brand-lockup`).evaluate(brand => {
const name = brand.querySelector(".brand-name");
const box = name.getBoundingClientRect();
return brand.contains(document.elementFromPoint(box.x + box.width / 2, box.y + box.height / 2));
}), true, "topbar covers the sidebar brand");
await page.screenshot({ path: join(tmpdir(), `pixhelf-auth-sidebar${screenshotSuffix}-${width}.png`) });
if (width === 1440) {
const rejected = await context.request.post(`${origin}/api/auth/logout`, { headers: { Origin: origin, "X-Pixhelf-Origin": origin, "X-CSRF-Token": "forged" } });
assert.equal(rejected.status(), 403);
assert.equal((await context.request.get(original)).status(), 200);
await page.route("**/api/auth/logout", route => route.fulfill({ status: 200, contentType: "text/html", body: "upstream error" }), { times: 1 });
await signOut.click();
await page.locator(`${panel} .sidebar-account-error`).filter({ hasText: "未能确认退出结果" }).waitFor();
assert.equal((await context.request.get(original)).status(), 200, "a failed logout incorrectly invalidated the session");
}
await signOut.click();
await page.waitForURL(`${origin}/login`);
await second.waitForURL(`${origin}/login`);
assert.equal((await context.cookies()).some(cookie => cookie.name === "pixhelf-session"), false, "browser retained the deleted session cookie");
assert.equal((await context.request.get(original, { headers: { "If-None-Match": "*", Range: "bytes=0-7" } })).status(), 401);
await page.goBack();
await page.getByRole("heading", { name: "登录 Pixhelf", exact: true }).waitFor();
assert.equal(await page.locator(".image-card").count(), 0);
if (width === 320) {
await page.locator("#login-username").fill("admin");
await page.locator("#login-password").fill(password);
await page.locator(".login-submit").click();
await page.waitForURL(`${origin}/`);
await page.locator(".image-card").first().waitFor();
const activeSession = await (await context.request.get(`${origin}/api/auth/session`)).json();
const revoked = await context.request.post(`${origin}/api/auth/logout`, { headers: { Origin: origin, "X-Pixhelf-Origin": origin, "X-CSRF-Token": activeSession.csrfToken } });
assert.equal(revoked.status(), 204);
// Revoke on the server without notifying the page, as on expiry. The image request must detect it.
await page.locator(".image-card").first().click();
await page.waitForURL(`${origin}/login?expired=1`);
await page.getByRole("status").filter({ hasText: "登录已过期" }).waitFor();
}
assert.deepEqual(errors, []);
await context.close();
console.log(JSON.stringify({ protocol, width, login: true, cookieMatchesProtocol: true, protectedMedia: true, crossTabLogout: true, navigationAccessible: true }));
}
let currentPassword = password;
let currentUsername = "admin";
for (const width of [1440, 320]) {
const stopped = new Promise(resolve => backend.once("exit", resolve));
backend.kill("SIGTERM");
await stopped;
await startBackend();
const context = await browser.newContext({ ...contextOptions, ignoreHTTPSErrors: true, viewport: { width, height: 844 }, hasTouch: width <= 720 });
const page = await context.newPage();
const errors = [];
page.on("pageerror", error => errors.push(error.message));
await page.goto(origin);
await page.locator("#login-username").fill(currentUsername);
await page.locator("#login-password").fill(currentPassword);
await page.locator(".login-submit").click();
await page.waitForURL(`${origin}/`);
const second = await context.newPage();
await second.goto(origin);
const other = await browser.newContext({ ...contextOptions, ignoreHTTPSErrors: true });
const otherLogin = await other.request.post(`${origin}/api/auth/login`, { headers: { Origin: origin, "X-Pixhelf-Origin": origin }, data: { username: currentUsername, password: currentPassword } });
assert.equal(otherLogin.status(), 200);
await page.bringToFront();
await openSettings(page);
const nextPassword = width === 1440 ? `${password}-${width}` : "";
const nextUsername = `gallery-${width}`;
await page.locator('[name="username"]').fill(nextUsername);
await page.locator('[name="currentPassword"]').fill(currentPassword);
await page.locator('[name="newPassword"]').fill(nextPassword);
let submissions = 0;
page.on("request", request => { if (new URL(request.url()).pathname === "/api/auth/password") submissions++; });
await page.evaluate(() => { const form = document.querySelector(".password-settings-form"); form.requestSubmit(); form.requestSubmit(); });
await page.waitForURL(`${origin}/login?accountChanged=1`);
await page.getByRole("status").filter({ hasText: "账号信息已更新" }).waitFor();
await second.waitForURL(url => url.pathname === "/login");
assert.equal(submissions, 1, "double submission changed the password more than once");
assert.equal((await context.cookies()).some(cookie => cookie.name === "pixhelf-session"), false);
assert.equal((await other.request.get(`${origin}/api/gallery`)).status(), 401, "password change left another device logged in");
const oldLogin = await other.request.post(`${origin}/api/auth/login`, { headers: { Origin: origin, "X-Pixhelf-Origin": origin }, data: { username: currentUsername, password: currentPassword } });
assert.equal(oldLogin.status(), 401);
currentPassword = nextPassword || currentPassword;
currentUsername = nextUsername;
assert.deepEqual(errors, []);
await other.close();
await context.close();
console.log(JSON.stringify({ protocol, width, accountChanged: true, passwordKeptWhenBlank: width === 320, sessionsRevoked: true, doubleSubmissionPrevented: true }));
}
const stopped = new Promise(resolve => backend.once("exit", resolve));
backend.kill("SIGTERM");
await stopped;
await startBackend();
const persisted = await browser.newContext({ ...contextOptions, ignoreHTTPSErrors: true });
const result = await persisted.request.post(`${origin}/api/auth/login`, { headers: { Origin: origin, "X-Pixhelf-Origin": origin }, data: { username: currentUsername, password: currentPassword } });
assert.equal(result.status(), 200, "changed password did not survive restart");
const adminPage = await persisted.newPage();
await adminPage.setViewportSize({ width: 1440, height: 844 });
await adminPage.goto(origin);
await openSettings(adminPage);
await adminPage.route("**/api/auth/guest", route => route.fulfill({ status: 503, json: { error: "unavailable" } }), { times: 1 });
await adminPage.getByRole("button", { name: "访客模式", exact: true }).click();
await adminPage.getByRole("alert").filter({ hasText: "无法读取访客设置" }).waitFor();
await adminPage.getByRole("button", { name: "重试", exact: true }).click();
const guestSwitch = adminPage.getByRole("switch", { name: "免登录浏览", exact: true });
await guestSwitch.waitFor();
assert.equal(await guestSwitch.getAttribute("aria-checked"), "false");
let captureSave;
const receivedSave = new Promise(resolve => { captureSave = resolve; });
await adminPage.route("**/api/auth/guest", captureSave, { times: 1 });
await guestSwitch.click();
const delayedSave = await receivedSave;
assert(await guestSwitch.isDisabled());
await adminPage.keyboard.press("Escape");
await adminPage.mouse.click(2, 2);
assert(await adminPage.locator(".settings-dialog").isVisible(), "saving guest settings must keep the dialog open");
await delayedSave.fulfill({ status: 500, json: { error: "无法保存访客设置,请稍后重试" } });
await adminPage.getByRole("alert").filter({ hasText: "无法保存访客设置" }).waitFor();
assert.equal(await guestSwitch.getAttribute("aria-checked"), "false", "a failed save must not enable guest mode");
assert.equal((await (await persisted.request.get(`${origin}/api/auth/guest`)).json()).enabled, false);
await guestSwitch.click();
await adminPage.waitForFunction(() => document.querySelector('[role="switch"]')?.getAttribute("aria-checked") === "true");
assert.deepEqual(JSON.parse(await readFile(join(directory, "cache/auth/guest.json"), "utf8")), { enabled: true });
for (const width of [1440, 390, 320]) {
await adminPage.setViewportSize({ width, height: 844 });
const sizes = [];
for (const label of ["账号安全", "外部存储", "关于", "访客模式"]) {
await adminPage.locator(".settings-dialog").getByRole("button", { name: label, exact: true }).click();
sizes.push(await adminPage.locator(".settings-dialog").boundingBox());
}
for (const size of sizes) {
assert(Math.abs(size.width - sizes[0].width) < 1 && Math.abs(size.height - sizes[0].height) < 1);
assert(size.x >= 10 && size.x + size.width <= width - 10);
}
await guestSwitch.waitFor();
assert(await adminPage.locator(".settings-nav").evaluate(element => element.scrollWidth <= element.clientWidth + 1));
await adminPage.screenshot({ path: join(tmpdir(), `pixhelf-guest-settings-${protocol}-${width}.png`), animations: "disabled" });
}
const visitors = [];
const guestErrors = [];
const navigation = async page => {
const toggle = page.locator(".gallery-sidebar-toggle");
if (await toggle.getAttribute("aria-expanded") !== "true") await toggle.click();
return page.locator(page.viewportSize().width <= 720 ? ".mobile-sidebar" : ".desktop-sidebar");
};
for (const width of [1440, 390, 320]) {
const visitor = await browser.newContext({ ...contextOptions, ignoreHTTPSErrors: true, viewport: { width, height: 844 }, hasTouch: width <= 720 });
const page = await visitor.newPage();
page.on("pageerror", error => guestErrors.push(error.message));
await page.goto(origin);
await page.locator(".image-card").nth(24).waitFor();
assert.equal(new URL(page.url()).pathname, "/", "guests must reach the gallery without a login step");
assert.equal((await visitor.cookies()).some(cookie => cookie.name === "pixhelf-session"), false);
assert.deepEqual(await (await visitor.request.get(`${origin}/api/auth/session`)).json(), { enabled: true, authenticated: false, guest: true });
assert.equal((await visitor.request.get(`${origin}/api/storage/config`)).status(), 401);
const nav = await navigation(page);
assert(await nav.getByText("访客", { exact: true }).isVisible());
assert.equal(await nav.getByRole("button", { name: "设置", exact: true }).count(), 0);
assert.equal(await nav.getByRole("button", { name: "外部存储", exact: true }).count(), 0);
assert(await nav.getByRole("link", { name: "管理员登录", exact: true }).isVisible());
await page.screenshot({ path: join(tmpdir(), `pixhelf-guest-gallery-${protocol}-${width}.png`), animations: "disabled" });
await nav.getByRole("button", { name: "相似图片", exact: true }).click();
await page.getByLabel("上传查询图片").setInputFiles({ name: "guest-query.png", mimeType: "image/png", buffer: png(0) });
await page.locator(".similar-search-page .image-card").first().waitFor();
await page.locator(".similar-search-page .photo-card-open").first().click();
await page.locator(".image-viewer").waitFor();
await page.keyboard.press("Escape");
await page.locator(".image-viewer").waitFor({ state: "detached" });
await (await navigation(page)).getByRole("link", { name: "管理员登录", exact: true }).click();
await page.getByRole("heading", { name: "登录 Pixhelf", exact: true }).waitFor();
assert.equal(await page.locator(".auth-story-caption").textContent(), "一隅光影,满架时光。");
await page.screenshot({ path: join(tmpdir(), `pixhelf-guest-login-${protocol}-${width}.png`), animations: "disabled" });
await page.getByRole("link", { name: "以访客身份浏览", exact: true }).click();
await page.locator(".image-card").first().waitFor();
assert(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth));
visitors.push({ context: visitor, page });
}
const guestRestart = new Promise(resolve => backend.once("exit", resolve));
backend.kill("SIGTERM");
await guestRestart;
await startBackend();
for (const visitor of visitors) {
await visitor.page.reload();
await visitor.page.locator(".image-card").first().waitFor();
}
await adminPage.goto(`${origin}/login`);
await adminPage.getByLabel("用户名", { exact: true }).fill(currentUsername);
await adminPage.getByLabel("密码", { exact: true }).fill(currentPassword);
await adminPage.getByRole("button", { name: "登录", exact: true }).click();
await adminPage.waitForURL(`${origin}/`);
await openSettings(adminPage);
await adminPage.getByRole("button", { name: "访客模式", exact: true }).click();
await adminPage.waitForFunction(() => document.querySelector('[role="switch"]')?.getAttribute("aria-checked") === "true");
await guestSwitch.click();
await adminPage.waitForFunction(() => document.querySelector('[role="switch"]')?.getAttribute("aria-checked") === "false");
for (const visitor of visitors) {
assert.equal((await visitor.context.request.get(`${origin}/api/gallery`)).status(), 401);
await visitor.page.bringToFront();
await visitor.page.evaluate(() => window.dispatchEvent(new Event("focus")));
await visitor.page.waitForURL(`${origin}/login`);
assert.equal(await visitor.page.getByRole("link", { name: "以访客身份浏览", exact: true }).count(), 0);
await visitor.context.close();
}
assert.deepEqual(guestErrors, []);
assert.deepEqual(JSON.parse(await readFile(join(directory, "cache/auth/guest.json"), "utf8")), { enabled: false });
await persisted.close();
console.log(JSON.stringify({ protocol, guestWithoutPassword: true, guestSearch: true, adminOnlySettings: true, guestSettingsRetry: true, guestModePersists: true, guestRevocation: true }));
console.log(`${protocol.toUpperCase()} setup, authentication and guest browser checks passed`);
} catch (error) {
console.error(backendLog);
throw error;
} finally {
await browser?.close();
for (const socket of tunnels) socket.destroy();
proxy?.closeAllConnections();
if (proxy?.listening) await new Promise(resolve => proxy.close(resolve));
if (backend && backend.exitCode === null) {
const exited = new Promise(resolve => backend.once("exit", resolve));
backend.kill("SIGTERM");
await exited;
}
await rm(directory, { recursive: true, force: true });
}
}
await checkAuthentication("https");
await checkAuthentication("http");