- 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>
497 lines
24 KiB
JavaScript
497 lines
24 KiB
JavaScript
'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}“`);
|
||
}
|
||
});
|