feat: garde-fous d'écriture des classeurs Excel #153 (P0)
L'édition d'un .xlsx pouvait détruire une partie du classeur, le concurrencer en silence, ou diffuser une injection de formule. - BUG-085 : inspect_workbook() détecte ce qu'un round-trip openpyxl perd (valeurs calculées en cache, slicers, contrôles, connexions, custom XML, signature, commentaires enrichis, macros) → xlsx_lossy_features exposé en lecture, bandeau FR/EN, et 409 xlsx_lossy_content sans `force` (confirmation explicite puis reprise). Périmètre réel revalidé : graphiques, images et TCD survivent au round-trip. - BUG-086 : écriture atomique (fichier .tmp + os.replace) : un plantage ne peut plus tronquer le classeur, le backup reste intact. - BUG-087 : verrou par fichier autour du read-modify-write (timeout 15 s, 409 conflict) ; endpoint xlsx/save devenu synchrone pour que l'attente s'exécute dans le threadpool. - BUG-088 : une saisie en '=' ou '@' est stockée en texte, sauf opt-in `allow_formula` ou le bouton f(x) de la visionneuse. Le handler ServiceError expose désormais code + details, que api() propage. - BUG-084 : la suppression d'une vault purge enfin l'index inversé (documents fantômes qui continuaient de matcher) et is_stale() devient is_ready(), le nom étant trompeur (la staleness n'existe plus). Tests : 1390 pytest, 10 JSDOM (xlsx-viewer.test.mjs, branché au CI), 3 E2E Playwright, suite E2E complète verte, ruff/mypy 0. 🤖 Generated with Codebuff Co-Authored-By: Codebuff <[email protected]>
This commit is contained in:
@@ -0,0 +1,120 @@
|
||||
/**
|
||||
* E2E tests — Excel viewer, write guards (ROADMAP #153 P0).
|
||||
*
|
||||
* Fixture : `test_vault/sample-xlsx-lossy.xlsx` — a plain 2x2 workbook whose
|
||||
* sheet XML carries a cached formula result (`<f>B1*2</f><v>200</v>`) and whose
|
||||
* package contains `xl/slicers/slicer1.xml`. Both are dropped by an openpyxl
|
||||
* round-trip, so the read response must report
|
||||
* `xlsx_lossy_features: ["cached_values", "slicers"]` (BUG-085 A1).
|
||||
*
|
||||
* Covered :
|
||||
* - the warning banner lists both features ;
|
||||
* - saving a cell on that workbook asks for confirmation (native dialog) and
|
||||
* then succeeds (the client retries with `force: true`) ;
|
||||
* - the f(x) toggle is off by default, so "=B1*3" is stored as text.
|
||||
*
|
||||
* The fixture is restored byte-for-byte in `afterAll` so a local run never
|
||||
* dirties the working copy.
|
||||
*
|
||||
* Run (local) : BASE_URL=http://localhost:2029 npx playwright test tests/e2e/xlsx-viewer.spec.js
|
||||
*/
|
||||
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { readFileSync, writeFileSync, existsSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
const BASE = process.env.BASE_URL || 'http://localhost:2029';
|
||||
const VAULT = 'TestVault';
|
||||
const FIXTURE = 'sample-xlsx-lossy.xlsx';
|
||||
// Playwright runs from the repository root (run-e2e-local.* / CI both do).
|
||||
const FIXTURE_PATH = path.resolve(process.cwd(), 'test_vault', FIXTURE);
|
||||
|
||||
let originalBytes = null;
|
||||
|
||||
async function login(page) {
|
||||
await page.goto(BASE);
|
||||
const loginForm = page.locator('#login-screen');
|
||||
await expect(loginForm).toBeVisible({ timeout: 5000 }).catch(() => {});
|
||||
if (await loginForm.isVisible()) {
|
||||
await page.fill('#login-username', process.env.OBSIGATE_USER || 'admin');
|
||||
await page.fill('#login-password', process.env.OBSIGATE_PASS || 'test123');
|
||||
await page.click('#login-btn');
|
||||
}
|
||||
await page.waitForFunction(() => window.__OBSIGATE_BOOTED === true, { timeout: 20000 });
|
||||
}
|
||||
|
||||
async function openFixture(page) {
|
||||
const treeItem = page.locator(`.tree-item[data-vault="${VAULT}"][data-path="${FIXTURE}"]`);
|
||||
if (!(await treeItem.count())) {
|
||||
await page.locator(`.tree-item.vault-item[data-vault="${VAULT}"]`).first().click();
|
||||
await treeItem.waitFor({ state: 'attached', timeout: 8000 });
|
||||
}
|
||||
await treeItem.dblclick({ timeout: 5000 });
|
||||
await expect(page.locator('#content-area .xlsx-table')).toBeVisible({ timeout: 15000 });
|
||||
}
|
||||
|
||||
test.describe('Excel viewer — garde-fous d\'écriture (#153 P0)', () => {
|
||||
test.beforeAll(() => {
|
||||
if (existsSync(FIXTURE_PATH)) originalBytes = readFileSync(FIXTURE_PATH);
|
||||
});
|
||||
|
||||
test.afterAll(() => {
|
||||
if (originalBytes) writeFileSync(FIXTURE_PATH, originalBytes);
|
||||
});
|
||||
|
||||
test('affiche la bannière listant les éléments non préservés', async ({ page }) => {
|
||||
await login(page);
|
||||
await openFixture(page);
|
||||
|
||||
const banner = page.locator('#content-area .xlsx-warning');
|
||||
await expect(banner).toBeVisible();
|
||||
// 2 features : valeurs calculées + segments (jamais de couleur codée en dur,
|
||||
// les libellés viennent bien des locales).
|
||||
await expect(banner.locator('.xlsx-warning-tag')).toHaveCount(2);
|
||||
await expect(banner).toContainText('segments');
|
||||
await expect(banner).toContainText('valeurs calculées');
|
||||
});
|
||||
|
||||
test('demande confirmation puis enregistre la cellule', async ({ page }) => {
|
||||
await login(page);
|
||||
await openFixture(page);
|
||||
|
||||
let dialogMessage = null;
|
||||
page.on('dialog', async (dialog) => {
|
||||
dialogMessage = dialog.message();
|
||||
await dialog.accept();
|
||||
});
|
||||
|
||||
const cell = page.locator('#content-area td[data-cell="A2"]');
|
||||
await cell.click();
|
||||
await cell.fill('Total confirmé');
|
||||
await cell.press('Enter');
|
||||
|
||||
const save = page.locator('#xlsx-save-btn');
|
||||
await expect(save).toBeEnabled();
|
||||
await save.click();
|
||||
|
||||
await expect.poll(() => dialogMessage, { timeout: 10000 }).toContain('segments');
|
||||
await expect(page.locator('.toast-success')).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// La cellule reste modifiée côté UI (plus de marque « sale »).
|
||||
await expect(page.locator('#content-area td.xlsx-dirty')).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('le toggle f(x) est désactivé par défaut (formule stockée en texte)', async ({ page }) => {
|
||||
await login(page);
|
||||
await openFixture(page);
|
||||
|
||||
const toggle = page.locator('#xlsx-formula-btn');
|
||||
await expect(toggle).toHaveAttribute('aria-pressed', 'false');
|
||||
|
||||
// 409 → confirmation, puis reprise avec force (le toggle reste désactivé).
|
||||
page.on('dialog', (dialog) => dialog.accept());
|
||||
const cell = page.locator('#content-area td[data-cell="B2"]');
|
||||
await cell.click();
|
||||
await cell.fill('=B1*3');
|
||||
await cell.press('Enter');
|
||||
await page.locator('#xlsx-save-btn').click();
|
||||
await expect(page.locator('.toast-success')).toBeVisible({ timeout: 10000 });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,273 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* ObsiGate — JSDOM integration tests for the Excel viewer (ROADMAP #153 P0).
|
||||
*
|
||||
* Loads the real viewer.js module and drives renderXlsxViewer():
|
||||
* - A1 : `xlsx_lossy_features` renders a warning banner; a save on such a
|
||||
* workbook gets 409 `xlsx_lossy_content`, asks for confirmation and
|
||||
* retries with `force: true` (or gives up when refused);
|
||||
* - A4 : the f(x) toggle flips `allow_formula` in the save payload.
|
||||
*
|
||||
* Usage: node tests/frontend/xlsx-viewer.test.mjs
|
||||
*/
|
||||
|
||||
import { strict as assert } from "node:assert";
|
||||
import { JSDOM } from "jsdom";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
import { readFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
const REPO_ROOT = path.resolve(__dirname, "..", "..");
|
||||
|
||||
// ── JSDOM bootstrap ─────────────────────────────────────────────────────────
|
||||
const dom = new JSDOM(
|
||||
`<!DOCTYPE html>
|
||||
<html>
|
||||
<body>
|
||||
<div id="content-area"></div>
|
||||
</body>
|
||||
</html>`,
|
||||
{ url: "http://localhost/", pretendToBeVisual: true }
|
||||
);
|
||||
|
||||
const w = dom.window;
|
||||
globalThis.window = w;
|
||||
globalThis.document = w.document;
|
||||
globalThis.HTMLElement = w.HTMLElement;
|
||||
globalThis.Element = w.Element;
|
||||
globalThis.Node = w.Node;
|
||||
globalThis.Event = w.Event;
|
||||
globalThis.CustomEvent = w.CustomEvent;
|
||||
globalThis.MouseEvent = w.MouseEvent;
|
||||
globalThis.localStorage = w.localStorage;
|
||||
globalThis.sessionStorage = w.sessionStorage;
|
||||
globalThis.requestAnimationFrame = (cb) => setTimeout(() => cb(Date.now()), 0);
|
||||
Object.defineProperty(globalThis, "navigator", {
|
||||
value: w.navigator,
|
||||
configurable: true,
|
||||
writable: true,
|
||||
});
|
||||
|
||||
// ── fetch / confirm doubles ─────────────────────────────────────────────────
|
||||
let calls = [];
|
||||
let nextResponse = () => ({ ok: true, status: 200, body: { status: "ok" } });
|
||||
let confirmAnswer = true;
|
||||
let confirmCalls = 0;
|
||||
// Every confirm() prompt is captured so the tests can assert on its text.
|
||||
let confirmPrompts = [];
|
||||
|
||||
const FR = JSON.parse(
|
||||
readFileSync(path.join(REPO_ROOT, "frontend", "locales", "fr.json"), "utf8")
|
||||
);
|
||||
|
||||
globalThis.fetch = async (url, opts = {}) => {
|
||||
// The i18n bootstrap fetches the locale files: serve the real FR one so the
|
||||
// assertions run on the shipped strings, not on raw keys.
|
||||
if (url.includes("/static/locales/")) {
|
||||
return { ok: true, status: 200, json: async () => FR };
|
||||
}
|
||||
calls.push({ url, body: opts.body ? JSON.parse(opts.body) : null });
|
||||
const res = nextResponse(url, opts);
|
||||
return {
|
||||
ok: res.ok,
|
||||
status: res.status,
|
||||
json: async () => res.body,
|
||||
};
|
||||
};
|
||||
globalThis.confirm = (msg) => { confirmCalls++; confirmPrompts.push(msg); return confirmAnswer; };
|
||||
w.confirm = globalThis.confirm;
|
||||
|
||||
// ── Helpers ─────────────────────────────────────────────────────────────────
|
||||
let testCount = 0;
|
||||
let passCount = 0;
|
||||
|
||||
async function test(name, fn) {
|
||||
testCount++;
|
||||
calls = [];
|
||||
confirmCalls = 0;
|
||||
confirmPrompts = [];
|
||||
confirmAnswer = true;
|
||||
nextResponse = () => ({ ok: true, status: 200, body: { status: "ok" } });
|
||||
try {
|
||||
await fn();
|
||||
console.log(` ✓ ${name}`);
|
||||
passCount++;
|
||||
} catch (e) {
|
||||
console.log(` ✗ ${name}`);
|
||||
console.log(` ${e.message}`);
|
||||
if (e.stack) console.log(` ${e.stack.split("\n").slice(1, 3).join("\n ")}`);
|
||||
}
|
||||
}
|
||||
|
||||
const { renderXlsxViewer } = await import(
|
||||
pathToFileURL(path.join(REPO_ROOT, "frontend", "js", "viewer.js")).href
|
||||
);
|
||||
// Load the FR catalog so t() resolves the real strings.
|
||||
const { initI18n } = await import(
|
||||
pathToFileURL(path.join(REPO_ROOT, "frontend", "js", "i18n.js")).href
|
||||
);
|
||||
await initI18n();
|
||||
|
||||
const sheetHtml = (value) =>
|
||||
'<div class="csv-table-wrapper"><table class="csv-table xlsx-table">' +
|
||||
'<thead><tr><th class="xlsx-corner"></th><th>A</th></tr></thead><tbody>' +
|
||||
`<tr><th class="xlsx-rownum">1</th><td data-cell="A1">${value}</td></tr>` +
|
||||
"</tbody></table></div>";
|
||||
|
||||
function mount({ lossy = [] } = {}) {
|
||||
const area = document.getElementById("content-area");
|
||||
area.innerHTML = "";
|
||||
renderXlsxViewer(area, {
|
||||
vault: "V",
|
||||
path: "data.xlsx",
|
||||
is_xlsx: true,
|
||||
xlsx_sheets: [{ name: "Feuille1", html: sheetHtml("100") }],
|
||||
xlsx_lossy_features: lossy,
|
||||
});
|
||||
return area;
|
||||
}
|
||||
|
||||
/** Mark a cell dirty the way a user edit would. */
|
||||
function editCell(area, ref, text) {
|
||||
const td = area.querySelector(`td[data-cell="${ref}"]`);
|
||||
td.textContent = text;
|
||||
td.dispatchEvent(new w.Event("input", { bubbles: true }));
|
||||
return td;
|
||||
}
|
||||
|
||||
const lossyError = {
|
||||
ok: false,
|
||||
status: 409,
|
||||
body: {
|
||||
detail: "Saving this workbook would drop features…",
|
||||
code: "xlsx_lossy_content",
|
||||
details: { features: ["slicers"] },
|
||||
},
|
||||
};
|
||||
|
||||
console.log("\n── xlsx viewer JSDOM integration tests (#153 P0) ──\n");
|
||||
|
||||
// ── A1 — warning banner ─────────────────────────────────────────────────────
|
||||
|
||||
await test("no banner when the workbook has nothing at risk", () => {
|
||||
const area = mount();
|
||||
assert.equal(area.querySelector(".xlsx-warning"), null);
|
||||
});
|
||||
|
||||
await test("banner lists every lossy feature reported by the backend", () => {
|
||||
const area = mount({ lossy: ["cached_values", "slicers"] });
|
||||
const banner = area.querySelector(".xlsx-warning");
|
||||
assert.ok(banner, "banner absent");
|
||||
const tags = [...banner.querySelectorAll(".xlsx-warning-tag")].map((n) => n.textContent);
|
||||
assert.equal(tags.length, 2);
|
||||
assert.ok(tags.includes(FR["xlsx.feature_cached_values"]), tags.join("|"));
|
||||
assert.ok(tags.includes(FR["xlsx.feature_slicers"]), tags.join("|"));
|
||||
assert.ok(banner.textContent.includes(FR["xlsx.lossy_title"]));
|
||||
assert.ok(banner.textContent.includes(FR["xlsx.lossy_hint"]));
|
||||
});
|
||||
|
||||
await test("formula toggle is present and starts unpressed", () => {
|
||||
const area = mount();
|
||||
const btn = area.querySelector("#xlsx-formula-btn");
|
||||
assert.ok(btn);
|
||||
assert.equal(btn.getAttribute("aria-pressed"), "false");
|
||||
assert.equal(btn.getAttribute("title"), FR["xlsx.formula_toggle_title"]);
|
||||
});
|
||||
|
||||
// ── Save payload ────────────────────────────────────────────────────────────
|
||||
|
||||
await test("save sends one PUT per dirty sheet with the cell map", async () => {
|
||||
const area = mount();
|
||||
editCell(area, "A1", "250");
|
||||
area.querySelector("#xlsx-save-btn").click();
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
assert.equal(calls.length, 1);
|
||||
assert.match(calls[0].url, /\/api\/file\/V\/xlsx\/save\?path=data\.xlsx/);
|
||||
assert.deepEqual(calls[0].body.cells, { A1: "250" });
|
||||
assert.equal(calls[0].body.sheet, "Feuille1");
|
||||
assert.equal(calls[0].body.force, false);
|
||||
assert.equal(calls[0].body.allow_formula, false);
|
||||
});
|
||||
|
||||
await test("save button stays disabled when nothing is dirty", async () => {
|
||||
const area = mount();
|
||||
const btn = area.querySelector("#xlsx-save-btn");
|
||||
assert.equal(btn.disabled, true);
|
||||
btn.click();
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
assert.equal(calls.length, 0);
|
||||
});
|
||||
|
||||
// ── A4 — formula toggle ─────────────────────────────────────────────────────
|
||||
|
||||
await test("f(x) toggle flips allow_formula on the next save", async () => {
|
||||
const area = mount();
|
||||
area.querySelector("#xlsx-formula-btn").click();
|
||||
assert.equal(area.querySelector("#xlsx-formula-btn").getAttribute("aria-pressed"), "true");
|
||||
editCell(area, "A1", "=B1*2");
|
||||
area.querySelector("#xlsx-save-btn").click();
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
assert.equal(calls[0].body.allow_formula, true);
|
||||
});
|
||||
|
||||
// ── A1 — 409 confirmation & force retry ─────────────────────────────────────
|
||||
|
||||
await test("409 xlsx_lossy_content asks once then retries with force", async () => {
|
||||
const area = mount({ lossy: ["slicers"] });
|
||||
editCell(area, "A1", "250");
|
||||
nextResponse = () => (calls.length === 1 ? lossyError : { ok: true, status: 200, body: {} });
|
||||
area.querySelector("#xlsx-save-btn").click();
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
assert.equal(confirmCalls, 1);
|
||||
assert.equal(calls.length, 2);
|
||||
assert.equal(calls[0].body.force, false);
|
||||
assert.equal(calls[1].body.force, true);
|
||||
// The prompt names the features the backend reported.
|
||||
assert.ok(confirmPrompts[0].includes(FR["xlsx.feature_slicers"]), confirmPrompts[0]);
|
||||
// Save succeeded → cells are no longer dirty.
|
||||
assert.equal(area.querySelectorAll("td.xlsx-dirty").length, 0);
|
||||
});
|
||||
|
||||
await test("confirming once is enough for the following saves", async () => {
|
||||
const area = mount({ lossy: ["cached_values"] });
|
||||
editCell(area, "A1", "1");
|
||||
nextResponse = () => (calls.length === 1 ? lossyError : { ok: true, status: 200, body: {} });
|
||||
area.querySelector("#xlsx-save-btn").click();
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
editCell(area, "A1", "2");
|
||||
area.querySelector("#xlsx-save-btn").click();
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
assert.equal(confirmCalls, 1, "the user is not asked twice");
|
||||
assert.equal(calls.length, 3);
|
||||
assert.equal(calls[2].body.force, true);
|
||||
});
|
||||
|
||||
await test("refusing the confirmation writes nothing and keeps the cells dirty", async () => {
|
||||
const area = mount({ lossy: ["slicers"] });
|
||||
editCell(area, "A1", "250");
|
||||
nextResponse = () => lossyError;
|
||||
confirmAnswer = false;
|
||||
area.querySelector("#xlsx-save-btn").click();
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
assert.equal(confirmCalls, 1);
|
||||
assert.equal(calls.length, 1, "no retry after a refusal");
|
||||
assert.equal(area.querySelectorAll("td.xlsx-dirty").length, 1);
|
||||
assert.equal(area.querySelector("#xlsx-save-btn").disabled, false);
|
||||
});
|
||||
|
||||
await test("a non-409 failure is not retried", async () => {
|
||||
const area = mount();
|
||||
editCell(area, "A1", "250");
|
||||
nextResponse = () => ({ ok: false, status: 500, body: { detail: "boom" } });
|
||||
area.querySelector("#xlsx-save-btn").click();
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
assert.equal(calls.length, 1);
|
||||
assert.equal(confirmCalls, 0);
|
||||
assert.equal(area.querySelectorAll("td.xlsx-dirty").length, 1);
|
||||
});
|
||||
|
||||
// ── Report ──────────────────────────────────────────────────────────────────
|
||||
console.log(`\n${passCount}/${testCount} tests passed\n`);
|
||||
process.exit(passCount === testCount ? 0 : 1);
|
||||
@@ -115,6 +115,25 @@ class TestInvertedIndex:
|
||||
inv.remove_document("V", "p.md")
|
||||
assert inv.doc_count == 0 # Skipped
|
||||
|
||||
def test_is_ready_tracks_initial_build(self, client):
|
||||
"""is_ready() is the only freshness signal: no generation counter,
|
||||
no cooldown, no lazy rebuild (plan.md step 6)."""
|
||||
inv = InvertedIndex()
|
||||
assert inv.is_ready() is False
|
||||
inv.rebuild()
|
||||
assert inv.is_ready() is True
|
||||
# The old staleness API is gone for good.
|
||||
assert not hasattr(inv, "is_stale")
|
||||
|
||||
def test_is_ready_survives_incremental_updates(self, client):
|
||||
"""Incremental add/remove must not flip readiness back (which would
|
||||
silently push search() onto the O(N) full-scan fallback)."""
|
||||
self.inv.rebuild()
|
||||
assert self.inv.is_ready() is True
|
||||
self.inv.add_document("V", "p.md", {"path": "p.md", "title": "T", "tags": [], "content": "x"})
|
||||
self.inv.remove_document("V", "p.md")
|
||||
assert self.inv.is_ready() is True
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
# Search / Advanced Search integration tests
|
||||
@@ -192,3 +211,40 @@ class TestSearchFunctions:
|
||||
def test_suggest_tags_no_match(self, client):
|
||||
suggestions = suggest_tags("xyznonexistent", vault_filter="all")
|
||||
assert len(suggestions) == 0
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
# Vault removal — inverted index must not keep ghost documents
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
class TestVaultRemovalPurgesInvertedIndex:
|
||||
"""`remove_vault_from_index()` must notify the inverted-index hook.
|
||||
|
||||
Regression: it only cleaned the indexer's own structures, so every document
|
||||
of the removed vault survived in the inverted index (postings, doc_info,
|
||||
doc_vault, vault_docs) and kept matching searches for a vault that no
|
||||
longer exists — a leak that only a manual reindex used to clear.
|
||||
"""
|
||||
|
||||
def test_removing_a_vault_purges_its_documents(self, client):
|
||||
import asyncio
|
||||
|
||||
import backend.indexer as ix
|
||||
import backend.search as bs
|
||||
|
||||
ix.set_index_change_hook(bs._on_index_change_hook)
|
||||
inv = bs._inverted_index
|
||||
inv.rebuild()
|
||||
|
||||
vault_keys = [k for k in inv.doc_info if k.startswith("TestVault::")]
|
||||
assert vault_keys, "vault non indexe, test sans valeur"
|
||||
before = inv.doc_count
|
||||
|
||||
asyncio.run(ix.remove_vault_from_index("TestVault"))
|
||||
|
||||
ghosts = [k for k in inv.doc_info if k.startswith("TestVault::")]
|
||||
assert not ghosts, f"documents fantomes dans l'index inverse : {ghosts[:5]}"
|
||||
assert inv.doc_count == before - len(vault_keys)
|
||||
assert "TestVault" not in inv.vault_docs
|
||||
# The index stays usable for the remaining vaults.
|
||||
assert inv.is_ready() is True
|
||||
|
||||
+210
-1
@@ -1,7 +1,13 @@
|
||||
"""Display / edit / download for .xlsx files (viewer + PUT xlsx/save)."""
|
||||
"""Display / edit / download for .xlsx files (viewer + PUT xlsx/save).
|
||||
|
||||
Covers #152 (affichage / édition) and #153 P0 : A1 alerte de fidélité avant
|
||||
écriture, A2 écriture atomique, A3 verrou par fichier, A4 neutralisation de
|
||||
l'injection de formule.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
@@ -29,6 +35,43 @@ def xlsx_file(test_vault_dir: str) -> str:
|
||||
return str(path)
|
||||
|
||||
|
||||
def _add_lossy_parts(path: Path, parts: dict[str, bytes]) -> None:
|
||||
"""Re-pack *path* with extra OPC parts openpyxl cannot write back."""
|
||||
with zipfile.ZipFile(path) as zf:
|
||||
items = {n: zf.read(n) for n in zf.namelist()}
|
||||
items.update(parts)
|
||||
with zipfile.ZipFile(path, "w", zipfile.ZIP_DEFLATED) as zf:
|
||||
for name, blob in items.items():
|
||||
zf.writestr(name, blob)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def lossy_xlsx(test_vault_dir: str) -> str:
|
||||
"""Workbook with a slicer + a formula carrying its cached result."""
|
||||
from openpyxl import Workbook
|
||||
|
||||
path = Path(test_vault_dir) / "risky.xlsx"
|
||||
wb = Workbook()
|
||||
ws = wb.active
|
||||
ws.title = "Data"
|
||||
ws["A1"] = 3
|
||||
ws["A2"] = "=A1*3"
|
||||
wb.save(path)
|
||||
# <f>…</f><v>…</v> : openpyxl keeps the formula, drops the cached result.
|
||||
with zipfile.ZipFile(path) as zf:
|
||||
items = {n: zf.read(n) for n in zf.namelist()}
|
||||
sheet = next(n for n in items if n.startswith("xl/worksheets/sheet"))
|
||||
xml = items[sheet].decode("utf-8").replace(
|
||||
"<f>A1*3</f>", "<f>A1*3</f><v>9</v>"
|
||||
)
|
||||
items[sheet] = xml.encode("utf-8")
|
||||
items["xl/slicers/slicer1.xml"] = b"<slicer/>"
|
||||
with zipfile.ZipFile(path, "w", zipfile.ZIP_DEFLATED) as zf:
|
||||
for name, blob in items.items():
|
||||
zf.writestr(name, blob)
|
||||
return str(path)
|
||||
|
||||
|
||||
# ── Display ───────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -150,3 +193,169 @@ class TestXlsxSave:
|
||||
def test_missing_sheet_field_400(self, client, xlsx_file):
|
||||
resp = self._save(client, {"cells": {"A1": "x"}})
|
||||
assert resp.status_code == 400
|
||||
|
||||
def test_non_boolean_flag_400(self, client, xlsx_file):
|
||||
for flag in ("force", "allow_formula"):
|
||||
resp = self._save(client, {"sheet": "Budget", "cells": {"A1": "x"}, flag: "yes"})
|
||||
assert resp.status_code == 400, flag
|
||||
|
||||
|
||||
# ── #153 A1 — lossy-write guard ──────────────────────────────────────────
|
||||
|
||||
|
||||
class TestXlsxLossyGuard:
|
||||
def _save(self, client, body, path):
|
||||
return client.put(
|
||||
f"/api/file/{VAULT}/xlsx/save", params={"path": path}, json=body,
|
||||
)
|
||||
|
||||
def test_read_reports_lossy_features(self, client, lossy_xlsx):
|
||||
data = client.get(f"/api/file/{VAULT}", params={"path": "risky.xlsx"}).json()
|
||||
assert data["is_xlsx"] is True
|
||||
assert "slicers" in data["xlsx_lossy_features"]
|
||||
assert "cached_values" in data["xlsx_lossy_features"]
|
||||
|
||||
def test_read_reports_nothing_for_a_plain_workbook(self, client, xlsx_file):
|
||||
data = client.get(f"/api/file/{VAULT}", params={"path": "budget.xlsx"}).json()
|
||||
# B2 holds "=B1*2" but openpyxl wrote no cached <v> for it.
|
||||
assert data["xlsx_lossy_features"] == []
|
||||
|
||||
def test_save_refuses_without_force(self, client, lossy_xlsx):
|
||||
resp = self._save(client, {"sheet": "Data", "cells": {"B1": "hello"}}, "risky.xlsx")
|
||||
assert resp.status_code == 409
|
||||
body = resp.json()
|
||||
assert body["code"] == "xlsx_lossy_content"
|
||||
assert "slicers" in body["details"]["features"]
|
||||
|
||||
def test_refused_save_leaves_the_file_untouched(self, client, lossy_xlsx):
|
||||
before = Path(lossy_xlsx).read_bytes()
|
||||
self._save(client, {"sheet": "Data", "cells": {"B1": "hello"}}, "risky.xlsx")
|
||||
assert Path(lossy_xlsx).read_bytes() == before
|
||||
|
||||
def test_save_with_force_succeeds(self, client, lossy_xlsx):
|
||||
resp = self._save(
|
||||
client,
|
||||
{"sheet": "Data", "cells": {"B1": "hello"}, "force": True},
|
||||
"risky.xlsx",
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert openpyxl.load_workbook(lossy_xlsx)["Data"]["B1"].value == "hello"
|
||||
|
||||
def test_inspect_flags_every_known_family(self, lossy_xlsx):
|
||||
from backend.xlsx_reader import LOSSY_PARTS, inspect_workbook
|
||||
|
||||
for key, prefixes in LOSSY_PARTS.items():
|
||||
_add_lossy_parts(
|
||||
Path(lossy_xlsx),
|
||||
{f"{prefixes[0]}probe.xml": b"<x/>" for _ in [0]},
|
||||
)
|
||||
assert key in inspect_workbook(Path(lossy_xlsx)), key
|
||||
|
||||
def test_inspect_is_quiet_on_a_corrupt_archive(self, test_vault_dir):
|
||||
from backend.xlsx_reader import inspect_workbook
|
||||
|
||||
bad = Path(test_vault_dir) / "broken.xlsx"
|
||||
bad.write_bytes(b"not a zip at all")
|
||||
assert inspect_workbook(bad) == []
|
||||
|
||||
|
||||
# ── #153 A2 — atomic write ───────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestXlsxAtomicWrite:
|
||||
def test_failed_save_keeps_the_original(self, client, xlsx_file, monkeypatch):
|
||||
from openpyxl.workbook.workbook import Workbook
|
||||
|
||||
before = Path(xlsx_file).read_bytes()
|
||||
|
||||
def boom(self, *args, **kwargs):
|
||||
raise OSError("disk full")
|
||||
|
||||
monkeypatch.setattr(Workbook, "save", boom)
|
||||
# TestClient re-raises the server exception (in production: 500).
|
||||
with pytest.raises(OSError):
|
||||
client.put(
|
||||
f"/api/file/{VAULT}/xlsx/save",
|
||||
params={"path": "budget.xlsx"},
|
||||
json={"sheet": "Budget", "cells": {"A1": "perdu"}},
|
||||
)
|
||||
# The workbook on disk is byte-identical : the write never reached it.
|
||||
assert Path(xlsx_file).read_bytes() == before
|
||||
# No temporary file left behind in the vault.
|
||||
assert list(Path(xlsx_file).parent.glob("*.tmp")) == []
|
||||
|
||||
def test_no_tmp_left_after_a_successful_save(self, client, xlsx_file):
|
||||
client.put(
|
||||
f"/api/file/{VAULT}/xlsx/save",
|
||||
params={"path": "budget.xlsx"},
|
||||
json={"sheet": "Budget", "cells": {"A1": "ok"}},
|
||||
)
|
||||
assert list(Path(xlsx_file).parent.glob("*.tmp")) == []
|
||||
|
||||
|
||||
# ── #153 A3 — per-file write lock ────────────────────────────────────────
|
||||
|
||||
|
||||
class TestXlsxWriteLock:
|
||||
def test_concurrent_write_returns_409(self, client, xlsx_file):
|
||||
from backend.services import mutations
|
||||
|
||||
key = str(Path(xlsx_file).resolve())
|
||||
with mutations._xlsx_write_lock(key):
|
||||
resp = client.put(
|
||||
f"/api/file/{VAULT}/xlsx/save",
|
||||
params={"path": "budget.xlsx"},
|
||||
json={"sheet": "Budget", "cells": {"A1": "concurrent"}},
|
||||
)
|
||||
assert resp.status_code == 409
|
||||
assert resp.json()["code"] == "conflict"
|
||||
# The blocked call wrote nothing.
|
||||
assert openpyxl.load_workbook(xlsx_file)["Budget"]["A1"].value == "Poste"
|
||||
|
||||
def test_lock_is_released_after_a_normal_save(self, client, xlsx_file):
|
||||
from backend.services import mutations
|
||||
|
||||
key = str(Path(xlsx_file).resolve())
|
||||
client.put(
|
||||
f"/api/file/{VAULT}/xlsx/save",
|
||||
params={"path": "budget.xlsx"},
|
||||
json={"sheet": "Budget", "cells": {"A1": "premier"}},
|
||||
)
|
||||
# The lock must be free again once the request returned.
|
||||
with mutations._xlsx_write_lock(key):
|
||||
pass
|
||||
assert openpyxl.load_workbook(xlsx_file)["Budget"]["A1"].value == "premier"
|
||||
|
||||
|
||||
# ── #153 A4 — formula injection ──────────────────────────────────────────
|
||||
|
||||
|
||||
class TestXlsxFormulaGuard:
|
||||
def _save(self, client, cells, **extra):
|
||||
return client.put(
|
||||
f"/api/file/{VAULT}/xlsx/save",
|
||||
params={"path": "budget.xlsx"},
|
||||
json={"sheet": "Budget", "cells": cells, **extra},
|
||||
)
|
||||
|
||||
def test_formula_like_value_is_stored_as_text(self, client, xlsx_file):
|
||||
resp = self._save(client, {"A3": "=cmd|'/c calc'!A1"})
|
||||
assert resp.status_code == 200
|
||||
cell = openpyxl.load_workbook(xlsx_file)["Budget"]["A3"]
|
||||
assert cell.value == "=cmd|'/c calc'!A1"
|
||||
assert cell.data_type == "s" # pas de <f> dans l'archive
|
||||
|
||||
def test_at_prefix_is_stored_as_text(self, client, xlsx_file):
|
||||
self._save(client, {"A4": "@SUM(A1:A2)"})
|
||||
assert openpyxl.load_workbook(xlsx_file)["Budget"]["A4"].data_type == "s"
|
||||
|
||||
def test_allow_formula_keeps_a_real_formula(self, client, xlsx_file):
|
||||
resp = self._save(client, {"A3": "=B1+5"}, allow_formula=True)
|
||||
assert resp.status_code == 200
|
||||
assert openpyxl.load_workbook(xlsx_file)["Budget"]["A3"].data_type == "f"
|
||||
|
||||
def test_numbers_are_unaffected(self, client, xlsx_file):
|
||||
self._save(client, {"B3": "42", "B4": "-3.5"})
|
||||
ws = openpyxl.load_workbook(xlsx_file)["Budget"]
|
||||
assert ws["B3"].value == 42 and isinstance(ws["B3"].value, int)
|
||||
assert ws["B4"].value == -3.5
|
||||
|
||||
Reference in New Issue
Block a user