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>
This commit is contained in:
ClaudeandClaude Opus 5.5 committed 2026-10-05 06:40:44 +00:00
1 parent a65c075004
commit 19c8151c7e
15 files changed
+980 -53

No files matched your search

+52 -3
View File
@@ -27,9 +27,12 @@ const SALT = 'test-salt-0123456789abcdef0123456789abcdef';
const ME = 'user-me';
const OTHER = 'user-other';
const ADMIN = 'user-admin';
/** Konto ohne Benutzernamen (alle anderen haben aus `setup` ein Profil): Hochladen geht erst mit Profil. */
const NEWBIE = 'user-new';
const JWT_ME = 'eyJhbGciOiJI.payload-me.signature-me';
const JWT_OTHER = 'eyJhbGciOiJI.payload-other.signature-other';
const JWT_ADMIN = 'eyJhbGciOiJI.payload-admin.signature-admin';
const JWT_NEW = 'eyJhbGciOiJI.payload-new.signature-new';
const MIB = 1024 * 1024;
const LOGO_BUCKET = 'logo-images';
/** Rechte einer frisch hochgeladenen Logo-Datei von ME. */
@@ -48,6 +51,9 @@ async function setup() {
fake.addUser(JWT_ME, { id: ME });
fake.addUser(JWT_OTHER, { id: OTHER });
fake.addUser(JWT_ADMIN, { id: ADMIN, labels: ['admin'] });
fake.addUser(JWT_NEW, { id: NEWBIE });
// Hochladen verlangt einen Benutzernamen (Tabelle `profiles`): ME, OTHER und ADMIN haben einen, NEWBIE nicht.
for (const id of [ME, OTHER, ADMIN]) fake.addProfile(id);
const logs = [];
const clock = { value: 1_700_000_000_000 };
@@ -159,6 +165,8 @@ function seedReports(fake, logoId, hashes) {
const sha32 = (text) => crypto.createHash('sha256').update(text).digest('hex').slice(0, 32);
const nonAccountCalls = (fake) => fake.calls.filter((c) => c.path !== '/v1/account');
/** Die Aufrufe bei Appwrite außer dem Konto und der Suche nach dem Profil von ME (die macht jede Anfrage zum Hochladen als Erstes). */
const callsBeyondProfile = (fake) => nonAccountCalls(fake).filter((c) => !(c.method === 'GET' && c.path.endsWith('/tables/profiles/rows/user-me')));
const filePuts = (fake, id) => fake.calls.filter((c) => c.method === 'PUT' && c.path.endsWith(`/files/${id}`));
const fileReads = (fake, id) => fake.calls.filter((c) => c.method === 'GET' && c.path.endsWith(`/files/${id}/view`));
@@ -319,6 +327,47 @@ test('Logo hochladen ohne oder mit ungültigem JWT: 401, nichts angelegt, die Da
assert.equal(fake.calls.filter((c) => c.path === '/v1/account').length, 1, 'fehlendes oder kaputtes JWT erreicht Appwrite gar nicht erst');
});
test('Logo hochladen ohne Benutzernamen: 403 usernameRequired noch vor jeder anderen Prüfung (auch vor Body und Datei), nichts angelegt, Datei unberührt', async () => {
const { upload, post, fake } = await setup();
logoFile(fake, 'logo1', NEWBIE);
expectError(await upload(uploadBody(), JWT_NEW), 403, 'usernameRequired', 'gültiger Body');
expectError(await upload({}, JWT_NEW), 403, 'usernameRequired', 'ungültiger Body (kein Name, keine Datei)');
expectError(await upload(uploadBody({ fileId: 'gibts-nicht' }), JWT_NEW), 403, 'usernameRequired', 'Datei gibt es nicht');
expectError(await upload(uploadBody({ fileId: 'logo1', mono: 'ja' }), JWT_NEW), 403, 'usernameRequired', 'mono ungültig');
expectError(await post('/api/logos/hochladen', null, JWT_NEW), 403, 'usernameRequired', 'kein Body');
assert.equal(fake.rows('logos').length, 0);
assert.deepEqual(fake.file('logo1').$permissions, ['read("any")', 'delete("user:user-new")'], 'die Datei bleibt, wie sie war');
assert.equal(fake.calls.filter((c) => c.path.startsWith('/v1/storage/')).length, 0, 'keine Datei wird angefasst');
// Appwrite sieht je Versuch nur die Prüfung des JWT und die Suche nach dem Profil (Zeilen-ID = Konto-ID)
assert.deepEqual(
nonAccountCalls(fake).map((c) => `${c.method} ${c.path}`),
Array(5).fill('GET /v1/tablesdb/aircard/tables/profiles/rows/user-new'),
);
});
test('Logo hochladen: das Profil gehört dem Konto – ein Profil der anderen Konten hilft nicht, mit eigenem Profil (über /api/profil) geht es', async () => {
const { upload, post, fake } = await setup();
logoFile(fake, 'logo1', NEWBIE);
expectError(await upload(uploadBody(), JWT_NEW), 403, 'usernameRequired');
const named = await post('/api/profil', { username: 'Neuling' }, JWT_NEW);
assert.deepEqual(named.json, { username: 'Neuling' });
const res = await upload(uploadBody(), JWT_NEW);
assert.equal(res.status, 201);
assert.equal(fake.row('logos', rowOf('logo1', NEWBIE)).ownerId, NEWBIE);
});
test('Logo hochladen: lässt sich das Profil nicht prüfen (Appwrite-Fehler), 502 und nichts wird angelegt; der nächste Versuch gelingt', async () => {
const { upload, fake } = await setup();
logoFile(fake, 'logo1');
fake.failNext((c) => c.method === 'GET' && c.path.endsWith('/tables/profiles/rows/user-me'), { status: 500 });
expectError(await upload(uploadBody()), 502, 'unavailable');
assert.equal(fake.rows('logos').length, 0);
assert.deepEqual(fake.file('logo1').$permissions, UPLOAD_PERMISSIONS);
assert.equal((await upload(uploadBody())).status, 201);
});
test('Logo hochladen mit fremder, falscher oder gesperrter Datei: 403, keine Zeile, Datei unverändert, Inhalt wird nicht gelesen', async () => {
const { upload, fake } = await setup();
logoFile(fake, 'fremd', OTHER);
@@ -698,7 +747,7 @@ test('Logo hochladen: scheitert das Aufräumen nach einer Beanstandung, steht `r
assert.equal(fake.row('logos', rowOf('logo2')), null);
});
test('Logo hochladen: Validierung liefert 400 mit dem passenden Code und fasst Appwrite (außer dem Konto) nicht an', async () => {
test('Logo hochladen: Validierung liefert 400 mit dem passenden Code und fasst Appwrite (außer Konto und Profil) nicht an', async () => {
const { upload, fake } = await setup();
logoFile(fake, 'logo1');
@@ -741,7 +790,7 @@ test('Logo hochladen: Validierung liefert 400 mit dem passenden Code und fasst A
}
assert.equal(fake.rows('logos').length, 0);
assert.equal(nonAccountCalls(fake).length, 0, 'ungültige Anfragen erreichen Appwrite (außer dem Konto) nie');
assert.equal(callsBeyondProfile(fake).length, 0, 'ungültige Anfragen erreichen Appwrite (außer Konto und Profil) nie');
});
test('Logo hochladen: kaputtes JSON, Array oder leerer Body ist 400 badRequest', async () => {
@@ -750,7 +799,7 @@ test('Logo hochladen: kaputtes JSON, Array oder leerer Body ist 400 badRequest',
const res = await call('POST', '/api/logos/hochladen', { raw, headers: { 'X-Aircard-Jwt': JWT_ME } });
expectError(res, 400, 'badRequest', JSON.stringify(raw));
}
assert.equal(nonAccountCalls(fake).length, 0);
assert.equal(callsBeyondProfile(fake).length, 0);
});
test('Logo hochladen: gleichzeitig dieselbe Datei oder zwei Dateien am Limit, genau eine Anfrage gelingt (Sperre je Konto)', async () => {