feat: ordre naturel des sections Configurations et avatar utilisateur #113
CI / lint (push) Successful in 1m59s
CI / security (push) Successful in 1m28s
CI / test (push) Successful in 4m14s
CI / build (push) Successful in 1m20s
CI / e2e (push) Successful in 13m43s

This commit is contained in:
2026-09-23 22:18:29 -04:00
parent a726ad8511
commit 33fe1a3439
24 changed files with 1078 additions and 296 deletions
@@ -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
View File
@@ -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