mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-10-23 07:12:21 +02:00
fix(email): support case-insensitive IMAP atoms and portable browser discovery
- Make _uid_from_fetch_meta case-insensitive with re.IGNORECASE matching size extraction - Remove hardcoded developer home paths from test_email_library_print_browser.cjs - Implement standard Playwright Chromium discovery with environment variable overrides and actionable launch failures - Add executable tests for uppercase, lowercase, mixed-case IMAP atoms, malformed metadata, and preflight security
This commit is contained in:
@@ -639,7 +639,7 @@ def _message_id_chain(*values: str) -> list[str]:
|
|||||||
|
|
||||||
|
|
||||||
def _uid_from_fetch_meta(meta_b: bytes) -> str:
|
def _uid_from_fetch_meta(meta_b: bytes) -> str:
|
||||||
m = re.search(rb"\bUID\s+(\d+)\b", meta_b)
|
m = re.search(rb"\bUID\s+(\d+)\b", meta_b, re.IGNORECASE)
|
||||||
return m.group(1).decode() if m else ""
|
return m.group(1).decode() if m else ""
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -753,6 +753,111 @@ def test_messages_download_bulk_spooled_stream_lifecycle():
|
|||||||
assert f2.closed
|
assert f2.closed
|
||||||
|
|
||||||
|
|
||||||
|
def test_uid_and_size_from_fetch_meta_case_insensitivity_and_validation():
|
||||||
|
"""Unit test: _uid_from_fetch_meta and _size_from_fetch_meta case insensitivity and boundary validation."""
|
||||||
|
from routes.email.email_routes import _uid_from_fetch_meta, _size_from_fetch_meta
|
||||||
|
|
||||||
|
# 1. Standard uppercase UID and RFC822.SIZE
|
||||||
|
meta_upper = b"1 (UID 101 RFC822.SIZE 1234)"
|
||||||
|
assert _uid_from_fetch_meta(meta_upper) == "101"
|
||||||
|
assert _size_from_fetch_meta(meta_upper) == 1234
|
||||||
|
|
||||||
|
# 2. Lowercase uid and rfc822.size
|
||||||
|
meta_lower = b"1 (uid 202 rfc822.size 5678)"
|
||||||
|
assert _uid_from_fetch_meta(meta_lower) == "202"
|
||||||
|
assert _size_from_fetch_meta(meta_lower) == 5678
|
||||||
|
|
||||||
|
# 3. Mixed-case combinations
|
||||||
|
combos = [
|
||||||
|
(b"1 (Uid 303 Rfc822.Size 1000)", "303", 1000),
|
||||||
|
(b"1 (uID 404 RFC822.size 2000)", "404", 2000),
|
||||||
|
(b"1 (UID 505 rfc822.size 3000)", "505", 3000),
|
||||||
|
(b"1 (uid 606 RFC822.SIZE 4000)", "606", 4000),
|
||||||
|
(b"1 (uId 707 Rfc822.SIZE 5000)", "707", 5000),
|
||||||
|
(b"1 (FLAGS (\\Seen) uid 808 rfc822.Size 6000)", "808", 6000),
|
||||||
|
]
|
||||||
|
for meta_bytes, exp_uid, exp_sz in combos:
|
||||||
|
assert _uid_from_fetch_meta(meta_bytes) == exp_uid
|
||||||
|
assert _size_from_fetch_meta(meta_bytes) == exp_sz
|
||||||
|
|
||||||
|
# 4. Malformed metadata cases
|
||||||
|
assert _uid_from_fetch_meta(b"1 (rfc822.size 1234)") == ""
|
||||||
|
assert _size_from_fetch_meta(b"1 (uid 101 flags (\\seen))") is None
|
||||||
|
assert _uid_from_fetch_meta(b"1 (uid abc rfc822.size 1234)") == ""
|
||||||
|
assert _size_from_fetch_meta(b"1 (uid 101 rfc822.size invalid)") is None
|
||||||
|
assert _size_from_fetch_meta(b"1 (uid 101 rfc822.size -50)") is None
|
||||||
|
|
||||||
|
# 5. Boundary integrity (must not match substrings like UUID, GUID, NOT_RFC822.SIZE)
|
||||||
|
assert _uid_from_fetch_meta(b"1 (UUID 101 GUID 202 NOT_RFC822.SIZE 1234)") == ""
|
||||||
|
assert _size_from_fetch_meta(b"1 (UUID 101 GUID 202 NOT_RFC822.SIZE 1234)") is None
|
||||||
|
assert _uid_from_fetch_meta(b"1 (uid101 rfc822.size1234)") == ""
|
||||||
|
assert _size_from_fetch_meta(b"1 (uid101 rfc822.size1234)") is None
|
||||||
|
assert _uid_from_fetch_meta(b"") == ""
|
||||||
|
assert _size_from_fetch_meta(b"") is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_messages_download_bulk_case_insensitive_imap_atoms(monkeypatch):
|
||||||
|
"""End-to-end: Bulk and single-message export with lowercase and mixed-case IMAP atoms."""
|
||||||
|
import io
|
||||||
|
import zipfile
|
||||||
|
|
||||||
|
msg101 = b"From: a@example.com\r\nSubject: Item 101\r\n\r\nBody 1"
|
||||||
|
msg102 = b"From: b@example.com\r\nSubject: Item 102\r\n\r\nBody 2"
|
||||||
|
mailbox = _MockImapConn({"101": msg101, "102": msg102})
|
||||||
|
|
||||||
|
def custom_fetch(args):
|
||||||
|
fetch_cmd = str(args[1]) if len(args) > 1 else ""
|
||||||
|
if "RFC822.SIZE" in fetch_cmd or "rfc822.size" in fetch_cmd.lower():
|
||||||
|
# Server returns lowercase/mixed-case atoms
|
||||||
|
return "OK", [
|
||||||
|
b"1 (uid 101 rfc822.size " + str(len(msg101)).encode() + b")",
|
||||||
|
b"2 (Uid 102 Rfc822.Size " + str(len(msg102)).encode() + b")",
|
||||||
|
]
|
||||||
|
# In body fetch, server returns mixed-case atoms
|
||||||
|
return "OK", [
|
||||||
|
(b"1 (uid 101 rfc822 {" + str(len(msg101)).encode(), msg101),
|
||||||
|
b")",
|
||||||
|
(b"2 (Uid 102 RFC822 {" + str(len(msg102)).encode(), msg102),
|
||||||
|
b")",
|
||||||
|
]
|
||||||
|
|
||||||
|
mailbox.fetch_cb = custom_fetch
|
||||||
|
client = _create_test_app(monkeypatch, mailbox=mailbox)
|
||||||
|
|
||||||
|
# 1. Bulk download (2 items) with lowercase / mixed-case IMAP responses
|
||||||
|
res_bulk = client.post("/api/email/messages-download-bulk", json={
|
||||||
|
"messages": [{"uid": "101"}, {"uid": "102"}]
|
||||||
|
})
|
||||||
|
assert res_bulk.status_code == 200
|
||||||
|
assert res_bulk.headers["x-exported-emails"] == "2"
|
||||||
|
assert res_bulk.headers["x-skipped-emails"] == "0"
|
||||||
|
zf = zipfile.ZipFile(io.BytesIO(res_bulk.content))
|
||||||
|
assert len(zf.namelist()) == 2
|
||||||
|
|
||||||
|
# 2. Single message download fast path with lowercase IMAP response
|
||||||
|
mailbox.fetch_calls.clear()
|
||||||
|
res_single = client.post("/api/email/messages-download-bulk", json={
|
||||||
|
"messages": [{"uid": "101"}]
|
||||||
|
})
|
||||||
|
assert res_single.status_code == 200
|
||||||
|
assert res_single.headers["content-type"].startswith("message/rfc822")
|
||||||
|
assert res_single.content == msg101
|
||||||
|
|
||||||
|
# 3. Lowercase malformed preflight fails safely with 502 and NO body fetch
|
||||||
|
mailbox.fetch_calls.clear()
|
||||||
|
mailbox.fetch_cb = lambda args: (
|
||||||
|
("OK", [b"1 (uid 101 rfc822.size NaN)"])
|
||||||
|
if "RFC822.SIZE" in str(args) or "rfc822.size" in str(args).lower()
|
||||||
|
else ("OK", [])
|
||||||
|
)
|
||||||
|
res_malformed = client.post("/api/email/messages-download-bulk", json={
|
||||||
|
"messages": [{"uid": "101"}]
|
||||||
|
})
|
||||||
|
assert res_malformed.status_code == 502
|
||||||
|
assert "Invalid email size metadata from server" in res_malformed.json()["detail"]
|
||||||
|
assert not any("(UID RFC822)" in str(call) for call in mailbox.fetch_calls)
|
||||||
|
|
||||||
|
|
||||||
def test_email_print_browser_behaviors():
|
def test_email_print_browser_behaviors():
|
||||||
"""Real Chromium DOM: placeholder stripping, button removal, and partial failure."""
|
"""Real Chromium DOM: placeholder stripping, button removal, and partial failure."""
|
||||||
import shutil
|
import shutil
|
||||||
|
|||||||
@@ -1,19 +1,115 @@
|
|||||||
|
/**
|
||||||
|
* Browser print regression tests for Email Library print and PDF export.
|
||||||
|
*
|
||||||
|
* Requirements:
|
||||||
|
* - Node.js (v20+)
|
||||||
|
* - Playwright (`npm ci` or `npm install -D @playwright/test`)
|
||||||
|
* - Chromium browser binary:
|
||||||
|
* - Standard Playwright install: `npx playwright install chromium`
|
||||||
|
* - Or custom executable via env vars:
|
||||||
|
* - `PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH=/path/to/chrome`
|
||||||
|
* - `CHROME_PATH=/path/to/chrome`
|
||||||
|
* - Or custom browsers cache dir via:
|
||||||
|
* - `PLAYWRIGHT_BROWSERS_PATH=/path/to/cache`
|
||||||
|
*/
|
||||||
|
|
||||||
const http = require('http');
|
const http = require('http');
|
||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
const { chromium } = require('playwright');
|
const os = require('os');
|
||||||
|
|
||||||
|
let chromium;
|
||||||
|
try {
|
||||||
|
({ chromium } = require('playwright'));
|
||||||
|
} catch (err) {
|
||||||
|
if (err && err.code === 'MODULE_NOT_FOUND') {
|
||||||
|
console.error(
|
||||||
|
'Error: Playwright module not found.\n' +
|
||||||
|
'Please install dependencies by running:\n' +
|
||||||
|
' npm ci\n' +
|
||||||
|
'or ensure NODE_PATH contains the playwright package.'
|
||||||
|
);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
const ROOT = path.resolve(__dirname, '..');
|
const ROOT = path.resolve(__dirname, '..');
|
||||||
|
|
||||||
// Find available chromium executable or default to Playwright's cached browser
|
// Discover available Chromium executable using standard Playwright resolution,
|
||||||
|
// custom environment variables, standard cache paths, or system binaries.
|
||||||
function findChromiumPath() {
|
function findChromiumPath() {
|
||||||
const candidates = [
|
// 1. Explicit environment variable overrides
|
||||||
'/home/alteixeira20/.cache/ms-playwright/chromium_headless_shell-1248/chrome-headless-shell-linux64/chrome-headless-shell',
|
if (process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH && fs.existsSync(process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH)) {
|
||||||
'/home/alteixeira20/.cache/ms-playwright/chromium-1248/chrome-linux/chrome',
|
return process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH;
|
||||||
];
|
|
||||||
for (const c of candidates) {
|
|
||||||
if (fs.existsSync(c)) return c;
|
|
||||||
}
|
}
|
||||||
|
if (process.env.CHROME_PATH && fs.existsSync(process.env.CHROME_PATH)) {
|
||||||
|
return process.env.CHROME_PATH;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Playwright's configured/default executable path
|
||||||
|
try {
|
||||||
|
const defaultPath = chromium.executablePath();
|
||||||
|
if (defaultPath && fs.existsSync(defaultPath)) {
|
||||||
|
return defaultPath;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// chromium.executablePath() may throw if no default browser is installed
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Inspect standard Playwright browser cache directories
|
||||||
|
const cacheDirs = [];
|
||||||
|
if (process.env.PLAYWRIGHT_BROWSERS_PATH) {
|
||||||
|
cacheDirs.push(process.env.PLAYWRIGHT_BROWSERS_PATH);
|
||||||
|
}
|
||||||
|
if (process.platform === 'win32') {
|
||||||
|
if (process.env.LOCALAPPDATA) {
|
||||||
|
cacheDirs.push(path.join(process.env.LOCALAPPDATA, 'ms-playwright'));
|
||||||
|
}
|
||||||
|
cacheDirs.push(path.join(os.homedir(), 'AppData', 'Local', 'ms-playwright'));
|
||||||
|
} else if (process.platform === 'darwin') {
|
||||||
|
cacheDirs.push(path.join(os.homedir(), 'Library', 'Caches', 'ms-playwright'));
|
||||||
|
} else {
|
||||||
|
cacheDirs.push(path.join(os.homedir(), '.cache', 'ms-playwright'));
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const cacheDir of cacheDirs) {
|
||||||
|
if (!fs.existsSync(cacheDir)) continue;
|
||||||
|
try {
|
||||||
|
const entries = fs.readdirSync(cacheDir).sort().reverse();
|
||||||
|
for (const entry of entries) {
|
||||||
|
const entryPath = path.join(cacheDir, entry);
|
||||||
|
const candidates = [
|
||||||
|
path.join(entryPath, 'chrome-headless-shell-linux64', 'chrome-headless-shell'),
|
||||||
|
path.join(entryPath, 'chrome-linux64', 'chrome'),
|
||||||
|
path.join(entryPath, 'chrome-linux', 'chrome'),
|
||||||
|
path.join(entryPath, 'chrome-win64', 'chrome.exe'),
|
||||||
|
path.join(entryPath, 'chrome-win', 'chrome.exe'),
|
||||||
|
path.join(entryPath, 'chrome-mac', 'Chromium.app', 'Contents', 'MacOS', 'Chromium'),
|
||||||
|
];
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
if (fs.existsSync(candidate)) {
|
||||||
|
return candidate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. System browser fallbacks
|
||||||
|
const systemCandidates = process.platform === 'win32' ? [
|
||||||
|
path.join(process.env['PROGRAMFILES'] || 'C:\\Program Files', 'Google', 'Chrome', 'Application', 'chrome.exe'),
|
||||||
|
path.join(process.env['PROGRAMFILES(X86)'] || 'C:\\Program Files (x86)', 'Google', 'Chrome', 'Application', 'chrome.exe'),
|
||||||
|
] : [
|
||||||
|
'/usr/bin/google-chrome',
|
||||||
|
'/usr/bin/chromium',
|
||||||
|
'/usr/bin/chromium-browser',
|
||||||
|
'/snap/bin/chromium',
|
||||||
|
];
|
||||||
|
for (const sc of systemCandidates) {
|
||||||
|
if (fs.existsSync(sc)) return sc;
|
||||||
|
}
|
||||||
|
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -97,7 +193,18 @@ async function run() {
|
|||||||
const chromePath = findChromiumPath();
|
const chromePath = findChromiumPath();
|
||||||
if (chromePath) launchOpts.executablePath = chromePath;
|
if (chromePath) launchOpts.executablePath = chromePath;
|
||||||
|
|
||||||
const browser = await chromium.launch(launchOpts);
|
let browser;
|
||||||
|
try {
|
||||||
|
browser = await chromium.launch(launchOpts);
|
||||||
|
} catch (launchErr) {
|
||||||
|
throw new Error(
|
||||||
|
`Failed to launch Chromium browser: ${launchErr.message}\n` +
|
||||||
|
'Missing or unexecutable Chromium binary.\n' +
|
||||||
|
'To install Playwright Chromium, run:\n' +
|
||||||
|
' npx playwright install chromium\n' +
|
||||||
|
'Or set PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH to a valid Chromium executable.'
|
||||||
|
);
|
||||||
|
}
|
||||||
const context = await browser.newContext();
|
const context = await browser.newContext();
|
||||||
const page = await context.newPage();
|
const page = await context.newPage();
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user