Files
ClaudeandClaude Opus 5.5 19c8151c7e feat(api): Benutzernamen (Profil-Tabelle, /api/profil, Pflicht beim Hochladen und Veröffentlichen)
- Schema: Tabelle `profiles` (Zeilen-ID = Konto-ID, `username` und `usernameKey`
  je 20 Zeichen, Unique-Index `username_key` auf `usernameKey`, rowSecurity an,
  keine Tabellenrechte) im Setup-Skript.
- Neues Modul `deploy/api/profiles.js`: `POST /api/profil` legt das Profil an
  oder ändert es (Zeilenrecht genau read("any"), Sperre je Konto). Muster,
  Sperrliste und Schlüssel (`usernameKey`) stehen in `common.js`; vergeben ->
  409 `usernameTaken`, auch wenn erst der Unique-Index bei einem Rennen
  entscheidet. Dazu die MSG-Texte `usernameInvalid`, `usernameReserved`,
  `usernameTaken`, `usernameRequired`.
- `POST /api/logos/hochladen` und `POST /api/veroeffentlichen` antworten 403
  `usernameRequired`, wenn das Konto kein Profil hat (`hasProfile`, nach der
  Anmeldung und vor allen anderen Prüfungen).
- Fake-Appwrite: Tabelle `profiles` mit Unique-Index (409), `addProfile`,
  `holdNext`. API-Dienst besteht jetzt aus sechs Dateien (READMEs).
- Client: die vier API-Texte in `API_MESSAGES` und `en.ts` (Gleichlauf mit MSG).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 06:40:44 +00:00

497 lines
24 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use strict';
// Tests für deploy/api/profiles.js (Benutzernamen): echte HTTP-Anfragen auf Port 0 gegen createApp, dahinter ein Fake-Appwrite im
// Speicher (fake-appwrite.js, Tabelle `profiles` mit Unique-Index auf `usernameKey`) und das echte appwrite.js.
// Aufruf: node --test "deploy/api/*.test.js"
const { test, afterEach } = require('node:test');
const assert = require('node:assert/strict');
const http = require('node:http');
const { createAppwrite, AppwriteError } = require('./appwrite.js');
const { startFakeAppwrite } = require('./fake-appwrite.js');
const { createApp } = require('./app.js');
const { createProfileRoutes, hasProfile } = require('./profiles.js');
const { MSG, USERNAME_PATTERN, RESERVED_USERNAMES, usernameKey, isReservedUsername } = require('./common.js');
const SALT = 'test-salt-0123456789abcdef0123456789abcdef';
const ME = 'user-me';
const OTHER = 'user-other';
const JWT_ME = 'eyJhbGciOiJI.payload-me.signature-me';
const JWT_OTHER = 'eyJhbGciOiJI.payload-other.signature-other';
/** Die Sperrliste der Spec (§ 2), wörtlich: der Test hält sie fest, damit eine Änderung auffällt. */
const SPEC_RESERVED = [
'admin',
'administrator',
'root',
'system',
'support',
'moderator',
'mod',
'aircard',
'aircardios',
'aircard-ios',
'aircard.ios',
'aircard_ios',
'webklar',
'anonym',
'anonymous',
'null',
'undefined',
'team',
'staff',
'official',
'offiziell',
'hilfe',
'help',
'kontakt',
'contact',
'info',
];
// ---- Aufbau ---------------------------------------------------------------------------------
const cleanups = [];
afterEach(async () => {
for (const fn of cleanups.splice(0).reverse()) await fn();
});
async function setup() {
const fake = await startFakeAppwrite();
fake.addUser(JWT_ME, { id: ME });
fake.addUser(JWT_OTHER, { id: OTHER });
const logs = [];
const appwrite = createAppwrite({ endpoint: fake.endpoint, project: fake.project, key: fake.key });
const app = createApp({ appwrite, salt: SALT, log: (line) => logs.push(String(line)) });
const server = http.createServer(app);
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
const { port } = server.address();
cleanups.push(async () => {
server.closeAllConnections();
await new Promise((resolve) => server.close(resolve));
await fake.close();
});
const call = (method, path, options) => request(port, method, path, options);
const post = (path, body, jwt) => call('POST', path, { body, headers: jwt ? { 'X-Aircard-Jwt': jwt } : {} });
/** POST /api/profil mit dem Namen (Standard: als ME). */
const profile = (username, jwt = JWT_ME) => post('/api/profil', { username }, jwt);
return { fake, appwrite, logs, call, post, profile };
}
function request(port, method, path, { headers = {}, body, raw, contentType = 'application/json' } = {}) {
return new Promise((resolve, reject) => {
const payload = raw !== undefined ? raw : body === undefined ? undefined : JSON.stringify(body);
const requestHeaders = { ...headers };
if (payload !== undefined && contentType) requestHeaders['Content-Type'] = contentType;
if (payload !== undefined) requestHeaders['Content-Length'] = Buffer.byteLength(payload);
const req = http.request({ host: '127.0.0.1', port, method, path, headers: requestHeaders }, (res) => {
const chunks = [];
res.on('data', (chunk) => chunks.push(chunk));
res.on('end', () => {
const text = Buffer.concat(chunks).toString('utf8');
let json = null;
try {
json = JSON.parse(text);
} catch {
// kein JSON
}
resolve({ status: res.statusCode, headers: res.headers, text, json });
});
});
req.on('error', reject);
if (payload !== undefined) req.write(payload);
req.end();
});
}
/** Erwartet Status und genau {error: <Text des MSG-Schlüssels>, code: <Schlüssel>}. */
function expectError(res, status, code, label = code) {
assert.equal(res.status, status, `${label}: Status`);
assert.deepEqual(res.json, { error: MSG[code], code }, label);
}
const nonAccountCalls = (fake) => fake.calls.filter((c) => c.path !== '/v1/account');
/** Aufrufe bei Appwrite auf `profiles`: Zeile anlegen, per Abfrage suchen (die Vorabprüfung), Zeile ändern. */
const isCreate = (c) => c.method === 'POST' && c.path.endsWith('/tables/profiles/rows');
const isList = (c) => c.method === 'GET' && c.path.endsWith('/tables/profiles/rows');
const isUpdate = (c) => c.method === 'PATCH' && c.path.includes('/tables/profiles/rows/');
/** Wartet (höchstens etwa 2 s), bis `condition()` stimmt: so fängt ein Test den Moment ab, in dem ein Aufruf bei Appwrite noch läuft. */
async function until(condition) {
for (let i = 0; i < 400 && !condition(); i += 1) await new Promise((resolve) => setTimeout(resolve, 5));
assert.ok(condition(), 'der erwartete Zustand wurde nicht erreicht');
}
/**
* Zwei Anfragen, die beide vorab geprüft haben („Name frei“), bevor eine davon schreibt: das Schreiben (`isWrite`) wird festgehalten, bis
* beide Abfragen bei Appwrite angekommen sind. So entscheidet erst der Unique-Index, nicht die Vorabprüfung.
*/
async function raceTwo(fake, isWrite, first, second) {
const releases = [fake.holdNext(isWrite), fake.holdNext(isWrite)];
const pending = Promise.all([first(), second()]);
await until(() => fake.calls.filter(isList).length === 2);
for (const release of releases) release();
return pending;
}
// ---- Reine Helfer (common.js) -----------------------------------------------------------------------
test('USERNAME_PATTERN: 3–20 Zeichen, erstes ein Buchstabe oder eine Zahl, danach Buchstaben, Zahlen, Punkt, Unterstrich, Bindestrich', () => {
assert.equal(USERNAME_PATTERN.source, '^[A-Za-z0-9][A-Za-z0-9._-]{2,19}$');
for (const ok of ['abc', 'Kenso', 'k3nso', '007', 'a.b', 'a_b', 'a-b', 'A.b_c-d9', 'a'.repeat(20), 'Z9.']) {
assert.ok(USERNAME_PATTERN.test(ok), ok);
}
const bad = ['', 'a', 'ab', '_abc', '.abc', '-abc', 'a'.repeat(21), 'a b', ' abc', 'abc ', 'abc\n', 'a\nbc', 'Jürgen', 'abc', 'a@b', 'a/b', 'a+b', 'abc😀'];
for (const name of bad) assert.ok(!USERNAME_PATTERN.test(name), JSON.stringify(name));
});
test('usernameKey: der Name klein geschrieben, sonst unverändert', () => {
assert.equal(usernameKey('Kenso'), 'kenso');
assert.equal(usernameKey('AirCard-IOS'), 'aircard-ios');
assert.equal(usernameKey('a.B_c-D9'), 'a.b_c-d9');
assert.equal(usernameKey('kenso'), 'kenso');
});
test('Sperrliste: genau die Schlüssel der Spec (§ 2), gesperrt in jeder Schreibweise; andere Namen sind frei', () => {
assert.deepEqual([...RESERVED_USERNAMES].sort(), [...SPEC_RESERVED].sort());
for (const key of SPEC_RESERVED) {
assert.ok(isReservedUsername(key), key);
assert.ok(isReservedUsername(key.toUpperCase()), key.toUpperCase());
assert.ok(isReservedUsername(`${key[0].toUpperCase()}${key.slice(1)}`), `${key} mit großem Anfang`);
// jeder gesperrte Name passt zum Muster (sonst wäre der Eintrag wirkungslos: er scheiterte schon als „ungültig“)
assert.match(key, USERNAME_PATTERN);
}
for (const free of ['kenso', 'admins', 'admin1', 'root_', 'air-card-ios', 'aircard-ios2', 'team.x', 'xinfo', 'helpme']) {
assert.ok(!isReservedUsername(free), free);
}
});
test('MSG: die vier Benutzernamen-Texte stehen wörtlich fest (Spec § 4)', () => {
assert.equal(
MSG.usernameInvalid,
'Der Benutzername muss 3–20 Zeichen lang sein, mit einem Buchstaben oder einer Zahl beginnen und darf nur Buchstaben, Zahlen, Punkt, Unterstrich und Bindestrich enthalten.',
);
assert.equal(MSG.usernameReserved, 'Dieser Benutzername ist nicht erlaubt.');
assert.equal(MSG.usernameTaken, 'Dieser Benutzername ist schon vergeben.');
assert.equal(MSG.usernameRequired, 'Bitte wähle zuerst einen Benutzernamen.');
});
test('createProfileRoutes liefert genau die Route POST /api/profil mit Name, json und run', () => {
const routes = createProfileRoutes({
appwrite: {},
say: () => {},
withLock: (key, fn) => fn(),
requireUser: async () => ({ id: ME, isAdmin: false }),
});
assert.deepEqual(
routes.map(([key]) => key),
['POST /api/profil'],
);
const [[, route]] = routes;
assert.equal(route.name, 'profil');
assert.equal(route.json, true);
assert.equal(typeof route.run, 'function');
});
test('hasProfile: true mit Zeile (Zeilen-ID = Konto-ID), false ohne; ein Appwrite-Fehler wird nicht verschluckt', async () => {
const { fake, appwrite } = await setup();
assert.equal(await hasProfile(appwrite, ME), false);
fake.addProfile(ME, 'Kenso');
assert.equal(await hasProfile(appwrite, ME), true);
assert.equal(await hasProfile(appwrite, OTHER), false, 'ein anderes Konto hat nichts davon');
fake.failNext((c) => c.method === 'GET' && c.path.endsWith('/tables/profiles/rows/user-other'), { status: 500 });
await assert.rejects(hasProfile(appwrite, OTHER), (err) => err instanceof AppwriteError && err.status === 500);
// fehlt die Tabelle ganz (Schema nicht eingespielt), ist das ein Fehler und kein „kein Profil“
fake.failNext((c) => c.method === 'GET' && c.path.endsWith('/tables/profiles/rows/user-other'), { status: 404, type: 'table_not_found' });
await assert.rejects(hasProfile(appwrite, OTHER), (err) => err instanceof AppwriteError && err.status === 404);
});
test('Fake-Appwrite: der Unique-Index auf usernameKey weist eine zweite Zeile (Anlegen und Ändern) mit 409 ab', async () => {
const { fake, appwrite } = await setup();
const isConflict = (err) => err instanceof AppwriteError && err.status === 409;
await appwrite.createRow('profiles', 'a', { username: 'Kenso', usernameKey: 'kenso' }, ['read("any")']);
await assert.rejects(appwrite.createRow('profiles', 'b', { username: 'KENSO', usernameKey: 'kenso' }, ['read("any")']), isConflict);
assert.equal(fake.row('profiles', 'b'), null);
await appwrite.createRow('profiles', 'b', { username: 'Other', usernameKey: 'other' }, ['read("any")']);
await assert.rejects(appwrite.updateRow('profiles', 'b', { username: 'Kenso', usernameKey: 'kenso' }), isConflict);
assert.equal(fake.row('profiles', 'b').usernameKey, 'other');
// die eigene Zeile ändern (nur die Schreibweise) ist kein Verstoß
await appwrite.updateRow('profiles', 'a', { username: 'KENSO', usernameKey: 'kenso' });
assert.equal(fake.row('profiles', 'a').username, 'KENSO');
// Spaltenlängen wie in der echten Tabelle (username und usernameKey: 20)
await assert.rejects(appwrite.createRow('profiles', 'c', { username: 'x'.repeat(21), usernameKey: 'x' }, []), (err) => err.status === 400);
});
// ---- Anlegen und Ändern ----------------------------------------------------------------------------
test('Profil anlegen: 200 {username}, Zeilen-ID = Konto-ID, Rechte genau read("any"), usernameKey klein, der Name wie eingegeben', async () => {
const { profile, fake } = await setup();
const res = await profile('Kenso.IOS');
assert.equal(res.status, 200);
assert.deepEqual(res.json, { username: 'Kenso.IOS' });
assert.equal(fake.rows('profiles').length, 1);
const row = fake.row('profiles', ME);
assert.equal(row.username, 'Kenso.IOS');
assert.equal(row.usernameKey, 'kenso.ios');
assert.deepEqual(row.$permissions, ['read("any")']);
// der Server-Key hängt an jedem Datenaufruf, das JWT nur am Konto-Aufruf
assert.equal(fake.calls.find((c) => c.path === '/v1/account').headers['x-appwrite-jwt'], JWT_ME);
for (const c of nonAccountCalls(fake)) {
assert.equal(c.headers['x-appwrite-key'], fake.key);
assert.equal(c.headers['x-appwrite-jwt'], undefined);
}
});
test('Profil anlegen: die Zeile gehört dem Konto aus dem JWT, andere Felder im Body (Konto-ID, Schlüssel, Rechte) werden ignoriert', async () => {
const { post, fake } = await setup();
const res = await post(
'/api/profil',
{ username: 'Kenso', usernameKey: 'anders', userId: OTHER, ownerId: OTHER, $id: OTHER, $permissions: ['read("any")', 'update("any")'] },
JWT_ME,
);
assert.deepEqual(res.json, { username: 'Kenso' });
assert.equal(fake.row('profiles', OTHER), null);
const row = fake.row('profiles', ME);
assert.equal(row.usernameKey, 'kenso');
assert.deepEqual(row.$permissions, ['read("any")']);
assert.deepEqual(Object.keys(row).filter((key) => !key.startsWith('$')).sort(), ['username', 'usernameKey']);
});
test('Profil anlegen an den Grenzen: 3 und 20 Zeichen gehen', async () => {
const { profile, fake } = await setup();
assert.deepEqual((await profile('abc')).json, { username: 'abc' });
assert.deepEqual((await profile('X'.repeat(20), JWT_OTHER)).json, { username: 'X'.repeat(20) });
assert.equal(fake.row('profiles', OTHER).usernameKey, 'x'.repeat(20));
});
test('Profil ändern: dieselbe Zeile wird aktualisiert, der alte Schlüssel ist sofort für ein anderes Konto frei, der neue vergeben', async () => {
const { profile, fake } = await setup();
assert.equal((await profile('Kenso')).status, 200);
expectError(await profile('kenso', JWT_OTHER), 409, 'usernameTaken', 'solange der Name vergeben ist');
const res = await profile('Kenso.neu');
assert.equal(res.status, 200);
assert.deepEqual(res.json, { username: 'Kenso.neu' });
assert.equal(fake.rows('profiles').length, 1, 'eine Zeile je Konto');
assert.equal(fake.row('profiles', ME).username, 'Kenso.neu');
assert.equal(fake.row('profiles', ME).usernameKey, 'kenso.neu');
assert.deepEqual(fake.row('profiles', ME).$permissions, ['read("any")']);
assert.equal(fake.calls.filter(isCreate).length, 1, 'beim Ändern wird nichts neu angelegt');
assert.equal(fake.calls.filter(isUpdate).length, 1);
// der alte Name ist frei: ein anderes Konto nimmt ihn, das alte Konto bekommt ihn nicht mehr zurück
assert.deepEqual((await profile('kenso', JWT_OTHER)).json, { username: 'kenso' });
expectError(await profile('Kenso'), 409, 'usernameTaken', 'zurück');
assert.equal(fake.row('profiles', ME).username, 'Kenso.neu', 'nach dem Fehlschlag bleibt das Profil, wie es war');
assert.equal(fake.row('profiles', OTHER).usernameKey, 'kenso');
});
test('„kenso“ → „Kenso“ beim selben Konto ist erlaubt (nur die Schreibweise), ebenso derselbe Name nochmal', async () => {
const { profile, fake } = await setup();
assert.deepEqual((await profile('kenso')).json, { username: 'kenso' });
assert.deepEqual((await profile('Kenso')).json, { username: 'Kenso' });
assert.equal(fake.row('profiles', ME).username, 'Kenso');
assert.equal(fake.row('profiles', ME).usernameKey, 'kenso');
assert.deepEqual((await profile('Kenso')).json, { username: 'Kenso' }, 'derselbe Name nochmal');
assert.deepEqual((await profile('KENSO')).json, { username: 'KENSO' });
assert.equal(fake.rows('profiles').length, 1);
assert.equal(fake.row('profiles', ME).username, 'KENSO');
});
test('vergebener Name (in jeder Schreibweise): 409 usernameTaken, es wird nichts angelegt oder geändert', async () => {
const { profile, fake } = await setup();
fake.addProfile(OTHER, 'Kenso');
for (const name of ['kenso', 'KENSO', 'Kenso', 'kEnSo']) expectError(await profile(name), 409, 'usernameTaken', name);
assert.equal(fake.row('profiles', ME), null, 'kein Profil angelegt');
assert.equal(fake.rows('profiles').length, 1);
// wer schon ein Profil hat, behält es
fake.addProfile(ME, 'Me.me');
expectError(await profile('kenso'), 409, 'usernameTaken');
assert.equal(fake.row('profiles', ME).username, 'Me.me');
assert.equal(fake.row('profiles', OTHER).username, 'Kenso', 'das andere Profil ist unberührt');
});
// ---- Gleichzeitigkeit --------------------------------------------------------------------------------
test('Rennen: zwei Konten wollen gleichzeitig denselben Namen – genau eines bekommt ihn, das andere 409 (Unique-Index, nicht nur die Vorabprüfung)', async () => {
const { profile, fake } = await setup();
const results = await raceTwo(
fake,
isCreate,
() => profile('Rennen', JWT_ME),
() => profile('rennen', JWT_OTHER),
);
assert.deepEqual(results.map((res) => res.status).sort(), [200, 409]);
const winner = results.findIndex((res) => res.status === 200);
const loser = results.find((res) => res.status === 409);
assert.deepEqual(loser.json, { error: MSG.usernameTaken, code: 'usernameTaken' });
assert.equal(fake.rows('profiles').length, 1, 'genau eine Zeile');
const [row] = fake.rows('profiles');
assert.equal(row.$id, winner === 0 ? ME : OTHER);
assert.equal(row.usernameKey, 'rennen');
assert.equal(results[winner].json.username, row.username);
// beide Vorabprüfungen haben den Namen für frei gehalten und beide haben angelegt: erst der Unique-Index hat entschieden
assert.equal(fake.calls.filter(isCreate).length, 2);
});
test('Rennen beim Umbenennen: zwei Konten wollen denselben neuen Namen – genau eines bekommt ihn, das andere behält seinen alten', async () => {
const { profile, fake } = await setup();
fake.addProfile(ME, 'Alt.eins');
fake.addProfile(OTHER, 'Alt.zwei');
const results = await raceTwo(
fake,
isUpdate,
() => profile('Gemeinsam', JWT_ME),
() => profile('GEMEINSAM', JWT_OTHER),
);
assert.deepEqual(results.map((res) => res.status).sort(), [200, 409]);
assert.deepEqual(results.find((res) => res.status === 409).json, { error: MSG.usernameTaken, code: 'usernameTaken' });
assert.equal(fake.calls.filter(isUpdate).length, 2, 'beide haben ändern wollen: erst der Unique-Index hat entschieden');
const [winner, loser] = results[0].status === 200 ? [ME, OTHER] : [OTHER, ME];
assert.equal(fake.row('profiles', winner).usernameKey, 'gemeinsam');
assert.equal(fake.row('profiles', loser).username, loser === ME ? 'Alt.eins' : 'Alt.zwei', 'wer verliert, behält den alten Namen');
assert.equal(fake.rows('profiles').length, 2);
});
test('Sperre je Konto (profile:<id>): zwei gleichzeitige Anfragen desselben Kontos laufen nacheinander, beide gelingen, am Ende gilt die zweite', async () => {
const { profile, fake } = await setup();
const release = fake.holdNext(isCreate);
const first = profile('Alpha');
await until(() => fake.calls.some(isCreate)); // die erste steht beim Anlegen
const second = profile('Beta');
await until(() => fake.calls.filter((c) => c.path === '/v1/account').length === 2); // die zweite ist angemeldet ...
await new Promise((resolve) => setTimeout(resolve, 50));
assert.equal(fake.calls.filter(isList).length, 1, '... wartet aber auf die Sperre, statt schon abzufragen');
release();
assert.deepEqual((await first).json, { username: 'Alpha' });
assert.deepEqual((await second).json, { username: 'Beta' });
assert.equal(fake.rows('profiles').length, 1);
assert.equal(fake.row('profiles', ME).username, 'Beta');
assert.equal(fake.row('profiles', ME).usernameKey, 'beta');
assert.equal(fake.calls.filter(isCreate).length, 1, 'die zweite hat die Zeile der ersten geändert, nicht noch einmal angelegt');
assert.equal(fake.calls.filter(isUpdate).length, 1);
});
// ---- Prüfungen -----------------------------------------------------------------------------------------
test('gesperrte Namen (Spec § 2, in jeder Schreibweise): 400 usernameReserved, ohne Abfrage bei Appwrite', async () => {
const { profile, fake } = await setup();
for (const name of ['Admin', 'AirCard-IOS', 'AIRCARD.IOS', 'Aircard_Ios', 'ROOT', 'Null', ...SPEC_RESERVED]) {
expectError(await profile(name), 400, 'usernameReserved', name);
}
assert.equal(fake.rows('profiles').length, 0);
assert.equal(nonAccountCalls(fake).length, 0, 'die Prüfung braucht Appwrite nur für das JWT');
});
test('ungültige Namen (Muster): 400 usernameInvalid, nichts angelegt, ohne Abfrage bei Appwrite', async () => {
const { profile, fake } = await setup();
const cases = [
['zu kurz', 'ab'],
['ein Zeichen', 'a'],
['leer', ''],
['Unterstrich vorn', '_abc'],
['Punkt vorn', '.abc'],
['Bindestrich vorn', '-abc'],
['21 Zeichen', 'a'.repeat(21)],
['Leerzeichen', 'a b'],
['Leerzeichen vorn', ' abc'],
['Leerzeichen hinten', 'abc '],
['Zeilenumbruch', 'abc\n'],
['Umlaut', 'Jürgen'],
['Emoji', 'abc😀'],
['Klammeraffe', '@kenso'],
['Schrägstrich', 'a/b'],
];
for (const [label, name] of cases) expectError(await profile(name), 400, 'usernameInvalid', label);
assert.equal(fake.rows('profiles').length, 0);
assert.equal(nonAccountCalls(fake).length, 0);
});
test('kein Text als Name (fehlt, null, Zahl, Liste, Objekt, Wahrheitswert): 400 usernameInvalid; kein JSON-Objekt: 400 badRequest', async () => {
const { post, call, fake } = await setup();
for (const body of [{}, { username: null }, { username: 7 }, { username: ['abc'] }, { username: { a: 1 } }, { username: true }, { name: 'Kenso' }]) {
expectError(await post('/api/profil', body, JWT_ME), 400, 'usernameInvalid', JSON.stringify(body));
}
for (const raw of ['[]', '"abc"', '7', '{kaputt']) {
const res = await call('POST', '/api/profil', { raw, headers: { 'X-Aircard-Jwt': JWT_ME } });
expectError(res, 400, 'badRequest', raw);
}
assert.equal(fake.rows('profiles').length, 0);
});
test('ohne oder mit ungültigem JWT: 401, noch vor der Prüfung des Namens, nichts angelegt', async () => {
const { post, profile, fake } = await setup();
expectError(await post('/api/profil', { username: 'Kenso' }), 401, 'unauthorized', 'ohne JWT');
expectError(await post('/api/profil', {}), 401, 'unauthorized', 'ohne JWT, ungültiger Body');
expectError(await profile('Kenso', 'xxx.yyy.zzz'), 401, 'unauthorized', 'unbekanntes JWT');
expectError(await profile('Kenso', 'zwei teile'), 401, 'unauthorized', 'unzulässige Zeichen');
expectError(await profile('Admin', ''), 401, 'unauthorized', 'leeres JWT');
assert.equal(fake.rows('profiles').length, 0);
assert.equal(nonAccountCalls(fake).length, 0, 'ohne gültiges JWT wird nichts abgefragt oder geschrieben');
});
test('nur POST: GET /api/profil gibt es nicht (404)', async () => {
const { call } = await setup();
expectError(await call('GET', '/api/profil'), 404, 'notFound');
});
// ---- Fehler bei Appwrite -----------------------------------------------------------------------------------
test('Appwrite antwortet mit Fehler: 502 unavailable ohne halben Zustand, der nächste Versuch gelingt', async () => {
const { profile, fake } = await setup();
// die Vorabprüfung scheitert
fake.failNext(isList, { status: 500 });
expectError(await profile('Kenso'), 502, 'unavailable', 'Vorabprüfung');
assert.equal(fake.rows('profiles').length, 0);
// das Anlegen scheitert
fake.failNext(isCreate, { status: 503 });
expectError(await profile('Kenso'), 502, 'unavailable', 'Anlegen');
assert.equal(fake.rows('profiles').length, 0);
assert.deepEqual((await profile('Kenso')).json, { username: 'Kenso' });
// das Ändern scheitert: das alte Profil bleibt
fake.failNext(isUpdate, { status: 500 });
expectError(await profile('Neu.name'), 502, 'unavailable', 'Ändern');
assert.equal(fake.row('profiles', ME).username, 'Kenso');
assert.deepEqual((await profile('Neu.name')).json, { username: 'Neu.name' });
assert.equal(fake.row('profiles', ME).usernameKey, 'neu.name');
});
test('fehlt die Tabelle `profiles` (Schema nicht eingespielt): 500 internal, kein Absturz', async () => {
const { profile, fake } = await setup();
fake.failNext(isList, { status: 404, type: 'table_not_found' });
expectError(await profile('Kenso'), 500, 'internal');
});
// ---- Logs -----------------------------------------------------------------------------------------------------
test('Logs: eine Ereigniszeile je Anlegen und Ändern, nie Name, Konto-ID, Schlüssel oder JWT', async () => {
const { profile, logs, fake } = await setup();
await profile('GEHEIMER.Name'); // anlegen
await profile('GEHEIMER.Neu'); // ändern
await profile('ab'); // 400
await profile('Admin'); // 400
await profile('geheimer.neu', JWT_OTHER); // 409
await profile('x', 'xxx.yyy.zzz'); // 401
const lines = logs.map((line) => line.replace(/^\[aircard-api\] \S+ /, ''));
assert.deepEqual(lines, ['profile created', 'profile updated']);
assert.ok(logs.every((line) => line.startsWith('[aircard-api] ')));
for (const secret of [fake.key, SALT, JWT_ME, JWT_OTHER, 'GEHEIMER', 'geheimer', ME, OTHER]) {
assert.ok(!logs.join('\n').includes(secret), `Log enthält „${secret}“`);
}
});