Added authentication for test-requests and test agains current nginx-server
Some checks failed
Sonarqube Scanner / Build and analyze (push) Failing after 20s
Some checks failed
Sonarqube Scanner / Build and analyze (push) Failing after 20s
This commit is contained in:
parent
b33ed98c66
commit
d0046f5693
1
.gitignore
vendored
1
.gitignore
vendored
@ -8,4 +8,5 @@ storage/trash/routes/*
|
|||||||
.env
|
.env
|
||||||
npm-debug.log*
|
npm-debug.log*
|
||||||
|
|
||||||
|
coverage/
|
||||||
.gitea/workflows/
|
.gitea/workflows/
|
||||||
4
package-lock.json
generated
4
package-lock.json
generated
@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "wegwichtel-next",
|
"name": "wegwichtel-next",
|
||||||
"version": "0.12.0",
|
"version": "0.12.1",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "wegwichtel-next",
|
"name": "wegwichtel-next",
|
||||||
"version": "0.12.0",
|
"version": "0.12.1",
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@file-type/av": "0.2.0",
|
"@file-type/av": "0.2.0",
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "wegwichtel-next",
|
"name": "wegwichtel-next",
|
||||||
"version": "0.12.0",
|
"version": "0.12.1",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "GPS-gestützte Lern- und Wanderwege mit GPX, POIs, Bildern und Audioansagen.",
|
"description": "GPS-gestützte Lern- und Wanderwege mit GPX, POIs, Bildern und Audioansagen.",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|||||||
@ -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',
|
'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'
|
'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))], {
|
const compile = spawnSync('python3', ['-m', 'py_compile', ...names.map(name => path.join(toolsDir, name))], {
|
||||||
cwd: root,
|
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, /error\.code == 401/);
|
||||||
assert.match(common, /self\.prompt_credentials\(\)/);
|
assert.match(common, /self\.prompt_credentials\(\)/);
|
||||||
assert.match(common, /getpass\.getpass/);
|
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.match(common, /def encode_multipart/);
|
||||||
assert.doesNotMatch(common, /Content-Type:.*application\/octet-stream/);
|
assert.doesNotMatch(common, /Content-Type:.*application\/octet-stream/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
test('Python client retries the same request with credentials after HTTP 401', async t => {
|
test('Python client retries the same request with API_USER and API_PASSWORD after HTTP 401', async t => {
|
||||||
const expected = `Basic ${Buffer.from('tester:secret').toString('base64')}`;
|
const apiUser = process.env.API_USER || 'tester';
|
||||||
let requests = 0;
|
const apiPassword = process.env.API_PASSWORD || 'secret';
|
||||||
|
const expectedAuthorization = `Basic ${Buffer.from(`${apiUser}:${apiPassword}`).toString('base64')}`;
|
||||||
|
const receivedAuthorization = [];
|
||||||
|
|
||||||
const server = http.createServer((req, res) => {
|
const server = http.createServer((req, res) => {
|
||||||
requests += 1;
|
receivedAuthorization.push(req.headers.authorization || null);
|
||||||
if (req.headers.authorization !== expected) {
|
if (req.headers.authorization !== expectedAuthorization) {
|
||||||
res.writeHead(401, { 'Content-Type': 'application/json', 'WWW-Authenticate': 'Basic realm="test"' });
|
res.writeHead(401, { 'Content-Type': 'application/json', 'WWW-Authenticate': 'Basic realm="test"' });
|
||||||
res.end(JSON.stringify({ message: 'Zugangsdaten erforderlich.' }));
|
res.end(JSON.stringify({ message: 'Zugangsdaten erforderlich.' }));
|
||||||
return;
|
return;
|
||||||
@ -68,20 +76,22 @@ test('Python client retries the same request with credentials after HTTP 401', a
|
|||||||
const { port } = server.address();
|
const { port } = server.address();
|
||||||
|
|
||||||
const script = `
|
const script = `
|
||||||
from common import ApiClient, Credentials
|
from common import ApiClient
|
||||||
class TestClient(ApiClient):
|
result = ApiClient('http://127.0.0.1:${port}').request('GET', '/protected')
|
||||||
def prompt_credentials(self):
|
|
||||||
self.credentials = Credentials('tester', 'secret')
|
|
||||||
result = TestClient('http://127.0.0.1:${port}').request('GET', '/protected')
|
|
||||||
assert result == {'ok': True}
|
assert result == {'ok': True}
|
||||||
`;
|
`;
|
||||||
const child = spawn('python3', ['-c', script], {
|
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']
|
stdio: ['ignore', 'pipe', 'pipe']
|
||||||
});
|
});
|
||||||
const stderr = [];
|
const stderr = [];
|
||||||
child.stderr.on('data', chunk => stderr.push(chunk.toString()));
|
child.stderr.on('data', chunk => stderr.push(chunk.toString()));
|
||||||
const status = await new Promise(resolve => child.once('exit', resolve));
|
const status = await new Promise(resolve => child.once('exit', resolve));
|
||||||
assert.equal(status, 0, stderr.join(''));
|
assert.equal(status, 0, stderr.join(''));
|
||||||
assert.equal(requests, 2);
|
assert.deepEqual(receivedAuthorization, [null, expectedAuthorization]);
|
||||||
});
|
});
|
||||||
|
|||||||
@ -4,17 +4,16 @@ Die Skripte benötigen nur Python 3 und die Standardbibliothek. Node.js, `curl`
|
|||||||
|
|
||||||
## Authentifizierung
|
## 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
|
```bash
|
||||||
export WEGWICHTEL_URL='https://wegwichtel.de'
|
export API_USER='florian'
|
||||||
export WEGWICHTEL_USER='florian'
|
export API_PASSWORD='geheimes-passwort'
|
||||||
# WEGWICHTEL_PASSWORD nur für unbeaufsichtigte Aufrufe verwenden.
|
|
||||||
```
|
```
|
||||||
|
|
||||||
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
|
## Routen
|
||||||
|
|
||||||
|
|||||||
@ -18,6 +18,8 @@ from urllib.parse import urljoin
|
|||||||
from urllib.request import Request, urlopen
|
from urllib.request import Request, urlopen
|
||||||
|
|
||||||
DEFAULT_BASE_URL = "https://wegwichtel.de"
|
DEFAULT_BASE_URL = "https://wegwichtel.de"
|
||||||
|
API_USER_ENV = "API_USER"
|
||||||
|
API_PASSWORD_ENV = "API_PASSWORD"
|
||||||
|
|
||||||
|
|
||||||
class ApiError(RuntimeError):
|
class ApiError(RuntimeError):
|
||||||
@ -33,6 +35,15 @@ class Credentials:
|
|||||||
password: str
|
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:
|
class ApiClient:
|
||||||
"""Kleine HTTP-Schicht mit dynamischer Basic-Auth-Nachfrage bei 401."""
|
"""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
|
self.credentials = Credentials(username, password) if username and password else None
|
||||||
|
|
||||||
def prompt_credentials(self) -> 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
|
username = input(f"Benutzername{f' [{default_user}]' if default_user else ''}: ").strip() or default_user
|
||||||
if not username:
|
if not username:
|
||||||
raise ApiError(401, "Für den geschützten API-Zugriff ist ein Benutzername erforderlich.")
|
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:
|
def client_from_args(args: argparse.Namespace) -> ApiClient:
|
||||||
base_url = args.base_url or os.getenv("WEGWICHTEL_URL") or DEFAULT_BASE_URL
|
base_url = args.base_url or os.getenv("WEGWICHTEL_URL") or DEFAULT_BASE_URL
|
||||||
username = args.username or os.getenv("WEGWICHTEL_USER")
|
return ApiClient(base_url, args.username)
|
||||||
password = os.getenv("WEGWICHTEL_PASSWORD") if username else None
|
|
||||||
return ApiClient(base_url, username, password)
|
|
||||||
|
|
||||||
|
|
||||||
def ask(label: str, value: Any = None, *, required: bool = False, cast: Callable[[str], Any] = str) -> Any:
|
def ask(label: str, value: Any = None, *, required: bool = False, cast: Callable[[str], Any] = str) -> Any:
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user