CI / backend (push) Canceled after 0s
用户要求:内测的固定验证码 123456 等于"知道手机号就能进任何账号",先关掉; 登录改用账号密码,并多一种"邮箱当账号名"的方式;发信邮箱要能在后台改。 短信那条路(默认关): - `SMS_LOGIN_ENABLED=false`,`/auth/sms/send` 与 `/auth/login` 统一回 2017,不是静默失败; - 生产校验改成"开着短信登录就必须配真通道",不开时 provider 是 dev 也不拦; - 新增 `GET /auth/methods`,登录页据此渲染,关着的路不出现(少一次"点进去才发现不能用")。 邮箱登录: - `app_user.email` + `uq_app_user_email`(`lower(email)` 部分索引),登录与绑定都归一成小写; - `POST /auth/login/password` 收 `phone` 或 `email`(二选一),失败统一 2013、连错 10 次锁 15 分钟; - 绑定在 `PATCH /auth/me`:格式错 2018、被占用 2019、留空串表示解绑。 系统设置(跨租户,只有平台管理员能改): - 新表 `system_setting`(不挂租户、无 RLS,写权限由 `deps.platform_admin` 把关); - `GET/PUT /settings/smtp`、`POST /settings/smtp/test`,授权码只写不读,留空表示沿用旧值; - `services/mail.py` 只用标准库 smtplib:465 走 SSL、587 走 STARTTLS,认证/拒收/网络失败都归 2020 但消息不同; - Web 新增「系统设置」页(状态卡 + 测试发信),侧栏只对平台管理员显示入口;账号页补「邮箱」。 验证:pytest 130 passed、ruff check/format 全绿、tsc 通过、vite build 通过; `web/e2e/login-email-smtp.mjs` 15 项全绿(含用 126 授权码真发一封测试邮件)。 顺带记下的两个坑:`platform_admin_phones` 是 list,pydantic-settings 会按 JSON 解析, 要用 `NoDecode` 交给 before-validator 拆逗号;迁移号写成 0009 会撞既有的 0009,一并改成 0010。
222 lines
8.7 KiB
JavaScript
222 lines
8.7 KiB
JavaScript
/**
|
|
* 登录方式与系统设置的验收:短信关着、邮箱能登录、SMTP 后台可配。
|
|
*
|
|
* 三个都要真跑,因为这三件事全是"接口回 200 但人用不了"的高危区:
|
|
* - 短信关着,登录页就不能出现「验证码」这一档(不然用户点进去只会看到 2017);
|
|
* - 邮箱 + 密码要能真的登进来,不是只把邮箱存进库;
|
|
* - SMTP 存进库之后要能立刻发信,别等到真要用的时候才发现授权码是错的。
|
|
*
|
|
* 授权码不进仓库:发信那一步只在给了 SMTP_PASS 环境变量时才跑。
|
|
*/
|
|
import { chromium } from "playwright-core";
|
|
import { mkdirSync, readFileSync } from "node:fs";
|
|
|
|
const WEB = process.env.WEB ?? "http://127.0.0.1:5180";
|
|
const API = process.env.API ?? "http://127.0.0.1:8100/api/v1";
|
|
const PHONE = process.env.PHONE ?? "13900000001";
|
|
const PWD_FILE = new URL("../../.local/e2e-account-password.txt", import.meta.url);
|
|
const OUT = "e2e/artifacts";
|
|
|
|
const SMTP_HOST = process.env.SMTP_HOST ?? "smtp.126.com";
|
|
const SMTP_USER = process.env.SMTP_USER ?? "";
|
|
const SMTP_PASS = process.env.SMTP_PASS ?? "";
|
|
|
|
let pass = 0;
|
|
let fail = 0;
|
|
function check(name, ok, extra = "") {
|
|
if (ok) {
|
|
pass += 1;
|
|
console.log(`PASS ${name}`);
|
|
} else {
|
|
fail += 1;
|
|
console.log(`FAIL ${name} ${extra}`);
|
|
}
|
|
}
|
|
|
|
function savedPassword() {
|
|
try {
|
|
return readFileSync(PWD_FILE, "utf8").trim() || null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
async function post(path, body, token) {
|
|
const r = await fetch(`${API}${path}`, {
|
|
method: "POST",
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
|
},
|
|
body: JSON.stringify(body ?? {}),
|
|
});
|
|
return r.json();
|
|
}
|
|
|
|
async function get(path, token) {
|
|
const r = await fetch(`${API}${path}`, {
|
|
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
|
});
|
|
return r.json();
|
|
}
|
|
|
|
async function patch(path, body, token) {
|
|
const r = await fetch(`${API}${path}`, {
|
|
method: "PATCH",
|
|
headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` },
|
|
body: JSON.stringify(body),
|
|
});
|
|
return r.json();
|
|
}
|
|
|
|
async function main() {
|
|
mkdirSync(OUT, { recursive: true });
|
|
const browser = await chromium.launch({
|
|
executablePath:
|
|
process.env.CHROME ??
|
|
`${process.env.LOCALAPPDATA}\\ms-playwright\\chromium-1223\\chrome-win64\\chrome.exe`,
|
|
});
|
|
const page = await browser.newPage({ viewport: { width: 1440, height: 960 } });
|
|
const errors = [];
|
|
page.on("console", (m) => m.type() === "error" && errors.push(m.text()));
|
|
page.on("pageerror", (e) => errors.push(String(e)));
|
|
|
|
const password = savedPassword();
|
|
|
|
// ① 服务端说清楚现在开着哪几条路
|
|
const methods = await get("/auth/methods");
|
|
check(
|
|
"① /auth/methods 说短信关着、密码与邮箱密码开着",
|
|
methods.code === 0 &&
|
|
methods.data.password === true &&
|
|
methods.data.email_password === true &&
|
|
methods.data.sms_code === false,
|
|
JSON.stringify(methods.data),
|
|
);
|
|
|
|
// ② 关着的那条路真的挡住了
|
|
const sms = await post("/auth/sms/send", { phone: PHONE });
|
|
check("② 发验证码的接口回 2017(不是静默成功)", sms.code === 2017, JSON.stringify(sms));
|
|
|
|
// ③ 登录页不该再出现「验证码」这一档
|
|
await page.goto(`${WEB}/login`);
|
|
await page.waitForSelector("#account");
|
|
const codeTab = await page.getByRole("button", { name: "验证码", exact: true }).count();
|
|
const emailTab = await page.getByRole("tab", { name: "邮箱" }).count();
|
|
const pwdInput = await page.locator("#password").count();
|
|
check("③ 登录页没有验证码那一档、有邮箱档与密码框", codeTab === 0 && emailTab === 1 && pwdInput === 1);
|
|
|
|
// ④ 手机号 + 密码,从界面登录进去
|
|
let loggedIn = false;
|
|
if (password) {
|
|
await page.getByLabel("手机号").fill(PHONE);
|
|
await page.locator("#password").fill(password);
|
|
await page.getByRole("button", { name: "登录", exact: true }).click();
|
|
loggedIn = await page
|
|
.waitForURL(`${WEB}/`, { timeout: 8000 })
|
|
.then(() => true)
|
|
.catch(() => false);
|
|
}
|
|
check("④ 手机号 + 密码能从登录页进首页", loggedIn, "(缺 .local/e2e-account-password.txt 时跳过)");
|
|
|
|
const tokens = loggedIn
|
|
? await page.evaluate(() => localStorage.getItem("dadao.access"))
|
|
: (await post("/auth/login/password", { phone: PHONE, password })).data?.tokens?.access_token;
|
|
|
|
// ⑤ 绑定邮箱,然后**用邮箱登录**
|
|
const email = `dadao-e2e-${Date.now()}@example.com`;
|
|
const bind = await patch("/auth/me", { email }, tokens);
|
|
check("⑤ 账号页能绑邮箱", bind.code === 0 && bind.data.user.email === email, JSON.stringify(bind));
|
|
|
|
const byEmail = await post("/auth/login/password", { email: email.toUpperCase(), password });
|
|
const emailLogin = await post("/auth/login/password", { email, password });
|
|
check(
|
|
"⑥ 邮箱 + 密码能登录(大小写不敏感)",
|
|
emailLogin.code === 0 && emailLogin.data?.tokens?.access_token,
|
|
JSON.stringify(emailLogin),
|
|
);
|
|
check("⑥b 全大写邮箱也能登录", byEmail.code === 0, JSON.stringify(byEmail));
|
|
|
|
// ⑦ 解绑之后这条路就该关掉,而不是留个后门
|
|
const unbind = await patch("/auth/me", { email: "" }, tokens);
|
|
const afterUnbind = await post("/auth/login/password", { email, password });
|
|
check(
|
|
"⑦ 解绑邮箱后邮箱登录回 2013",
|
|
unbind.code === 0 && unbind.data.user.email === null && afterUnbind.code === 2013,
|
|
JSON.stringify(afterUnbind),
|
|
);
|
|
|
|
// ⑧ 平台管理员才看得到「系统设置」
|
|
const me = await get("/auth/me", tokens);
|
|
check("⑧ 当前账号被标成平台管理员", me.data?.user?.platform_admin === true, JSON.stringify(me.data?.user));
|
|
await page.goto(`${WEB}/account`);
|
|
await page.waitForSelector('input[aria-label="昵称"]');
|
|
// 侧栏按 /auth/me 的 platform_admin 决定要不要渲染这一条,等它落下来再数
|
|
const settingsLink = await page
|
|
.waitForFunction(() => document.querySelectorAll('aside a[href="/settings"]').length, {
|
|
timeout: 5000,
|
|
})
|
|
.then((handle) => handle.jsonValue())
|
|
.catch(() => 0);
|
|
check("⑧b 侧栏里有「系统设置」入口", settingsLink === 1, `count=${settingsLink}`);
|
|
await page.screenshot({ path: `${OUT}/settings-entry.png` });
|
|
|
|
// ⑨ 系统设置页能读、能存;授权码只写不读
|
|
await page.goto(`${WEB}/settings`);
|
|
await page.waitForSelector('input[aria-label="服务器地址"]');
|
|
const before = await get("/settings/smtp", tokens);
|
|
check("⑨ 系统设置页读得到当前配置", before.code === 0 && before.data.password_set !== undefined);
|
|
|
|
if (SMTP_PASS) {
|
|
await page.getByLabel("服务器地址").fill(SMTP_HOST);
|
|
await page.getByLabel("端口").fill("465");
|
|
await page.locator('input[aria-label="账号"]').fill(SMTP_USER);
|
|
await page.getByLabel("授权码").fill(SMTP_PASS);
|
|
await page.getByRole("button", { name: "保存", exact: true }).click();
|
|
await page.waitForTimeout(1200);
|
|
const saved = await get("/settings/smtp", tokens);
|
|
check(
|
|
"⑩ 授权码存进库了,但接口不回显",
|
|
saved.data?.password_set === true &&
|
|
saved.data?.configured === true &&
|
|
!JSON.stringify(saved.data).includes(SMTP_PASS),
|
|
JSON.stringify(saved.data),
|
|
);
|
|
|
|
// ⑪ 真发一封,确认这条通道是通的(不是"存了个假配置")
|
|
const sent = await post("/settings/smtp/test", { to: SMTP_USER }, tokens);
|
|
check("⑪ 测试邮件发送成功", sent.code === 0, JSON.stringify(sent));
|
|
if (sent.code !== 0) {
|
|
console.log(` 发信失败原文:${sent.message}`);
|
|
}
|
|
await page.screenshot({ path: `${OUT}/settings-smtp.png` });
|
|
} else {
|
|
console.log("SKIP ⑩⑪ 没给 SMTP_PASS,跳过保存与发信");
|
|
}
|
|
|
|
// ⑫ 账号页上的邮箱框要能回填刚绑的值(说明前端读的是接口,不是本地缓存)
|
|
const rebind = await patch("/auth/me", { email }, tokens);
|
|
await page.goto(`${WEB}/account`);
|
|
await page.waitForSelector('input[aria-label="邮箱"]');
|
|
const emailInput = await page.waitForFunction(
|
|
() => {
|
|
const el = document.querySelector('input[aria-label="邮箱"]');
|
|
return el && el.value.includes("@") ? el.value : null;
|
|
},
|
|
{ timeout: 5000 },
|
|
);
|
|
const shownEmail = await emailInput.jsonValue();
|
|
check("⑫ 账号页回填刚绑的邮箱", rebind.code === 0 && shownEmail === email, shownEmail);
|
|
|
|
check("⑬ 控制台零报错", errors.length === 0, errors.slice(0, 2).join(" | "));
|
|
|
|
await browser.close();
|
|
console.log(`\n${fail === 0 ? "ALL PASS" : "SOME FAILED"} — pass ${pass}, fail ${fail}`);
|
|
process.exit(fail === 0 ? 0 : 1);
|
|
}
|
|
|
|
main().catch((err) => {
|
|
console.error("脚本异常", err);
|
|
process.exit(1);
|
|
});
|