mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 17:41:09 +02:00
test(feedback): pixel-avatar + redact privacy-boundary; mark plan SHIPPED
Tests:
- packages/feedback/src/avatar.test.ts — 10 unit tests (determinism,
mirror-symmetry, color contrast, padding-resilience, pseudonym-
integration, density-sanity).
- services/mana-analytics/src/services/feedback-redact.test.ts —
9 privacy-boundary tests verifying:
* anonymous path NEVER includes realName, even when author opted in
* auth path NEVER includes realName when author opted OUT
* realName only when (opted-in AND auth-path) — both gates required
* userId / deviceInfo / voteCount stripped from output
Plan-Doc:
- docs/plans/feedback-rewards-and-identity.md status → shipped (3.A,
3.B, 3.C, 3.F live; 3.D, 3.E open) mit Commit-Hashes.
Service-Layer minor: REWARD-const + redact als __TEST__-Export
publik gemacht (nur fürs Testen, kein Verhaltensänderung).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
98a9bc4dc5
commit
246c94374f
4 changed files with 221 additions and 15 deletions
|
|
@ -1,5 +1,5 @@
|
|||
---
|
||||
status: draft
|
||||
status: shipped (3.A, 3.B, 3.C, 3.F live 2026-04-27 — 3.D, 3.E open)
|
||||
owner: till
|
||||
created: 2026-04-27
|
||||
parent: docs/plans/feedback-hub-public.md
|
||||
|
|
@ -419,19 +419,25 @@ nirgends entfernt. Drop alle Referenzen.
|
|||
|
||||
## Reihenfolge
|
||||
|
||||
1. **Phase 3.A** *(jetzt)* — Credits-Loop
|
||||
- mana-credits: `/internal/credits/grant` + `grantCredits()` + transaction-type 'grant'
|
||||
- mana-analytics: `grantCreditsForSubmit` + `grantShipBonus` + Founder-Whitelist + Rate-Limit
|
||||
- mana-web: UI-Toast "+5 Mana" im FeedbackQuickModal + Onboarding-Wish-Confirm
|
||||
- 0003 SQL-Migration: `feedback_grant_log` mini-table + transaction.type-enum-Erweiterung
|
||||
2. **Phase 3.F** *(direkt danach, 2h)* — Legacy-Cleanup
|
||||
- vote/unvote/toggleVote/getPublicFeedback raus
|
||||
- voteCount Spalte raus
|
||||
- /feedback Modul + Route + ListView raus
|
||||
3. **Phase 3.B** — Loop-Closure (Status-Notify + my-wishes + Digest)
|
||||
4. **Phase 3.C** — Identität (Klarname + Pixel-Avatar + Karma + Eulen-Profil)
|
||||
5. **Phase 3.D** — Engagement (Trending, Compass, Quests, Match-Existing)
|
||||
6. **Phase 3.E** — Smart Triggers (Frust-Detect, Voice, Companion)
|
||||
1. **Phase 3.A** ✅ shipped 2026-04-27 — Credits-Loop
|
||||
- dbe24acfc + eecf64c1c (server + UI), e89958e9c (port-fix)
|
||||
- mana-credits transaction-type 'grant', `/internal/credits/grant` idempotent
|
||||
- mana-analytics +5/+500/+25 mit Founder-Whitelist + Rate-Limit
|
||||
- Reward-Chip in FeedbackQuickModal + Onboarding-Wish-Confirm
|
||||
2. **Phase 3.F** ✅ shipped 2026-04-27 — Legacy-Cleanup
|
||||
- eecf64c1c — vote/unvote/toggleVote shims raus, voteCount drop, /feedback Modul + Route gelöscht
|
||||
3. **Phase 3.B** ✅ shipped 2026-04-27 — Loop-Closure
|
||||
- 3a18a5e50 — feedback_notifications-Tabelle, Status-Notify + AdminResponse-Notify, Toast-Polling
|
||||
- /profile/my-wishes mit 3 Tabs (Eigene/Unterstützt/Inbox)
|
||||
- Migration 0004
|
||||
4. **Phase 3.C** ✅ shipped 2026-04-27 — Identität
|
||||
- ee5bb2871 + 1b30c3655 — Pixel-Identicon-Avatar (deterministic SVG), Klarname-Toggle
|
||||
mit Settings-Section, Karma-System mit Bronze/Silver/Gold/Platin-Tiers,
|
||||
/community/eule/[hash] Public-Profil mit SSR
|
||||
- Migration `services/mana-auth/sql/008_community_identity.sql`
|
||||
- Cross-schema-JOIN auth.users in mana-analytics
|
||||
5. **Phase 3.D** ⏸ offen — Engagement (Trending, Compass, Quests, Match-Existing)
|
||||
6. **Phase 3.E** ⏸ offen — Smart Triggers (Frust-Detect, Voice, Companion)
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
92
packages/feedback/src/avatar.test.ts
Normal file
92
packages/feedback/src/avatar.test.ts
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
import { describe, expect, it } from 'bun:test';
|
||||
import { generateAvatarSvg, __TEST__ } from './avatar';
|
||||
import {
|
||||
createDisplayHash,
|
||||
generateDisplayName,
|
||||
} from '../../../services/mana-analytics/src/lib/pseudonym';
|
||||
|
||||
describe('avatar', () => {
|
||||
const SAMPLE_HASH = 'a3f4e1d2c5b6a798e0f1d2c3b4a5968778695a4b3c2d1e0f1a2b3c4d5e6f7a8b';
|
||||
|
||||
it('is deterministic — same hash always yields same SVG', () => {
|
||||
expect(generateAvatarSvg(SAMPLE_HASH)).toBe(generateAvatarSvg(SAMPLE_HASH));
|
||||
});
|
||||
|
||||
it('produces different SVGs for different hashes', () => {
|
||||
const a = generateAvatarSvg(SAMPLE_HASH);
|
||||
const b = generateAvatarSvg('b3f4e1d2c5b6a798e0f1d2c3b4a5968778695a4b3c2d1e0f1a2b3c4d5e6f7a8b');
|
||||
expect(a).not.toBe(b);
|
||||
});
|
||||
|
||||
it('returns a self-contained SVG with viewBox + fill', () => {
|
||||
const svg = generateAvatarSvg(SAMPLE_HASH);
|
||||
expect(svg.startsWith('<svg')).toBe(true);
|
||||
expect(svg).toContain('viewBox="0 0 100 100"');
|
||||
expect(svg).toContain('fill="hsl(');
|
||||
expect(svg.endsWith('</svg>')).toBe(true);
|
||||
});
|
||||
|
||||
it('background and foreground colors differ', () => {
|
||||
const svg = generateAvatarSvg(SAMPLE_HASH);
|
||||
const colors = [...svg.matchAll(/fill="(hsl\([^"]+\))"/g)].map((m) => m[1]);
|
||||
expect(colors.length).toBeGreaterThanOrEqual(2);
|
||||
expect(colors[0]).not.toBe(colors[1]);
|
||||
});
|
||||
|
||||
it('cells are left-mirrored (col[i] === col[GRID-1-i] for all rows in left half)', () => {
|
||||
const { cells } = __TEST__.rendering(SAMPLE_HASH);
|
||||
const GRID = __TEST__.GRID;
|
||||
expect(cells).toHaveLength(GRID);
|
||||
for (let row = 0; row < GRID; row++) {
|
||||
for (let col = 0; col < 2; col++) {
|
||||
expect(cells[row][col]).toBe(cells[row][GRID - 1 - col]);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('survives short / malformed hashes via padding', () => {
|
||||
expect(() => generateAvatarSvg('abc')).not.toThrow();
|
||||
expect(() => generateAvatarSvg('')).not.toThrow();
|
||||
expect(() => generateAvatarSvg('not-hex-at-all-just-random-text')).not.toThrow();
|
||||
});
|
||||
|
||||
it('integrates with the pseudonym generator (same userId → same avatar AND name)', () => {
|
||||
const hash = createDisplayHash('user-42', 'fixed-secret');
|
||||
const name1 = generateDisplayName(hash);
|
||||
const name2 = generateDisplayName(hash);
|
||||
const svg1 = generateAvatarSvg(hash);
|
||||
const svg2 = generateAvatarSvg(hash);
|
||||
expect(name1).toBe(name2);
|
||||
expect(svg1).toBe(svg2);
|
||||
});
|
||||
|
||||
it('cell density is reasonable (not all-on, not all-off)', () => {
|
||||
// Sample 50 different hashes — none should produce a totally blank
|
||||
// or totally full grid (would mean the bit-extraction is broken).
|
||||
let allOnCount = 0;
|
||||
let allOffCount = 0;
|
||||
const GRID = __TEST__.GRID;
|
||||
for (let i = 0; i < 50; i++) {
|
||||
const hash = createDisplayHash(`user-${i}`, 'secret');
|
||||
const { cells } = __TEST__.rendering(hash);
|
||||
const total = GRID * GRID;
|
||||
let on = 0;
|
||||
for (const row of cells) for (const c of row) if (c) on++;
|
||||
if (on === total) allOnCount++;
|
||||
if (on === 0) allOffCount++;
|
||||
}
|
||||
expect(allOnCount).toBe(0);
|
||||
expect(allOffCount).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('avatar __TEST__ exports', () => {
|
||||
it('exports the GRID constant', () => {
|
||||
expect(__TEST__.GRID).toBe(5);
|
||||
});
|
||||
|
||||
it('exports hexToBytes that handles non-hex chars', () => {
|
||||
const bytes = __TEST__.hexToBytes('xx-aa-bb-cc-yy');
|
||||
expect(bytes).toEqual([0xaa, 0xbb, 0xcc]);
|
||||
});
|
||||
});
|
||||
104
services/mana-analytics/src/services/feedback-redact.test.ts
Normal file
104
services/mana-analytics/src/services/feedback-redact.test.ts
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
/**
|
||||
* Privacy-boundary tests für die `redact()`-Funktion.
|
||||
*
|
||||
* Kritisch: anonymous public endpoint darf NIE einen Klarnamen
|
||||
* ausliefern, auch wenn der User-Account `communityShowRealName=true`
|
||||
* gesetzt hat. Diese Tests sind das Sicherheitsnetz für die ›Public
|
||||
* bleibt anonym‹-Garantie der Community-Surface.
|
||||
*/
|
||||
import { describe, expect, it } from 'bun:test';
|
||||
import { __TEST__ } from './feedback';
|
||||
|
||||
const { redact } = __TEST__;
|
||||
|
||||
const baseFeedback = {
|
||||
id: 'feedback-1',
|
||||
userId: 'user-42',
|
||||
appId: 'mana',
|
||||
title: 'Test',
|
||||
feedbackText: 'I would like X please',
|
||||
category: 'feature' as const,
|
||||
status: 'submitted' as const,
|
||||
isPublic: true,
|
||||
adminResponse: null,
|
||||
voteCount: 0 as never,
|
||||
displayHash: 'abc123def456',
|
||||
displayName: 'Wachsame Eule #4528',
|
||||
moduleContext: null,
|
||||
parentId: null,
|
||||
reactions: { '👍': 3, '❤️': 1 },
|
||||
score: 4,
|
||||
deviceInfo: { ip: '1.2.3.4' } as never,
|
||||
createdAt: new Date('2026-04-27T10:00:00Z'),
|
||||
updatedAt: new Date('2026-04-27T10:00:00Z'),
|
||||
} as Parameters<typeof redact>[0];
|
||||
|
||||
const optedInAuthor = {
|
||||
name: 'Till Schäfer',
|
||||
communityShowRealName: true,
|
||||
communityKarma: 47,
|
||||
};
|
||||
|
||||
const optedOutAuthor = {
|
||||
name: 'Till Schäfer',
|
||||
communityShowRealName: false,
|
||||
communityKarma: 47,
|
||||
};
|
||||
|
||||
describe('redact (privacy-boundary)', () => {
|
||||
it('NEVER leaks realName on the anonymous path even when author opted in', () => {
|
||||
const item = redact(baseFeedback, optedInAuthor, { includeRealName: false });
|
||||
expect(item.realName).toBeUndefined();
|
||||
});
|
||||
|
||||
it('NEVER leaks realName on the auth path when author opted OUT', () => {
|
||||
const item = redact(baseFeedback, optedOutAuthor, { includeRealName: true });
|
||||
expect(item.realName).toBeUndefined();
|
||||
});
|
||||
|
||||
it('exposes realName ONLY when author opted-in AND auth-path requested it', () => {
|
||||
const item = redact(baseFeedback, optedInAuthor, { includeRealName: true });
|
||||
expect(item.realName).toBe('Till Schäfer');
|
||||
});
|
||||
|
||||
it('strips userId, deviceInfo, voteCount from output', () => {
|
||||
const item = redact(baseFeedback, optedInAuthor, { includeRealName: true });
|
||||
expect((item as Record<string, unknown>).userId).toBeUndefined();
|
||||
expect((item as Record<string, unknown>).deviceInfo).toBeUndefined();
|
||||
expect((item as Record<string, unknown>).voteCount).toBeUndefined();
|
||||
});
|
||||
|
||||
it('exposes karma always — it is public information', () => {
|
||||
const item1 = redact(baseFeedback, optedInAuthor, { includeRealName: false });
|
||||
const item2 = redact(baseFeedback, optedOutAuthor, { includeRealName: false });
|
||||
expect(item1.karma).toBe(47);
|
||||
expect(item2.karma).toBe(47);
|
||||
});
|
||||
|
||||
it('falls back to karma=0 when author row is null (deleted user)', () => {
|
||||
const item = redact(baseFeedback, null, { includeRealName: true });
|
||||
expect(item.karma).toBe(0);
|
||||
expect(item.realName).toBeUndefined();
|
||||
});
|
||||
|
||||
it('exposes displayHash + displayName on every output (needed for avatar + profile-URL)', () => {
|
||||
const anonymous = redact(baseFeedback, optedInAuthor, { includeRealName: false });
|
||||
const auth = redact(baseFeedback, optedInAuthor, { includeRealName: true });
|
||||
expect(anonymous.displayHash).toBe('abc123def456');
|
||||
expect(auth.displayHash).toBe('abc123def456');
|
||||
expect(anonymous.displayName).toBe('Wachsame Eule #4528');
|
||||
expect(auth.displayName).toBe('Wachsame Eule #4528');
|
||||
});
|
||||
|
||||
it('default options strip realName (defensive default)', () => {
|
||||
// When no options passed, behaves like the anonymous path.
|
||||
const item = redact(baseFeedback, optedInAuthor);
|
||||
expect(item.realName).toBeUndefined();
|
||||
});
|
||||
|
||||
it('falls back to displayName="Anonym" when missing', () => {
|
||||
const itemWithoutName = { ...baseFeedback, displayName: null };
|
||||
const item = redact(itemWithoutName as never, null, { includeRealName: false });
|
||||
expect(item.displayName).toBe('Anonym');
|
||||
});
|
||||
});
|
||||
|
|
@ -775,4 +775,8 @@ function redact(
|
|||
return item;
|
||||
}
|
||||
|
||||
export { ALLOWED_EMOJIS, REACTION_WEIGHTS };
|
||||
export { ALLOWED_EMOJIS, REACTION_WEIGHTS, REWARD };
|
||||
// Test-only exports — used by privacy-boundary tests to verify the
|
||||
// anonymous public path NEVER includes realName even when the user
|
||||
// opted in. Still safe to expose since redact is a pure-function.
|
||||
export const __TEST__ = { redact };
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue