Feature: Fremdprofil-API (publicProfileByHandle) + Handle-Slug-Helfer
Backend fuer oeffentliche Community-Fremdprofile (Stufe 2 der Roadmap). handle.ts: - handleToSlug: KochHeld#A23B -> KochHeld-A23B (URL-tauglich, '#' geht nicht im Pfad). - parseHandleSlug: zerlegt am LETZTEN Bindestrich (Username darf Bindestriche enthalten), validiert Username + Discriminator gegen das Alphabet. users.publicProfileByHandle (protectedProcedure): - DATENSCHUTZ: liefert nur bei profileVisible=true, sonst NOT_FOUND (privates und nicht existierendes Profil sind von aussen ununterscheidbar). - Strikte Feld-Whitelist: handle, username, discriminator, bio, image. NIEMALS Art.-9-Daten (Gewicht/Kalorien/Ziele), keine Kontakt-/Auth-Felder. - Aggregat-Punkte gesamt + 30 Tage aus activity_logs.points, plus anonymisierte Aktivitaets-Streak (distinkte aktive Tage der letzten 7). Tests (node:test): 9 Slug-Tests + 6 Endpoint-Tests (sichtbar, Leak-Schutz, NOT_FOUND bei privat/unbekannt/ungueltigem Slug, Auth-Pflicht). Verifiziert: 75 API-Tests gruen, Typecheck 3/3. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
9ab6db9c0e
commit
2dfc770950
4 changed files with 328 additions and 3 deletions
|
|
@ -96,3 +96,39 @@ export function validateUsername(raw: string): UsernameValidation {
|
|||
}
|
||||
return { ok: true, value };
|
||||
}
|
||||
|
||||
/**
|
||||
* Wandelt den anzeigefertigen Handle in eine URL-taugliche Form um:
|
||||
* "KochHeld#A12B" -> "KochHeld-A12B". Das '#' ist als URL-Fragment-Zeichen
|
||||
* nicht im Pfad nutzbar, daher der Bindestrich als Trenner.
|
||||
*/
|
||||
export function handleToSlug(
|
||||
username: string | null | undefined,
|
||||
discriminator: string | null | undefined,
|
||||
): string | null {
|
||||
if (!username || !discriminator) return null;
|
||||
return `${username}-${discriminator}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Zerlegt einen URL-Slug ("KochHeld-A12B") zurück in Username + Discriminator.
|
||||
* Es wird am LETZTEN Bindestrich getrennt, weil der Username selbst Bindestriche
|
||||
* enthalten darf. Gibt null zurück, wenn der Slug keine gültige Form hat.
|
||||
*/
|
||||
export function parseHandleSlug(
|
||||
slug: string,
|
||||
): { username: string; discriminator: string } | null {
|
||||
const trimmed = slug.trim();
|
||||
const lastDash = trimmed.lastIndexOf("-");
|
||||
if (lastDash <= 0 || lastDash === trimmed.length - 1) return null;
|
||||
|
||||
const username = trimmed.slice(0, lastDash);
|
||||
const discriminator = trimmed.slice(lastDash + 1).toUpperCase();
|
||||
|
||||
if (validateUsername(username).ok !== true) return null;
|
||||
if (discriminator.length !== DISCRIMINATOR_LENGTH) return null;
|
||||
for (const ch of discriminator) {
|
||||
if (!DISCRIMINATOR_ALPHABET.includes(ch)) return null;
|
||||
}
|
||||
return { username, discriminator };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,6 +7,8 @@ import {
|
|||
formatHandle,
|
||||
generateDiscriminator,
|
||||
generateUniqueDiscriminator,
|
||||
handleToSlug,
|
||||
parseHandleSlug,
|
||||
validateUsername,
|
||||
} from "../lib/handle";
|
||||
|
||||
|
|
@ -65,11 +67,11 @@ test("generateUniqueDiscriminator wirft, wenn nach maxAttempts kein freier gefun
|
|||
});
|
||||
|
||||
test("formatHandle baut Username#Discriminator", () => {
|
||||
assert.equal(formatHandle("Held", "A12B"), "Held#A12B");
|
||||
assert.equal(formatHandle("Held", "A23B"), "Held#A23B");
|
||||
});
|
||||
|
||||
test("formatHandle liefert null ohne Username", () => {
|
||||
assert.equal(formatHandle(null, "A12B"), null);
|
||||
assert.equal(formatHandle(null, "A23B"), null);
|
||||
});
|
||||
|
||||
test("formatHandle liefert null ohne Discriminator", () => {
|
||||
|
|
@ -100,3 +102,50 @@ test("validateUsername lehnt Umlaute ab (Handle bleibt URL-tauglich)", () => {
|
|||
test("Bio-Limit ist 280", () => {
|
||||
assert.equal(BIO_MAX_LENGTH, 280);
|
||||
});
|
||||
|
||||
test("handleToSlug baut Username-Discriminator (URL-tauglich, kein #)", () => {
|
||||
assert.equal(handleToSlug("KochHeld", "A23B"), "KochHeld-A23B");
|
||||
});
|
||||
|
||||
test("handleToSlug liefert null ohne vollstaendigen Handle", () => {
|
||||
assert.equal(handleToSlug(null, "A23B"), null);
|
||||
assert.equal(handleToSlug("KochHeld", null), null);
|
||||
});
|
||||
|
||||
test("parseHandleSlug zerlegt am letzten Bindestrich", () => {
|
||||
assert.deepEqual(parseHandleSlug("KochHeld-A23B"), {
|
||||
username: "KochHeld",
|
||||
discriminator: "A23B",
|
||||
});
|
||||
});
|
||||
|
||||
test("parseHandleSlug behaelt Bindestriche im Username", () => {
|
||||
assert.deepEqual(parseHandleSlug("Koch-Held-A23B"), {
|
||||
username: "Koch-Held",
|
||||
discriminator: "A23B",
|
||||
});
|
||||
});
|
||||
|
||||
test("parseHandleSlug normalisiert den Discriminator auf Grossschreibung", () => {
|
||||
assert.deepEqual(parseHandleSlug("KochHeld-a23b"), {
|
||||
username: "KochHeld",
|
||||
discriminator: "A23B",
|
||||
});
|
||||
});
|
||||
|
||||
test("parseHandleSlug lehnt ungueltige Slugs ab", () => {
|
||||
assert.equal(parseHandleSlug("KochHeld"), null); // kein Discriminator
|
||||
assert.equal(parseHandleSlug("KochHeld-"), null); // leerer Discriminator
|
||||
assert.equal(parseHandleSlug("-A23B"), null); // leerer Username
|
||||
assert.equal(parseHandleSlug("KochHeld-A1"), null); // Discriminator zu kurz
|
||||
assert.equal(parseHandleSlug("KochHeld-A1IB"), null); // I nicht im Alphabet
|
||||
});
|
||||
|
||||
test("handleToSlug und parseHandleSlug sind invers", () => {
|
||||
const slug = handleToSlug("Koch-Held", "A23B");
|
||||
assert.ok(slug);
|
||||
assert.deepEqual(parseHandleSlug(slug), {
|
||||
username: "Koch-Held",
|
||||
discriminator: "A23B",
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { schema } from "@hellth/db";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { asc, eq, sql } from "drizzle-orm";
|
||||
import { and, asc, eq, gte, sql } from "drizzle-orm";
|
||||
import { z } from "zod";
|
||||
import { auth } from "../../lib/auth";
|
||||
import {
|
||||
|
|
@ -9,6 +9,7 @@ import {
|
|||
USERNAME_MIN_LENGTH,
|
||||
formatHandle,
|
||||
generateUniqueDiscriminator,
|
||||
parseHandleSlug,
|
||||
validateUsername,
|
||||
} from "../../lib/handle";
|
||||
import { auditSecurityEvent } from "../../lib/security-audit";
|
||||
|
|
@ -214,6 +215,91 @@ export const usersRouter = router({
|
|||
return { ok: true };
|
||||
}),
|
||||
|
||||
// Öffentliches Fremdprofil per Handle-Slug (z. B. "KochHeld-A12B").
|
||||
// DATENSCHUTZ: liefert NUR, wenn der Eigentümer profileVisible=true gesetzt
|
||||
// hat (sonst NOT_FOUND, damit die Existenz nicht durchsickert). Strikte
|
||||
// Feld-Whitelist — ausschließlich öffentliche Identitäts- und Aggregatdaten,
|
||||
// NIEMALS Art.-9-Gesundheitsdaten (Gewicht/Kalorien/Ziele) oder Kontaktdaten.
|
||||
publicProfileByHandle: protectedProcedure
|
||||
.input(z.object({ handle: z.string().min(1).max(64) }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
const parsed = parseHandleSlug(input.handle);
|
||||
if (!parsed) {
|
||||
throw new TRPCError({ code: "NOT_FOUND", message: "Profil nicht gefunden" });
|
||||
}
|
||||
|
||||
const target = await ctx.db.query.user.findFirst({
|
||||
where: (row, { and: andWhere, eq: rowEq }) =>
|
||||
andWhere(
|
||||
rowEq(row.username, parsed.username),
|
||||
rowEq(row.discriminator, parsed.discriminator),
|
||||
),
|
||||
columns: {
|
||||
id: true,
|
||||
username: true,
|
||||
discriminator: true,
|
||||
bio: true,
|
||||
image: true,
|
||||
profileVisible: true,
|
||||
},
|
||||
});
|
||||
|
||||
// Privacy-Gate serverseitig: privates oder nicht existierendes Profil
|
||||
// ist von außen nicht unterscheidbar (immer NOT_FOUND).
|
||||
if (!target || !target.profileVisible) {
|
||||
throw new TRPCError({ code: "NOT_FOUND", message: "Profil nicht gefunden" });
|
||||
}
|
||||
|
||||
// Aggregierte Aktivitäts-Punkte (kein Health-Detail). 30-Tage-Fenster
|
||||
// über das date-Feld (YYYY-MM-DD) der activity_logs.
|
||||
const thirtyDaysAgo = new Date();
|
||||
thirtyDaysAgo.setUTCDate(thirtyDaysAgo.getUTCDate() - 30);
|
||||
const since = thirtyDaysAgo.toISOString().slice(0, 10);
|
||||
|
||||
const [totalRow] = await ctx.db
|
||||
.select({ points: sql<number>`coalesce(sum(${schema.activityLogs.points}), 0)` })
|
||||
.from(schema.activityLogs)
|
||||
.where(eq(schema.activityLogs.userId, target.id));
|
||||
|
||||
const [recentRow] = await ctx.db
|
||||
.select({ points: sql<number>`coalesce(sum(${schema.activityLogs.points}), 0)` })
|
||||
.from(schema.activityLogs)
|
||||
.where(
|
||||
and(
|
||||
eq(schema.activityLogs.userId, target.id),
|
||||
gte(schema.activityLogs.date, since),
|
||||
),
|
||||
);
|
||||
|
||||
// Anonymisierte Aktivitäts-Streak: Anzahl distinkter Tage mit Aktivität
|
||||
// in den letzten 7 Tagen. Nur "wie aktiv", keine Inhalte/Werte.
|
||||
const sevenDaysAgo = new Date();
|
||||
sevenDaysAgo.setUTCDate(sevenDaysAgo.getUTCDate() - 7);
|
||||
const since7 = sevenDaysAgo.toISOString().slice(0, 10);
|
||||
const [streakRow] = await ctx.db
|
||||
.select({ days: sql<number>`count(distinct ${schema.activityLogs.date})` })
|
||||
.from(schema.activityLogs)
|
||||
.where(
|
||||
and(
|
||||
eq(schema.activityLogs.userId, target.id),
|
||||
gte(schema.activityLogs.date, since7),
|
||||
),
|
||||
);
|
||||
|
||||
return {
|
||||
handle: formatHandle(target.username, target.discriminator),
|
||||
username: target.username,
|
||||
discriminator: target.discriminator,
|
||||
bio: target.bio,
|
||||
image: target.image,
|
||||
points: {
|
||||
total: Number(totalRow?.points ?? 0),
|
||||
last30Days: Number(recentRow?.points ?? 0),
|
||||
},
|
||||
activeDaysLast7: Number(streakRow?.days ?? 0),
|
||||
};
|
||||
}),
|
||||
|
||||
setTheme: protectedProcedure
|
||||
.input(z.object({ theme: z.enum(["dark", "light"]).nullable() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
|
|
|
|||
154
packages/api/src/trpc/users.test.ts
Normal file
154
packages/api/src/trpc/users.test.ts
Normal file
|
|
@ -0,0 +1,154 @@
|
|||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { appRouter } from "./index";
|
||||
|
||||
// Schlanker Mock nur für users.publicProfileByHandle. Deckt sowohl
|
||||
// query.user.findFirst (Profil-Lookup) als auch den select().from().where()
|
||||
// Query-Builder (Punkte-Aggregate) ab.
|
||||
|
||||
type UserRow = {
|
||||
id: string;
|
||||
username: string | null;
|
||||
discriminator: string | null;
|
||||
bio: string | null;
|
||||
image: string | null;
|
||||
profileVisible: boolean;
|
||||
} | null;
|
||||
|
||||
function createContext(targetUser: UserRow, aggregates: { total: number; recent: number; days: number }) {
|
||||
const user = { id: "viewer", email: "viewer@example.org", role: "user" };
|
||||
|
||||
// Reihenfolge der select()-Aufrufe im Endpoint: total, recent(30T), streak(7T).
|
||||
const selectResults = [
|
||||
[{ points: aggregates.total }],
|
||||
[{ points: aggregates.recent }],
|
||||
[{ days: aggregates.days }],
|
||||
];
|
||||
let selectIndex = 0;
|
||||
|
||||
const db = {
|
||||
query: {
|
||||
user: {
|
||||
findFirst: async () => targetUser,
|
||||
},
|
||||
},
|
||||
select() {
|
||||
const result = selectResults[selectIndex++] ?? [];
|
||||
return {
|
||||
from() {
|
||||
return {
|
||||
where() {
|
||||
return Promise.resolve(result);
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
return {
|
||||
db,
|
||||
headers: new Headers(),
|
||||
session: { user },
|
||||
user,
|
||||
userRole: "user",
|
||||
isPlatformAdmin: false,
|
||||
};
|
||||
}
|
||||
|
||||
const visibleUser: UserRow = {
|
||||
id: "target-1",
|
||||
username: "KochHeld",
|
||||
discriminator: "A23B",
|
||||
bio: "Mag Meal Prep.",
|
||||
image: null,
|
||||
profileVisible: true,
|
||||
};
|
||||
|
||||
test("publicProfileByHandle liefert sichtbares Profil mit Whitelist-Feldern + Aggregaten", async () => {
|
||||
const caller = appRouter.createCaller(
|
||||
createContext(visibleUser, { total: 340, recent: 80, days: 5 }) as never,
|
||||
);
|
||||
|
||||
const result = await caller.users.publicProfileByHandle({ handle: "KochHeld-A23B" });
|
||||
|
||||
assert.equal(result.handle, "KochHeld#A23B");
|
||||
assert.equal(result.username, "KochHeld");
|
||||
assert.equal(result.bio, "Mag Meal Prep.");
|
||||
assert.equal(result.points.total, 340);
|
||||
assert.equal(result.points.last30Days, 80);
|
||||
assert.equal(result.activeDaysLast7, 5);
|
||||
});
|
||||
|
||||
test("publicProfileByHandle leakt KEINE Health-/Kontaktfelder", async () => {
|
||||
const caller = appRouter.createCaller(
|
||||
createContext(visibleUser, { total: 0, recent: 0, days: 0 }) as never,
|
||||
);
|
||||
|
||||
const result = await caller.users.publicProfileByHandle({ handle: "KochHeld-A23B" });
|
||||
|
||||
for (const forbidden of ["email", "id", "weightKg", "calories", "profileVisible", "role", "createdAt"]) {
|
||||
assert.equal(forbidden in result, false, `Feld ${forbidden} darf nicht geliefert werden`);
|
||||
}
|
||||
});
|
||||
|
||||
test("publicProfileByHandle wirft NOT_FOUND bei privatem Profil", async () => {
|
||||
const caller = appRouter.createCaller(
|
||||
createContext({ ...visibleUser, profileVisible: false }, { total: 0, recent: 0, days: 0 }) as never,
|
||||
);
|
||||
|
||||
await assert.rejects(
|
||||
() => caller.users.publicProfileByHandle({ handle: "KochHeld-A23B" }),
|
||||
(error: unknown) => {
|
||||
assert.equal((error as { code?: string }).code, "NOT_FOUND");
|
||||
return true;
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test("publicProfileByHandle wirft NOT_FOUND bei unbekanntem Handle", async () => {
|
||||
const caller = appRouter.createCaller(
|
||||
createContext(null, { total: 0, recent: 0, days: 0 }) as never,
|
||||
);
|
||||
|
||||
await assert.rejects(
|
||||
() => caller.users.publicProfileByHandle({ handle: "Niemand-Z9Z9" }),
|
||||
(error: unknown) => {
|
||||
assert.equal((error as { code?: string }).code, "NOT_FOUND");
|
||||
return true;
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test("publicProfileByHandle wirft NOT_FOUND bei ungueltigem Slug", async () => {
|
||||
const caller = appRouter.createCaller(
|
||||
createContext(visibleUser, { total: 0, recent: 0, days: 0 }) as never,
|
||||
);
|
||||
|
||||
await assert.rejects(
|
||||
() => caller.users.publicProfileByHandle({ handle: "KeinDiscriminator" }),
|
||||
(error: unknown) => {
|
||||
assert.equal((error as { code?: string }).code, "NOT_FOUND");
|
||||
return true;
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test("publicProfileByHandle erfordert Authentifizierung", async () => {
|
||||
const caller = appRouter.createCaller({
|
||||
db: {},
|
||||
headers: new Headers(),
|
||||
session: null,
|
||||
user: null,
|
||||
userRole: "user",
|
||||
isPlatformAdmin: false,
|
||||
} as never);
|
||||
|
||||
await assert.rejects(
|
||||
() => caller.users.publicProfileByHandle({ handle: "KochHeld-A23B" }),
|
||||
(error: unknown) => {
|
||||
assert.equal((error as { code?: string }).code, "UNAUTHORIZED");
|
||||
return true;
|
||||
},
|
||||
);
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue