fix(geschichte): stop exposing author email in the list projection
GET /api/geschichten shipped every author's AppUser email to all readers via GeschichteSummary.AuthorSummary — contradicting the documented rule that author projections never expose email or group memberships. The frontend only used it as a display-name fallback; it now falls back to [Unbekannt], matching the server-side rule in GeschichteService.toView. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -37,10 +37,9 @@ public interface GeschichteSummary {
|
|||||||
|
|
||||||
String getBody();
|
String getBody();
|
||||||
|
|
||||||
|
/** Author projection — names only; never email or group memberships (same rule as GeschichteView.AuthorView). */
|
||||||
interface AuthorSummary {
|
interface AuthorSummary {
|
||||||
String getFirstName();
|
String getFirstName();
|
||||||
String getLastName();
|
String getLastName();
|
||||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
|
||||||
String getEmail();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -87,8 +87,9 @@ class GeschichteListProjectionTest {
|
|||||||
// ─── AuthorSummary nested projection ─────────────────────────────────────
|
// ─── AuthorSummary nested projection ─────────────────────────────────────
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void findSummaries_exposes_nested_author_firstName_lastName_email() {
|
void findSummaries_exposes_nested_author_names_but_never_email() {
|
||||||
AppUser richAuthor = appUserRepository.save(AppUser.builder()
|
AppUser richAuthor = appUserRepository.save(AppUser.builder()
|
||||||
|
.firstName("Franz").lastName("Raddatz")
|
||||||
.email("franz@raddatz.de").password("pw").build());
|
.email("franz@raddatz.de").password("pw").build());
|
||||||
geschichteRepository.save(published("Briefe aus der Front", richAuthor));
|
geschichteRepository.save(published("Briefe aus der Front", richAuthor));
|
||||||
|
|
||||||
@@ -97,7 +98,13 @@ class GeschichteListProjectionTest {
|
|||||||
|
|
||||||
assertThat(result).hasSize(1);
|
assertThat(result).hasSize(1);
|
||||||
GeschichteSummary.AuthorSummary a = result.get(0).getAuthor();
|
GeschichteSummary.AuthorSummary a = result.get(0).getAuthor();
|
||||||
assertThat(a.getEmail()).isEqualTo("franz@raddatz.de");
|
assertThat(a.getFirstName()).isEqualTo("Franz");
|
||||||
|
assertThat(a.getLastName()).isEqualTo("Raddatz");
|
||||||
|
// Design rule (GeschichteView.AuthorView javadoc): author projections never
|
||||||
|
// expose email or group memberships to readers.
|
||||||
|
assertThat(GeschichteSummary.AuthorSummary.class.getMethods())
|
||||||
|
.extracting(java.lang.reflect.Method::getName)
|
||||||
|
.doesNotContain("getEmail");
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── GeschichteType is exposed ────────────────────────────────────────────
|
// ─── GeschichteType is exposed ────────────────────────────────────────────
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ const baseRow = (overrides = {}) => ({
|
|||||||
body: '<p>Im Jahr 1923...</p>',
|
body: '<p>Im Jahr 1923...</p>',
|
||||||
type: 'STORY' as 'STORY' | 'JOURNEY',
|
type: 'STORY' as 'STORY' | 'JOURNEY',
|
||||||
status: 'PUBLISHED' as 'PUBLISHED' | 'DRAFT',
|
status: 'PUBLISHED' as 'PUBLISHED' | 'DRAFT',
|
||||||
author: { firstName: 'Anna', lastName: 'Schmidt', email: 'a@x' },
|
author: { firstName: 'Anna', lastName: 'Schmidt' },
|
||||||
publishedAt: '2026-04-15T10:00:00Z',
|
publishedAt: '2026-04-15T10:00:00Z',
|
||||||
...overrides
|
...overrides
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ function authorName(g: GeschichteSummary): string {
|
|||||||
const a = g.author;
|
const a = g.author;
|
||||||
if (!a) return '';
|
if (!a) return '';
|
||||||
const full = [a.firstName, a.lastName].filter(Boolean).join(' ').trim();
|
const full = [a.firstName, a.lastName].filter(Boolean).join(' ').trim();
|
||||||
return full || a.email || '';
|
return full || '[Unbekannt]';
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ const makeStory = (id: string, title: string, body: string | undefined = '<p>Bod
|
|||||||
items: [],
|
items: [],
|
||||||
author: {
|
author: {
|
||||||
id: 'u1',
|
id: 'u1',
|
||||||
email: 'marcel@example.com',
|
|
||||||
firstName: 'Marcel',
|
firstName: 'Marcel',
|
||||||
lastName: 'Raddatz',
|
lastName: 'Raddatz',
|
||||||
enabled: true,
|
enabled: true,
|
||||||
|
|||||||
@@ -3,21 +3,19 @@ import { formatAuthorName, formatAuthorDisplayName, formatPublishedAt } from './
|
|||||||
|
|
||||||
describe('formatAuthorName', () => {
|
describe('formatAuthorName', () => {
|
||||||
it('joins firstName and lastName with a space', () => {
|
it('joins firstName and lastName with a space', () => {
|
||||||
expect(formatAuthorName({ firstName: 'Anna', lastName: 'Schmidt', email: 'a@x' })).toBe(
|
expect(formatAuthorName({ firstName: 'Anna', lastName: 'Schmidt' })).toBe('Anna Schmidt');
|
||||||
'Anna Schmidt'
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns firstName alone when lastName is absent', () => {
|
it('returns firstName alone when lastName is absent', () => {
|
||||||
expect(formatAuthorName({ firstName: 'Anna', email: 'a@x' })).toBe('Anna');
|
expect(formatAuthorName({ firstName: 'Anna' })).toBe('Anna');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns lastName alone when firstName is absent', () => {
|
it('returns lastName alone when firstName is absent', () => {
|
||||||
expect(formatAuthorName({ lastName: 'Schmidt', email: 'a@x' })).toBe('Schmidt');
|
expect(formatAuthorName({ lastName: 'Schmidt' })).toBe('Schmidt');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('falls back to email when both names are absent', () => {
|
it('falls back to [Unbekannt] when both names are absent', () => {
|
||||||
expect(formatAuthorName({ email: 'fallback@example.com' })).toBe('fallback@example.com');
|
expect(formatAuthorName({})).toBe('[Unbekannt]');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns empty string for null input', () => {
|
it('returns empty string for null input', () => {
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
import { formatDate } from '$lib/shared/utils/date';
|
import { formatDate } from '$lib/shared/utils/date';
|
||||||
|
|
||||||
type AuthorSummary = { firstName?: string; lastName?: string; email: string };
|
type AuthorSummary = { firstName?: string; lastName?: string };
|
||||||
type AuthorView = { displayName: string };
|
type AuthorView = { displayName: string };
|
||||||
|
|
||||||
export function formatAuthorName(author: AuthorSummary | null | undefined): string {
|
export function formatAuthorName(author: AuthorSummary | null | undefined): string {
|
||||||
if (!author) return '';
|
if (!author) return '';
|
||||||
const full = [author.firstName, author.lastName].filter(Boolean).join(' ').trim();
|
const full = [author.firstName, author.lastName].filter(Boolean).join(' ').trim();
|
||||||
return full || author.email || '';
|
// Mirrors the server-side fallback in GeschichteService.toView — email is no longer exposed.
|
||||||
|
return full || '[Unbekannt]';
|
||||||
}
|
}
|
||||||
|
|
||||||
export function formatAuthorDisplayName(author: AuthorView | null | undefined): string {
|
export function formatAuthorDisplayName(author: AuthorView | null | undefined): string {
|
||||||
|
|||||||
@@ -76,7 +76,7 @@ describe('geschichten/[id] page', () => {
|
|||||||
await expect.element(page.getByText(/Anna Schmidt/)).toBeVisible();
|
await expect.element(page.getByText(/Anna Schmidt/)).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('falls back to author email when no name is set', async () => {
|
it('renders the server-computed author displayName verbatim', async () => {
|
||||||
render(GeschichtePage, {
|
render(GeschichtePage, {
|
||||||
context: new Map([[CONFIRM_KEY, createConfirmService()]]),
|
context: new Map([[CONFIRM_KEY, createConfirmService()]]),
|
||||||
props: {
|
props: {
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ const baseData = (overrides: Record<string, unknown> = {}) => ({
|
|||||||
title: string;
|
title: string;
|
||||||
body?: string;
|
body?: string;
|
||||||
publishedAt?: string;
|
publishedAt?: string;
|
||||||
author?: { firstName?: string; lastName?: string; email: string };
|
author?: { firstName?: string; lastName?: string };
|
||||||
}>,
|
}>,
|
||||||
personFilters: [] as { id?: string; displayName: string }[],
|
personFilters: [] as { id?: string; displayName: string }[],
|
||||||
documentFilter: null,
|
documentFilter: null,
|
||||||
@@ -127,7 +127,7 @@ describe('geschichten/+ page', () => {
|
|||||||
title: 'Reise nach Berlin',
|
title: 'Reise nach Berlin',
|
||||||
body: '<p>Im Jahr 1923...</p>',
|
body: '<p>Im Jahr 1923...</p>',
|
||||||
publishedAt: '2026-04-15T10:00:00Z',
|
publishedAt: '2026-04-15T10:00:00Z',
|
||||||
author: { firstName: 'Anna', lastName: 'Schmidt', email: 'a@x' }
|
author: { firstName: 'Anna', lastName: 'Schmidt' }
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
@@ -139,7 +139,7 @@ describe('geschichten/+ page', () => {
|
|||||||
.toBeVisible();
|
.toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('authorName falls back to email when first/last names are missing', async () => {
|
it('authorName falls back to [Unbekannt] when first/last names are missing', async () => {
|
||||||
render(GeschichtenListPage, {
|
render(GeschichtenListPage, {
|
||||||
props: {
|
props: {
|
||||||
data: baseData({
|
data: baseData({
|
||||||
@@ -147,14 +147,14 @@ describe('geschichten/+ page', () => {
|
|||||||
{
|
{
|
||||||
id: 'g1',
|
id: 'g1',
|
||||||
title: 'Anonym',
|
title: 'Anonym',
|
||||||
author: { email: 'anon@example.com' }
|
author: {}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(document.body.textContent).toContain('anon@example.com');
|
expect(document.body.textContent).toContain('[Unbekannt]');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('authorName renders empty when author is undefined', async () => {
|
it('authorName renders empty when author is undefined', async () => {
|
||||||
@@ -178,7 +178,7 @@ describe('geschichten/+ page', () => {
|
|||||||
{
|
{
|
||||||
id: 'g1',
|
id: 'g1',
|
||||||
title: 'Draft',
|
title: 'Draft',
|
||||||
author: { firstName: 'Anna', lastName: 'Schmidt', email: 'a@x' }
|
author: { firstName: 'Anna', lastName: 'Schmidt' }
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
@@ -202,7 +202,7 @@ describe('geschichten/+ page', () => {
|
|||||||
id: 'g1',
|
id: 'g1',
|
||||||
title: 'No Body',
|
title: 'No Body',
|
||||||
body: '',
|
body: '',
|
||||||
author: { firstName: 'Anna', lastName: 'Schmidt', email: 'a@x' }
|
author: { firstName: 'Anna', lastName: 'Schmidt' }
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user