feat: ordre naturel des sections Configurations et avatar utilisateur #113
This commit is contained in:
@@ -0,0 +1,174 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* ObsiGate — Settings section order + profile avatar (#113).
|
||||
*
|
||||
* Static checks (no jsdom — runs in the "Frontend unit tests" CI step):
|
||||
* - Order: the #config-nav TOC and the page sections must list the same
|
||||
* anchors in the same order, with #cfg-profile first and #cfg-about last
|
||||
* (regression guard for the reorganization).
|
||||
* - Avatar UI: the Profil section must carry the picker (preview, file
|
||||
* input, choose/remove buttons), config.js the resize/PATCH flow, auth.js
|
||||
* the sidebar image rendering, style.css the preview/img rules, and the
|
||||
* i18n keys must exist in FR and EN.
|
||||
* - Backend: backend/auth/router.py must validate the avatar data-URL.
|
||||
*
|
||||
* Usage: node tests/frontend/settings-order-avatar.test.mjs
|
||||
*/
|
||||
|
||||
import { strict as assert } from "node:assert";
|
||||
import { readFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const ROOT = path.join(__dirname, "..", "..");
|
||||
|
||||
const indexHtml = readFileSync(path.join(ROOT, "frontend", "index.html"), "utf8");
|
||||
const configJs = readFileSync(path.join(ROOT, "frontend", "js", "config.js"), "utf8");
|
||||
const authJs = readFileSync(path.join(ROOT, "frontend", "js", "auth.js"), "utf8");
|
||||
const css = readFileSync(path.join(ROOT, "frontend", "style.css"), "utf8");
|
||||
const routerPy = readFileSync(path.join(ROOT, "backend", "auth", "router.py"), "utf8");
|
||||
const fr = JSON.parse(readFileSync(path.join(ROOT, "frontend", "locales", "fr.json"), "utf8"));
|
||||
const en = JSON.parse(readFileSync(path.join(ROOT, "frontend", "locales", "en.json"), "utf8"));
|
||||
|
||||
function test(label, fn) {
|
||||
try {
|
||||
fn();
|
||||
console.log(" ✓ " + label);
|
||||
} catch (err) {
|
||||
console.error(" ✗ " + label + "\n " + err.message);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
|
||||
function tocHrefs() {
|
||||
const nav = indexHtml.match(/<nav class="help-nav" id="config-nav">([\s\S]*?)<\/nav>/);
|
||||
assert.ok(nav, "#config-nav not found");
|
||||
return [...nav[1].matchAll(/href="#([^"]+)"/g)].map((m) => m[1]);
|
||||
}
|
||||
|
||||
// ── #113a: order — TOC and page in sync ─────────────────────────────────────
|
||||
test("index.html — #cfg-profile is the first TOC entry, #cfg-about the last", () => {
|
||||
const hrefs = tocHrefs();
|
||||
assert.ok(hrefs.length >= 10, "TOC must list the settings sections");
|
||||
assert.equal(hrefs[0], "cfg-profile", "Profile must come first");
|
||||
assert.equal(hrefs[hrefs.length - 1], "cfg-about", "About must come last");
|
||||
});
|
||||
|
||||
test("index.html — page sections appear in the exact TOC order", () => {
|
||||
const hrefs = tocHrefs();
|
||||
const start = indexHtml.indexOf('id="config-scroll"');
|
||||
assert.ok(start > 0, "#config-scroll not found");
|
||||
const region = indexHtml.slice(start);
|
||||
let prev = -1;
|
||||
for (const id of hrefs) {
|
||||
const pos = region.indexOf(`id="${id}"`);
|
||||
assert.ok(pos > 0, `section #${id} missing from the page`);
|
||||
assert.ok(pos > prev, `section #${id} is out of TOC order`);
|
||||
prev = pos;
|
||||
}
|
||||
});
|
||||
|
||||
test("index.html — TOC has no dead anchors and page has no orphan cfg sections", () => {
|
||||
const hrefs = tocHrefs();
|
||||
const missing = hrefs.filter((id) => !indexHtml.includes(`id="${id}"`));
|
||||
assert.deepEqual(missing, [], `dead TOC anchors: ${missing.join(", ")}`);
|
||||
// Every cfg-* id declared on a <section> must be reachable from the TOC
|
||||
// (form-control ids like #cfg-debounce are not sections).
|
||||
const start = indexHtml.indexOf('id="config-scroll"');
|
||||
const region = indexHtml.slice(start, indexHtml.indexOf("Graph View Modal"));
|
||||
const pageIds = [...region.matchAll(/<section[^>]*id="(cfg-[^"]+)"/g)].map((m) => m[1]);
|
||||
const orphans = [...new Set(pageIds)].filter((id) => !hrefs.includes(id));
|
||||
assert.deepEqual(orphans, [], `page sections missing from the TOC: ${orphans.join(", ")}`);
|
||||
});
|
||||
|
||||
// ── #113b: avatar UI in the Profil section ──────────────────────────────────
|
||||
test("index.html — Profil section carries the avatar picker", () => {
|
||||
const section = indexHtml.match(
|
||||
/<section[^>]*id="cfg-profile"[\s\S]*?<\/section>/,
|
||||
);
|
||||
assert.ok(section, "#cfg-profile section not found");
|
||||
const html = section[0];
|
||||
for (const id of [
|
||||
"profile-avatar-field",
|
||||
"profile-avatar-preview",
|
||||
"profile-avatar-img",
|
||||
"profile-avatar-initials",
|
||||
"profile-avatar-input",
|
||||
"profile-avatar-choose",
|
||||
"profile-avatar-remove",
|
||||
"profile-avatar-error",
|
||||
]) {
|
||||
assert.match(html, new RegExp(`id="${id}"`), `#${id} missing from #cfg-profile`);
|
||||
}
|
||||
assert.match(html, /accept="image\/png,image\/jpeg,image\/webp"/,
|
||||
"the file input must restrict accepted image types");
|
||||
});
|
||||
|
||||
test("config.js — avatar flow: type/size guards, resize, PATCH, sidebar refresh", () => {
|
||||
assert.match(configJs, /_AVATAR_TYPES/, "accepted MIME types must be declared");
|
||||
assert.match(configJs, /image\/png[\s\S]{0,80}image\/jpeg[\s\S]{0,80}image\/webp/,
|
||||
"PNG/JPEG/WebP must be the accepted types");
|
||||
assert.match(configJs, /_AVATAR_MAX_FILE_BYTES/, "raw file size guard missing");
|
||||
assert.match(configJs, /_resizeAvatarFile/, "client-side resize helper missing");
|
||||
assert.match(configJs, /toDataURL\(["']image\/jpeg["']/, "resize must export a JPEG data-URL");
|
||||
assert.match(configJs, /method:\s*["']PATCH["'][\s\S]{0,200}avatar/,
|
||||
"avatar must be persisted via PATCH /api/auth/me");
|
||||
assert.match(configJs, /avatar:\s*value|JSON\.stringify\(\{\s*avatar/,
|
||||
"the PATCH body must carry the avatar field");
|
||||
assert.match(configJs, /renderUserSection\(\)/,
|
||||
"the sidebar must be re-rendered after an avatar change");
|
||||
assert.match(configJs, /config\.avatar_invalid_type/, "invalid-type error missing");
|
||||
assert.match(configJs, /config\.avatar_too_large/, "too-large error missing");
|
||||
});
|
||||
|
||||
test("auth.js — sidebar circle renders the avatar image, initials as fallback", () => {
|
||||
assert.match(authJs, /user\.avatar/, "renderUserSection must read user.avatar");
|
||||
assert.match(authJs, /sidebar-user-avatar-img/, "the <img> must carry its CSS class");
|
||||
assert.match(authJs, /userInitials\(name\)/, "initials fallback removed");
|
||||
assert.match(authJs, /updateCachedUser/, "cached-user merge helper missing");
|
||||
assert.match(authJs, /isAuthEnabled\(/, "public auth-status helper missing");
|
||||
});
|
||||
|
||||
test("style.css — preview circle and sidebar image rules exist", () => {
|
||||
assert.match(css, /\.profile-avatar-preview\s*\{/, "no .profile-avatar-preview rule");
|
||||
assert.match(css, /\.profile-avatar-overlay\s*\{/, "no hover overlay rule");
|
||||
assert.match(css, /\.sidebar-user-avatar-img\s*\{/, "no .sidebar-user-avatar-img rule");
|
||||
});
|
||||
|
||||
// ── #113c: i18n + backend ───────────────────────────────────────────────────
|
||||
test("i18n — avatar keys exist in FR and EN", () => {
|
||||
const keys = [
|
||||
"config.avatar_label",
|
||||
"config.avatar_hint",
|
||||
"config.avatar_choose",
|
||||
"config.avatar_remove",
|
||||
"config.avatar_updated",
|
||||
"config.avatar_removed",
|
||||
"config.avatar_too_large",
|
||||
"config.avatar_invalid_type",
|
||||
"config.avatar_upload_failed",
|
||||
];
|
||||
for (const key of keys) {
|
||||
assert.ok(fr[key], `fr.json missing ${key}`);
|
||||
assert.ok(en[key], `en.json missing ${key}`);
|
||||
assert.notEqual(fr[key], key, `fr.json ${key} is untranslated`);
|
||||
assert.notEqual(en[key], key, `en.json ${key} is untranslated`);
|
||||
}
|
||||
});
|
||||
|
||||
test("backend — /api/auth/me validates the avatar data-URL", () => {
|
||||
assert.match(routerPy, /_AVATAR_DATA_URL_RE/, "data-URL regex missing");
|
||||
assert.match(routerPy, /def _validate_avatar/, "validation helper missing");
|
||||
assert.match(routerPy, /image\/\(\?:png\|jpeg\|webp\)/, "only PNG/JPEG/WebP allowed (no SVG)");
|
||||
assert.match(routerPy, /_AVATAR_MAX_CHARS/, "size cap missing");
|
||||
assert.match(routerPy, /"avatar": current_user\.get\("avatar"\)/, "GET /me must return the avatar");
|
||||
assert.match(routerPy, /avatar: str \| None = None/, "UpdateMeRequest must expose avatar");
|
||||
assert.match(routerPy, /updates\["avatar"\] = _validate_avatar/, "PATCH /me must validate before store");
|
||||
});
|
||||
|
||||
if (process.exitCode) {
|
||||
console.error("\nSettings order & avatar tests FAILED");
|
||||
} else {
|
||||
console.log("\nAll settings order & avatar tests passed.");
|
||||
}
|
||||
+95
-1
@@ -308,4 +308,98 @@ class TestAdmin:
|
||||
resp = auth_client.post("/api/auth/logout", headers={
|
||||
"Authorization": f"Bearer {token}",
|
||||
})
|
||||
assert resp.status_code == 200
|
||||
assert resp.status_code == 200
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
# Profile avatar (#113)
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
# 1×1 transparent PNG.
|
||||
TINY_PNG = (
|
||||
"data:image/png;base64,"
|
||||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8"
|
||||
"z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg=="
|
||||
)
|
||||
|
||||
|
||||
class TestAvatar:
|
||||
def _login(self, client):
|
||||
resp = client.post("/api/auth/login", json={
|
||||
"username": "admin", "password": "chab30",
|
||||
})
|
||||
return resp.json()["access_token"]
|
||||
|
||||
def _headers(self, token):
|
||||
return {"Authorization": f"Bearer {token}"}
|
||||
|
||||
def test_me_returns_avatar_field(self, auth_client):
|
||||
"""GET /api/auth/me exposes the avatar key (None by default)."""
|
||||
token = self._login(auth_client)
|
||||
resp = auth_client.get("/api/auth/me", headers=self._headers(token))
|
||||
assert resp.status_code == 200
|
||||
assert "avatar" in resp.json()
|
||||
assert resp.json()["avatar"] is None
|
||||
|
||||
def test_patch_me_sets_avatar(self, auth_client):
|
||||
"""A valid PNG data-URL is persisted and returned by GET /me."""
|
||||
token = self._login(auth_client)
|
||||
resp = auth_client.patch("/api/auth/me", headers=self._headers(token),
|
||||
json={"avatar": TINY_PNG})
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["avatar"] == TINY_PNG
|
||||
me = auth_client.get("/api/auth/me", headers=self._headers(token)).json()
|
||||
assert me["avatar"] == TINY_PNG
|
||||
|
||||
def test_patch_me_avatar_empty_string_clears(self, auth_client):
|
||||
"""Sending an empty string removes the avatar."""
|
||||
token = self._login(auth_client)
|
||||
auth_client.patch("/api/auth/me", headers=self._headers(token),
|
||||
json={"avatar": TINY_PNG})
|
||||
resp = auth_client.patch("/api/auth/me", headers=self._headers(token),
|
||||
json={"avatar": ""})
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["avatar"] is None
|
||||
|
||||
def test_patch_me_avatar_rejects_svg(self, auth_client):
|
||||
"""SVG data-URLs are refused (XSS surface)."""
|
||||
token = self._login(auth_client)
|
||||
svg = "data:image/svg+xml;base64,PHN2Zz48L3N2Zz4="
|
||||
resp = auth_client.patch("/api/auth/me", headers=self._headers(token),
|
||||
json={"avatar": svg})
|
||||
assert resp.status_code == 400
|
||||
|
||||
def test_patch_me_avatar_rejects_non_image_payload(self, auth_client):
|
||||
"""A PNG data-URL header over non-image bytes is refused."""
|
||||
token = self._login(auth_client)
|
||||
fake = "data:image/png;base64,aGVsbG8gd29ybGQ=" # "hello world"
|
||||
resp = auth_client.patch("/api/auth/me", headers=self._headers(token),
|
||||
json={"avatar": fake})
|
||||
assert resp.status_code == 400
|
||||
|
||||
def test_patch_me_avatar_rejects_too_large(self, auth_client):
|
||||
"""Payloads above the size cap are refused before any decode."""
|
||||
token = self._login(auth_client)
|
||||
huge = "data:image/png;base64," + "A" * 500_000
|
||||
resp = auth_client.patch("/api/auth/me", headers=self._headers(token),
|
||||
json={"avatar": huge})
|
||||
assert resp.status_code == 400
|
||||
|
||||
def test_patch_me_avatar_rejects_invalid_base64(self, auth_client):
|
||||
"""Malformed base64 inside a well-formed data-URL is refused."""
|
||||
token = self._login(auth_client)
|
||||
resp = auth_client.patch("/api/auth/me", headers=self._headers(token),
|
||||
json={"avatar": "data:image/png;base64,@@not-base64@@"})
|
||||
assert resp.status_code == 400
|
||||
|
||||
def test_login_user_payload_includes_avatar(self, auth_client):
|
||||
"""The login response user object carries the avatar for the sidebar."""
|
||||
token = self._login(auth_client)
|
||||
auth_client.patch("/api/auth/me", headers=self._headers(token),
|
||||
json={"avatar": TINY_PNG})
|
||||
# Re-login to get a fresh user payload.
|
||||
resp = auth_client.post("/api/auth/login", json={
|
||||
"username": "admin", "password": "chab30",
|
||||
})
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["user"]["avatar"] == TINY_PNG
|
||||
Reference in New Issue
Block a user