diff --git a/.gitignore b/.gitignore index 8470125..0312e29 100644 --- a/.gitignore +++ b/.gitignore @@ -8,4 +8,5 @@ storage/trash/routes/* .env npm-debug.log* +coverage/ .gitea/workflows/ \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 380042e..2f790b5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "wegwichtel-next", - "version": "0.12.0", + "version": "0.12.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "wegwichtel-next", - "version": "0.12.0", + "version": "0.12.1", "hasInstallScript": true, "dependencies": { "@file-type/av": "0.2.0", diff --git a/package.json b/package.json index 66da3d3..9d55775 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "wegwichtel-next", - "version": "0.12.0", + "version": "0.12.1", "private": true, "description": "GPS-gestützte Lern- und Wanderwege mit GPX, POIs, Bildern und Audioansagen.", "type": "module", diff --git a/test/upload-detection-tools.test.js b/test/upload-detection-tools.test.js index 349c258..c9794c6 100644 --- a/test/upload-detection-tools.test.js +++ b/test/upload-detection-tools.test.js @@ -31,7 +31,10 @@ test('Python API tools are syntax-valid and contain reusable 401 authentication 'create_route.py', 'delete_audio.py', 'delete_picture.py', 'delete_poi.py', 'delete_route.py', 'restore_route.py', 'update_audio.py', 'update_picture.py', 'update_poi.py', 'update_route.py' ]; - assert.deepEqual(names.sort(), expected.sort()); + assert.deepEqual( + names.toSorted((left, right) => left.localeCompare(right)), + expected.toSorted((left, right) => left.localeCompare(right)) + ); const compile = spawnSync('python3', ['-m', 'py_compile', ...names.map(name => path.join(toolsDir, name))], { cwd: root, @@ -45,17 +48,22 @@ test('Python API tools are syntax-valid and contain reusable 401 authentication assert.match(common, /error\.code == 401/); assert.match(common, /self\.prompt_credentials\(\)/); assert.match(common, /getpass\.getpass/); + assert.match(common, /os\.getenv\(API_USER_ENV/); + assert.match(common, /os\.getenv\(API_PASSWORD_ENV/); assert.match(common, /def encode_multipart/); assert.doesNotMatch(common, /Content-Type:.*application\/octet-stream/); }); -test('Python client retries the same request with credentials after HTTP 401', async t => { - const expected = `Basic ${Buffer.from('tester:secret').toString('base64')}`; - let requests = 0; +test('Python client retries the same request with API_USER and API_PASSWORD after HTTP 401', async t => { + const apiUser = process.env.API_USER || 'tester'; + const apiPassword = process.env.API_PASSWORD || 'secret'; + const expectedAuthorization = `Basic ${Buffer.from(`${apiUser}:${apiPassword}`).toString('base64')}`; + const receivedAuthorization = []; + const server = http.createServer((req, res) => { - requests += 1; - if (req.headers.authorization !== expected) { + receivedAuthorization.push(req.headers.authorization || null); + if (req.headers.authorization !== expectedAuthorization) { res.writeHead(401, { 'Content-Type': 'application/json', 'WWW-Authenticate': 'Basic realm="test"' }); res.end(JSON.stringify({ message: 'Zugangsdaten erforderlich.' })); return; @@ -68,20 +76,22 @@ test('Python client retries the same request with credentials after HTTP 401', a const { port } = server.address(); const script = ` -from common import ApiClient, Credentials -class TestClient(ApiClient): - def prompt_credentials(self): - self.credentials = Credentials('tester', 'secret') -result = TestClient('http://127.0.0.1:${port}').request('GET', '/protected') +from common import ApiClient +result = ApiClient('http://127.0.0.1:${port}').request('GET', '/protected') assert result == {'ok': True} `; const child = spawn('python3', ['-c', script], { - env: { ...process.env, PYTHONPATH: path.join(root, 'tools/python') }, + env: { + ...process.env, + API_USER: apiUser, + API_PASSWORD: apiPassword, + PYTHONPATH: path.join(root, 'tools/python') + }, stdio: ['ignore', 'pipe', 'pipe'] }); const stderr = []; child.stderr.on('data', chunk => stderr.push(chunk.toString())); const status = await new Promise(resolve => child.once('exit', resolve)); assert.equal(status, 0, stderr.join('')); - assert.equal(requests, 2); + assert.deepEqual(receivedAuthorization, [null, expectedAuthorization]); }); diff --git a/tools/python/README.md b/tools/python/README.md index 2eaee0e..b8aa3ed 100644 --- a/tools/python/README.md +++ b/tools/python/README.md @@ -4,17 +4,16 @@ Die Skripte benötigen nur Python 3 und die Standardbibliothek. Node.js, `curl` ## Authentifizierung -Jeder Request wird zunächst mit den bereits bekannten Zugangsdaten oder ohne Zugangsdaten ausgeführt. Antwortet ein vorgeschalteter Webserver mit `401 Unauthorized`, fragt das Werkzeug Benutzername und Passwort interaktiv ab und wiederholt denselben Request. Das Passwort wird mit `getpass` ohne Bildschirmausgabe eingelesen. +Jeder Request wird zunächst ohne vorauseilenden Authorization-Header ausgeführt. Antwortet ein vorgeschalteter Webserver mit `401 Unauthorized`, verwendet die gemeinsame Request-Schicht zuerst die Umgebungsvariablen `API_USER` und `API_PASSWORD`. Sind beide gesetzt, wird derselbe Request genau mit diesen Basic-Auth-Credentials wiederholt. Fehlen sie, fragt das Werkzeug Benutzername und Passwort interaktiv ab. Das Passwort wird mit `getpass` ohne Bildschirmausgabe eingelesen. -Optionale Umgebungsvariablen: +Für unbeaufsichtigte Aufrufe und CI-Pipelines: ```bash -export WEGWICHTEL_URL='https://wegwichtel.de' -export WEGWICHTEL_USER='florian' -# WEGWICHTEL_PASSWORD nur für unbeaufsichtigte Aufrufe verwenden. +export API_USER='florian' +export API_PASSWORD='geheimes-passwort' ``` -Alle Skripte unterstützen außerdem `--base-url` und `--username`. Fehlende fachliche Parameter werden interaktiv abgefragt. +Die API-Basisadresse ist standardmäßig `https://wegwichtel.de`. Alle Skripte unterstützen außerdem `--base-url` für abweichende Zielsysteme und `--username` für eine interaktive Anmeldung. Fehlende fachliche Parameter werden interaktiv abgefragt. ## Routen diff --git a/tools/python/common.py b/tools/python/common.py index 72d908b..ae11224 100644 --- a/tools/python/common.py +++ b/tools/python/common.py @@ -18,6 +18,8 @@ from urllib.parse import urljoin from urllib.request import Request, urlopen DEFAULT_BASE_URL = "https://wegwichtel.de" +API_USER_ENV = "API_USER" +API_PASSWORD_ENV = "API_PASSWORD" class ApiError(RuntimeError): @@ -33,6 +35,15 @@ class Credentials: password: str +def credentials_from_environment() -> Credentials | None: + """Liest CI-Credentials, ohne einen Request vor dem ersten HTTP 401 zu authentifizieren.""" + username = os.getenv(API_USER_ENV, "").strip() + password = os.getenv(API_PASSWORD_ENV, "").strip() + if username and password: + return Credentials(username, password) + return None + + class ApiClient: """Kleine HTTP-Schicht mit dynamischer Basic-Auth-Nachfrage bei 401.""" @@ -41,7 +52,12 @@ class ApiClient: self.credentials = Credentials(username, password) if username and password else None def prompt_credentials(self) -> None: - default_user = self.credentials.username if self.credentials else os.getenv("WEGWICHTEL_USER", "") + environment_credentials = credentials_from_environment() + if environment_credentials: + self.credentials = environment_credentials + return + + default_user = self.credentials.username if self.credentials else os.getenv(API_USER_ENV, "") username = input(f"Benutzername{f' [{default_user}]' if default_user else ''}: ").strip() or default_user if not username: raise ApiError(401, "Für den geschützten API-Zugriff ist ein Benutzername erforderlich.") @@ -172,9 +188,7 @@ def add_common_arguments(parser: argparse.ArgumentParser) -> None: def client_from_args(args: argparse.Namespace) -> ApiClient: base_url = args.base_url or os.getenv("WEGWICHTEL_URL") or DEFAULT_BASE_URL - username = args.username or os.getenv("WEGWICHTEL_USER") - password = os.getenv("WEGWICHTEL_PASSWORD") if username else None - return ApiClient(base_url, username, password) + return ApiClient(base_url, args.username) def ask(label: str, value: Any = None, *, required: bool = False, cast: Callable[[str], Any] = str) -> Any: