fix(usage): close the QA findings on opt-in product usage
A QA exploration of this branch against an isolated rig — own stub collector, SQLite and PostgreSQL — turned up one dead end and a set of signals and controls that did not hold up. This closes all of them. Rotating JWT_SECRET, the documented response to a suspected compromise, made the signing key unreadable. That was already named and documented, but it left no way out: the delete packet can never be signed, so the row stays deletion_pending forever, and enable() refuses because it is not `disabled`. An operator who rotated precisely because the secret was compromised cannot restore it, so the feature was bricked with no control left. POST /usage/abandon is offered only in that state; it drops the local identity and records the receipt as `collector-unconfirmed` rather than claiming a deletion that did not happen. Every failed delivery was retried on the next admin request, and /activity is open to any authenticated admin while the settings ticker fires it every five minutes per open tab — 30 activity calls against a rejecting collector produced 30 outbound requests. Migration 206 adds attempts/next_attempt_at and the unattended sender honours the gate; Retry and opt-out still send immediately, and the tab names the time of the next automatic attempt. Feedback, votes and portal sessions now share an installation-wide budget of 30/hour. They are the only endpoints whose effect is outbound traffic carrying operator-written free text, and the general limiter skips authenticated requests by design. Reading status and withdrawing stay unthrottled. gallery_image_protection was true on a bare install with no galleries: PicPeak ships default_protection_level='standard' and enable_devtools_protection=true, so it reported fleet-wide 100% and could never separate a decision from an untouched default. It now reads only what deviates from the shipped defaults, and the devtools flag is not read at all — being on by default, its only informative state is off, which is the opposite of what the key claims. Also: - the export receipt counted every packet and called the total "usage reports"; reports and participant operations are now counted and named separately - GET /usage/preview no longer persists the custom_css marker, so the transparency view stops changing what will be sent - the feedback route requires every field the packet schema requires, so an API caller gets the missing field named instead of a bare INVALID_PACKET from inside signing - the German strings for this feature use "Sie" throughout, matching the rest of the admin UI; the ignore hint says what ignoring will do rather than stating it as already true - the consent dialog returns focus to the control that opened it - the long buttons wrap instead of running off a 390px viewport - a deletion receipt is labelled as belonging to an earlier participation while a new one is active Regression tests cover each of these, including the delete packet's reuse of the last accepted sequence, which was an unwritten assumption about the collector rather than a defect.
This commit is contained in:
@@ -31,6 +31,7 @@ vi.mock('../../../services/productUsage.service', () => ({
|
||||
upgradeConsent: vi.fn(),
|
||||
disable: vi.fn(),
|
||||
retry: vi.fn(),
|
||||
abandon: vi.fn(),
|
||||
preview: vi.fn(),
|
||||
export: vi.fn(),
|
||||
preferences: vi.fn(),
|
||||
@@ -216,3 +217,90 @@ describe('product usage controls', () => {
|
||||
await waitFor(() => expect(service.retry).toHaveBeenCalled());
|
||||
});
|
||||
});
|
||||
|
||||
describe('a withdrawal that can never be signed', () => {
|
||||
const stuck: UsageStatus = {
|
||||
...status,
|
||||
status: 'deletion_pending',
|
||||
installation_id: 'a'.repeat(64),
|
||||
schema_version: 'usage.v2',
|
||||
last_error: 'SIGNING_KEY_UNREADABLE',
|
||||
can_abandon: true
|
||||
};
|
||||
|
||||
it('explains the dead end and offers the only remaining exit', async () => {
|
||||
vi.mocked(service.status).mockResolvedValue(stuck);
|
||||
vi.mocked(service.abandon).mockResolvedValue({ ...status });
|
||||
mount();
|
||||
|
||||
// The operator is told what happened before being offered the exit.
|
||||
await screen.findByText('productUsage.signingKeyUnreadable');
|
||||
await screen.findByText('productUsage.abandonExplanation');
|
||||
fireEvent.click(await screen.findByText('productUsage.abandon'));
|
||||
await waitFor(() => expect(service.abandon).toHaveBeenCalledTimes(1));
|
||||
});
|
||||
|
||||
it('does not offer it for a withdrawal that is merely undelivered', async () => {
|
||||
vi.mocked(service.status).mockResolvedValue({
|
||||
...stuck,
|
||||
last_error: 'DELIVERY_FAILED',
|
||||
can_abandon: false
|
||||
});
|
||||
mount();
|
||||
await screen.findByText('productUsage.deliveryProblem');
|
||||
expect(screen.queryByText('productUsage.abandon')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it('says the sender is waiting rather than leaving a bare error on screen', async () => {
|
||||
vi.mocked(service.status).mockResolvedValue({
|
||||
...status,
|
||||
status: 'active',
|
||||
schema_version: 'usage.v2',
|
||||
installation_id: 'a'.repeat(64),
|
||||
last_error: 'DELIVERY_FAILED',
|
||||
retry_after: Date.now() + 600000
|
||||
});
|
||||
mount();
|
||||
await screen.findByText('productUsage.retryScheduled');
|
||||
});
|
||||
|
||||
it('marks a deletion receipt as belonging to an earlier participation', async () => {
|
||||
const receipts = { last_deletion: { kind: 'deletion' } };
|
||||
vi.mocked(service.status).mockResolvedValue({
|
||||
...status,
|
||||
status: 'active',
|
||||
schema_version: 'usage.v2',
|
||||
installation_id: 'a'.repeat(64),
|
||||
privacy_receipts: receipts
|
||||
});
|
||||
mount();
|
||||
await screen.findByText('productUsage.auditPreviousParticipation');
|
||||
|
||||
cleanup();
|
||||
// Withdrawn: the same receipt now describes the participation just ended,
|
||||
// so the qualifier would be wrong.
|
||||
vi.mocked(service.status).mockResolvedValue({ ...status, privacy_receipts: receipts });
|
||||
mount();
|
||||
await screen.findByText('productUsage.auditTitle');
|
||||
expect(screen.queryByText('productUsage.auditPreviousParticipation')).toBeNull();
|
||||
});
|
||||
|
||||
it('returns focus to the control that opened the consent dialog', async () => {
|
||||
mount();
|
||||
const trigger = await screen.findByText('productUsage.review');
|
||||
trigger.focus();
|
||||
expect(document.activeElement).toBe(trigger);
|
||||
|
||||
fireEvent.click(trigger);
|
||||
await screen.findByText('productUsage.consentTitle');
|
||||
fireEvent.click(screen.getByText('productUsage.cancel'));
|
||||
|
||||
// Without the restore this lands on <body>, dropping a keyboard user back
|
||||
// to the top of the page (WCAG 2.4.3).
|
||||
await waitFor(() =>
|
||||
expect(document.activeElement).toBe(
|
||||
screen.getByText('productUsage.review')
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
@@ -36,6 +36,13 @@ const DISCLOSURE: {
|
||||
{ key: 'feedbackDisclosure', heading: 'sectionFeedback', Icon: MessageSquare }
|
||||
];
|
||||
|
||||
// `.btn` is whitespace-nowrap and `.btn-md` a fixed 2.5rem tall — right for
|
||||
// short labels, wrong for the sentence-length ones in this tab, which ran off
|
||||
// the card at 390px and then, once allowed to wrap, out of the fixed height.
|
||||
// h-auto lets the second line have somewhere to go; min-h keeps a one-line
|
||||
// button the same size as every other button beside it.
|
||||
const WRAPPING_BUTTON = 'max-w-full whitespace-normal text-left h-auto min-h-[2.5rem]';
|
||||
|
||||
function ConsentDialog({
|
||||
close,
|
||||
enable,
|
||||
@@ -53,6 +60,12 @@ function ConsentDialog({
|
||||
const ref = useRef<HTMLDialogElement>(null);
|
||||
const [checked, setChecked] = useState(false);
|
||||
useEffect(() => {
|
||||
// React unmounts this <dialog> on close rather than only closing it, so
|
||||
// the focus restoration showModal() normally performs has nothing left to
|
||||
// return to and focus drops to <body> — a keyboard user is thrown back to
|
||||
// the top of the page every time they cancel (WCAG 2.4.3). Remember the
|
||||
// opener and put focus back by hand.
|
||||
const opener = document.activeElement as HTMLElement | null;
|
||||
ref.current?.showModal();
|
||||
// showModal() focuses the first focusable descendant, which is the scroll
|
||||
// region below — so its focus ring was drawn for everyone the moment the
|
||||
@@ -61,6 +74,9 @@ function ConsentDialog({
|
||||
// Focusing the dialog puts the ring back where it belongs: only when
|
||||
// someone deliberately tabs to the region.
|
||||
ref.current?.focus();
|
||||
return () => {
|
||||
if (opener?.isConnected) opener.focus();
|
||||
};
|
||||
}, []);
|
||||
return (
|
||||
<dialog
|
||||
@@ -261,6 +277,46 @@ export default function ProductUsageTab() {
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
{data.retry_after && (
|
||||
// A paced install is waiting, not broken. Without this the tab shows
|
||||
// a delivery error and an idle Retry button, and nothing says the
|
||||
// sender is going to try again on its own.
|
||||
<p role="status" className="text-sm text-neutral-600 dark:text-neutral-400">
|
||||
{t('productUsage.retryScheduled', {
|
||||
time: new Date(data.retry_after).toLocaleTimeString()
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
{data.can_abandon && (
|
||||
// The one dead end the operator cannot retry out of. Offered only
|
||||
// here, and worded so nobody mistakes it for a confirmed deletion.
|
||||
<div className="rounded border border-amber-300 dark:border-amber-700 p-3 space-y-2">
|
||||
<p>{t('productUsage.abandonExplanation')}</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
className={WRAPPING_BUTTON}
|
||||
disabled={busy}
|
||||
onClick={async () => {
|
||||
if (
|
||||
await confirm({
|
||||
title: t('productUsage.abandon'),
|
||||
message: t('productUsage.abandonConfirm'),
|
||||
confirmLabel: t('productUsage.abandon'),
|
||||
variant: 'danger'
|
||||
})
|
||||
) {
|
||||
await run(async () => {
|
||||
await service.abandon();
|
||||
setPreview(null);
|
||||
setPortalUrl(null);
|
||||
});
|
||||
}
|
||||
}}
|
||||
>
|
||||
{t('productUsage.abandon')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{data.status === 'disabled' ? (
|
||||
<Button disabled={busy} onClick={() => setConsent(true)}>
|
||||
@@ -323,6 +379,19 @@ export default function ProductUsageTab() {
|
||||
{t('productUsage.auditTitle')}
|
||||
</h3>
|
||||
<p>{t('productUsage.auditDescription')}</p>
|
||||
{/* The receipts outlive the participation they describe: rejoining
|
||||
does not clear them, so an active install would otherwise show
|
||||
a bare "deletion confirmed" next to its own live participation
|
||||
and read as a contradiction. */}
|
||||
{active &&
|
||||
Boolean(
|
||||
data.privacy_receipts.last_deletion ||
|
||||
data.privacy_receipts.last_abandonment
|
||||
) && (
|
||||
<p className="text-sm text-neutral-600 dark:text-neutral-400">
|
||||
{t('productUsage.auditPreviousParticipation')}
|
||||
</p>
|
||||
)}
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() =>
|
||||
@@ -342,9 +411,14 @@ export default function ProductUsageTab() {
|
||||
<h3 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100">
|
||||
{t('productUsage.inspect')}
|
||||
</h3>
|
||||
{/* `.btn` sets whitespace-nowrap, and these labels are long
|
||||
sentences in both locales — at 390px two of them ran past the
|
||||
card and their text was simply cut off. Allowed to wrap and
|
||||
capped at the container width instead. */}
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<Button
|
||||
variant="outline"
|
||||
className={WRAPPING_BUTTON}
|
||||
disabled={busy}
|
||||
onClick={() =>
|
||||
run(async () => setPreview(await service.preview()))
|
||||
@@ -354,6 +428,7 @@ export default function ProductUsageTab() {
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className={WRAPPING_BUTTON}
|
||||
disabled={busy || !data.last_packet}
|
||||
onClick={() => setPreview(data.last_packet)}
|
||||
>
|
||||
@@ -361,6 +436,7 @@ export default function ProductUsageTab() {
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className={WRAPPING_BUTTON}
|
||||
disabled={busy}
|
||||
onClick={() =>
|
||||
run(async () => download(await service.export()))
|
||||
@@ -370,6 +446,7 @@ export default function ProductUsageTab() {
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className={WRAPPING_BUTTON}
|
||||
disabled={busy || Boolean(data.pending_action)}
|
||||
onClick={() =>
|
||||
run(async () => {
|
||||
|
||||
@@ -1252,8 +1252,8 @@
|
||||
"de": "Bildschutz aktiviert"
|
||||
},
|
||||
"configured": {
|
||||
"en": "Enabled in applicable gallery/global configuration; only existence across the installation, never gallery IDs or counts.",
|
||||
"de": "In der betreffenden Galerie-/Globalkonfiguration aktiviert; nur installationsweite Existenz, niemals Galeriekennungen oder Anzahlen."
|
||||
"en": "Enabled beyond the shipped defaults — a stronger protection level, canvas rendering, or right-click disabled — globally or on at least one gallery; only existence across the installation, never gallery IDs or counts.",
|
||||
"de": "Über die Auslieferungsvorgaben hinaus aktiviert — höhere Schutzstufe, Canvas-Rendering oder deaktivierter Rechtsklick — global oder in mindestens einer Galerie; nur installationsweite Existenz, niemals Galeriekennungen oder Anzahlen."
|
||||
},
|
||||
"used": null
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user