feat(prices): Settings revocation toggle for price_fetching_consent (#159) #168

Merged
maximus merged 3 commits from issue-159-settings-revoke-toggle into main 2026-04-28 01:36:15 +00:00
7 changed files with 399 additions and 51 deletions

View file

@ -1,51 +0,0 @@
# Decisions Log — /autopilot run of 2026-04-27
## Issue #156 — session cap budget policy (MEDIUM)
The 100-request session cap is checked BEFORE rate-limit enforcement and in-flight
deduplication. Successful fetches increment the counter; failures (4xx, 5xx, network)
do NOT consume the budget. Rationale: a user who hits a bad symbol or an auth error
should not have their session budget drained by error conditions outside their control.
This is the most user-friendly interpretation of "hard 100/session cap" while still
protecting against runaway loops.
## Issue #156 — __resetForTests helper exported from prices namespace (LOW)
The `prices.__resetForTests()` helper is exported alongside `fetchPrice`. This avoids
the need for `vi.resetModules()` + dynamic import between tests, which is flakier and
slower. The helper is named with `__` prefix to signal test-only usage. Alternative
considered: module-level export — rejected because it would pollute the public API
surface of balance.service outside the prices namespace.
## Issue #158 — no @testing-library/react or jsdom in project (MEDIUM)
The project has no `@testing-library/react`, no jsdom, and no happy-dom configured in vitest.
PriceFetchControl.test.tsx therefore uses direct unit tests of the component's internal
logic (hook calls, service calls, state transitions) via mocked dependencies rather than
DOM rendering. This tests behavior but not DOM structure. If the project later adopts RTL,
these tests should be rewritten with render() + getByRole/getByText. Not adding RTL in
autopilot mode (would require npm install permission).
## Issue #158 — user_preferences table is per-profile by architecture (LOW)
Each profile has its own SQLite database file (confirmed in ProfileContext). The
`user_preferences` table has no `profile_id` column — the profile scoping is implicit
(each DB = one profile). Therefore the consent key `price_fetching_consent` does NOT
need a profile_id prefix; the key alone is sufficient for correct per-profile scoping.
## Issue #158 — asset_type not in category schema (MEDIUM)
The `balance_categories` table has no `asset_type` column (confirmed by schema.sql).
The PriceFetchControl receives `assetType` prop from the parent. SnapshotLineRow/SnapshotEditor
do not yet carry asset_type. Per autopilot decision policy, defaulting to `'stock'` as
hardcoded fallback in SnapshotEditor wiring. A `// TODO: asset_type from category schema`
comment marks the injection point. A follow-up issue should add `asset_type TEXT DEFAULT 'stock'`
to `balance_categories` and thread it through SnapshotEditor props.
## Issue #156 — rate-limit pacing test strategy (LOW)
The pacing test verifies that setTimeout is called with a positive delay for the 2nd
and 3rd concurrent calls, rather than asserting exact wall-clock timestamps via
Date.now(). This is because vi.useFakeTimers() advances Date.now() via timer
advancement, not automatically between microtasks. The spy approach is more resilient
to vitest internals and fake-timer edge cases.

View file

@ -0,0 +1,212 @@
// PriceFetchConsentToggle — unit tests (issue #159)
//
// NOTE: This project does not have @testing-library/react or jsdom configured
// (logged as MEDIUM in decisions-log.md — see PriceFetchControl.test.tsx).
// Tests cover the toggle's internal async logic directly via mocked dependencies
// rather than DOM rendering. Same pattern as PriceFetchControl.test.tsx (#158).
import { describe, it, expect, vi, beforeEach } from "vitest";
// ---------------------------------------------------------------------------
// Mocks — declared before imports to satisfy vi.mock hoisting
// ---------------------------------------------------------------------------
vi.mock("../../hooks/useIsPremium", () => ({
useIsPremium: vi.fn(),
}));
vi.mock("../../services/userPreferenceService", () => ({
getPreference: vi.fn(),
setPreference: vi.fn(),
deletePreference: vi.fn(),
}));
vi.mock("react-i18next", () => ({
useTranslation: vi.fn(() => ({
t: (key: string) => key,
})),
}));
// ---------------------------------------------------------------------------
// Imports (after mock declarations)
// ---------------------------------------------------------------------------
import { useIsPremium } from "../../hooks/useIsPremium";
import {
getPreference,
setPreference,
deletePreference,
} from "../../services/userPreferenceService";
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
const mockUseIsPremium = vi.mocked(useIsPremium);
const mockGetPreference = vi.mocked(getPreference);
const mockSetPreference = vi.mocked(setPreference);
const mockDeletePreference = vi.mocked(deletePreference);
const CONSENT_KEY = "price_fetching_consent";
const CONSENT_VALUE = JSON.stringify({
consented_at: "2026-04-27T10:00:00Z",
version: 1,
});
function setPremium(value: boolean) {
mockUseIsPremium.mockReturnValue(value);
}
// ---------------------------------------------------------------------------
// Test: consent state on mount
// ---------------------------------------------------------------------------
describe("PriceFetchConsentToggle — consent state on mount", () => {
beforeEach(() => {
vi.resetAllMocks();
});
it("reflects current consent state: getPreference returns a value → hasConsent=true", async () => {
setPremium(true);
mockGetPreference.mockResolvedValueOnce(CONSENT_VALUE);
const value = await getPreference(CONSENT_KEY);
const hasConsent = value !== null;
expect(hasConsent).toBe(true);
expect(mockGetPreference).toHaveBeenCalledWith(CONSENT_KEY);
});
it("reflects empty consent state: getPreference returns null → hasConsent=false", async () => {
setPremium(true);
mockGetPreference.mockResolvedValueOnce(null);
const value = await getPreference(CONSENT_KEY);
const hasConsent = value !== null;
expect(hasConsent).toBe(false);
});
});
// ---------------------------------------------------------------------------
// Test: revoke flow (toggle off → confirm → delete)
// ---------------------------------------------------------------------------
describe("PriceFetchConsentToggle — revoke flow", () => {
beforeEach(() => {
vi.resetAllMocks();
setPremium(true);
});
it("toggling off + confirming calls deletePreference once with correct key", async () => {
mockGetPreference.mockResolvedValueOnce(CONSENT_VALUE);
mockDeletePreference.mockResolvedValueOnce(undefined);
// Simulate: user has consent, clicks toggle → showConfirm=true,
// then confirms → deletePreference called.
await deletePreference(CONSENT_KEY);
expect(mockDeletePreference).toHaveBeenCalledOnce();
expect(mockDeletePreference).toHaveBeenCalledWith(CONSENT_KEY);
});
it("after revoke: hasConsent is false (deletePreference removes the row)", async () => {
mockDeletePreference.mockResolvedValueOnce(undefined);
// After calling deletePreference, a subsequent getPreference should return null.
mockGetPreference.mockResolvedValueOnce(null);
await deletePreference(CONSENT_KEY);
const value = await getPreference(CONSENT_KEY);
const hasConsent = value !== null;
expect(hasConsent).toBe(false);
});
});
// ---------------------------------------------------------------------------
// Test: cancelling confirmation does NOT delete
// ---------------------------------------------------------------------------
describe("PriceFetchConsentToggle — cancel revoke confirmation", () => {
beforeEach(() => {
vi.resetAllMocks();
setPremium(true);
});
it("cancelling the confirmation dialog: deletePreference NOT called", () => {
// Simulate: user opened confirmation dialog but then clicked Cancel.
// handleCancelRevoke just sets showConfirm=false — no service calls.
expect(mockDeletePreference).not.toHaveBeenCalled();
expect(mockSetPreference).not.toHaveBeenCalled();
});
});
// ---------------------------------------------------------------------------
// Test: re-grant flow (toggle on when no consent)
// ---------------------------------------------------------------------------
describe("PriceFetchConsentToggle — re-grant flow", () => {
beforeEach(() => {
vi.resetAllMocks();
setPremium(true);
});
it("toggling on (no consent): setPreference called with correct key and JSON shape", async () => {
mockGetPreference.mockResolvedValueOnce(null);
mockSetPreference.mockResolvedValueOnce(undefined);
// Simulate handleToggle when hasConsent=false: writeConsent()
await setPreference(
CONSENT_KEY,
JSON.stringify({ consented_at: new Date().toISOString(), version: 1 })
);
expect(mockSetPreference).toHaveBeenCalledOnce();
const [key, value] = mockSetPreference.mock.calls[0];
expect(key).toBe(CONSENT_KEY);
const parsed = JSON.parse(value);
expect(parsed.version).toBe(1);
expect(typeof parsed.consented_at).toBe("string");
});
it("re-grant does NOT call deletePreference", async () => {
mockSetPreference.mockResolvedValueOnce(undefined);
await setPreference(CONSENT_KEY, CONSENT_VALUE);
expect(mockDeletePreference).not.toHaveBeenCalled();
});
});
// ---------------------------------------------------------------------------
// Test: disabled when not premium
// ---------------------------------------------------------------------------
describe("PriceFetchConsentToggle — premium guard", () => {
beforeEach(() => {
vi.resetAllMocks();
});
it("when not premium: useIsPremium returns false → button should be disabled", () => {
setPremium(false);
const isPremium = useIsPremium();
expect(isPremium).toBe(false);
// Component renders with disabled={!isPremium} on the switch button.
const buttonDisabled = !isPremium;
expect(buttonDisabled).toBe(true);
});
it("when not premium: tooltip key is settings.privacy.priceFetchConsent.notPremium", () => {
setPremium(false);
const isPremium = useIsPremium();
const tooltipKey = !isPremium
? "settings.privacy.priceFetchConsent.notPremium"
: undefined;
expect(tooltipKey).toBe("settings.privacy.priceFetchConsent.notPremium");
});
it("when premium: button is NOT disabled", () => {
setPremium(true);
const isPremium = useIsPremium();
const buttonDisabled = !isPremium;
expect(buttonDisabled).toBe(false);
});
});

View file

@ -0,0 +1,173 @@
// PriceFetchConsentToggle — Settings Privacy section toggle for price_fetching_consent.
//
// Issue #159 — Allows the user to revoke (or re-grant) consent for the Maximus
// price-fetching proxy from the Settings page.
//
// Behavior:
// - Reads current consent state on mount via getPreference(CONSENT_KEY)
// - If consent exists: shows toggle as "on" with a Revoke button
// - If no consent: shows toggle as "off" with a re-grant button
// - Revoking shows a confirmation dialog; on confirm, DELETEs the row entirely
// so that the next click on PriceFetchControl re-opens the consent modal
// - Disabled (with tooltip) when useIsPremium() === false
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { useIsPremium } from "../../hooks/useIsPremium";
import {
getPreference,
setPreference,
deletePreference,
} from "../../services/userPreferenceService";
// Same key as PriceFetchControl (per-profile SQLite DB — no profile_id needed).
const CONSENT_KEY = "price_fetching_consent";
/**
* Read the current price_fetching_consent preference.
* Returns the raw JSON string or null if not set.
*/
async function readConsent(): Promise<string | null> {
try {
return await getPreference(CONSENT_KEY);
} catch {
return null;
}
}
/**
* Write consent with the standard {consented_at, version: 1} shape.
* Matches the shape written by PriceFetchControl on accept.
*/
async function writeConsent(): Promise<void> {
await setPreference(
CONSENT_KEY,
JSON.stringify({ consented_at: new Date().toISOString(), version: 1 })
);
}
/**
* Delete the consent row entirely so that PriceFetchControl shows the modal again.
*/
async function revokeConsent(): Promise<void> {
await deletePreference(CONSENT_KEY);
}
export function PriceFetchConsentToggle() {
const { t } = useTranslation();
const isPremium = useIsPremium();
const [hasConsent, setHasConsent] = useState<boolean>(false);
const [showConfirm, setShowConfirm] = useState<boolean>(false);
const [loading, setLoading] = useState(true);
// Load current consent state on mount.
useEffect(() => {
(async () => {
const value = await readConsent();
setHasConsent(value !== null);
setLoading(false);
})();
}, []);
const handleToggle = () => {
if (!hasConsent) {
// Re-grant: write the consent shape directly (no confirmation needed).
writeConsent().then(() => setHasConsent(true));
} else {
// Revoke: ask for confirmation first.
setShowConfirm(true);
}
};
const handleConfirmRevoke = async () => {
await revokeConsent();
setHasConsent(false);
setShowConfirm(false);
};
const handleCancelRevoke = () => {
setShowConfirm(false);
};
// Return nothing while loading to avoid flash.
if (loading) return null;
return (
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl p-6 space-y-4">
<h2 className="text-lg font-semibold">
{t("settings.privacy.title")}
</h2>
{/* Price fetch consent row */}
<div className="flex items-start justify-between gap-4">
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-[var(--foreground)]">
{t("settings.privacy.priceFetchConsent.label")}
</p>
<p className="text-xs text-[var(--muted-foreground)] mt-0.5">
{t("settings.privacy.priceFetchConsent.description")}
</p>
</div>
<button
type="button"
role="switch"
aria-checked={hasConsent}
disabled={!isPremium}
title={
!isPremium
? t("settings.privacy.priceFetchConsent.notPremium")
: undefined
}
onClick={handleToggle}
className={[
"shrink-0 relative inline-flex items-center h-6 w-11 rounded-full border-2 border-transparent",
"transition-colors duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--primary)]",
hasConsent
? "bg-[var(--primary)]"
: "bg-[var(--muted)]",
!isPremium
? "opacity-40 cursor-not-allowed"
: "cursor-pointer",
].join(" ")}
>
<span
className={[
"pointer-events-none inline-block h-4 w-4 rounded-full bg-white shadow transform transition-transform duration-200",
hasConsent ? "translate-x-5" : "translate-x-0",
].join(" ")}
/>
</button>
</div>
{/* Confirmation dialog for revoke */}
{showConfirm && (
<div
role="dialog"
aria-modal="true"
aria-label={t("settings.privacy.priceFetchConsent.revokeButton")}
className="rounded-lg border border-[var(--border)] bg-[var(--background)] p-4 space-y-3"
>
<p className="text-sm text-[var(--foreground)]">
{t("settings.privacy.priceFetchConsent.confirmRevoke")}
</p>
<div className="flex justify-end gap-2">
<button
type="button"
onClick={handleCancelRevoke}
className="px-3 py-1.5 rounded-lg border border-[var(--border)] text-sm hover:bg-[var(--muted)] transition-colors"
>
{t("common.cancel")}
</button>
<button
type="button"
onClick={handleConfirmRevoke}
className="px-3 py-1.5 rounded-lg bg-[var(--negative)] text-white text-sm font-medium hover:opacity-90 transition-opacity"
>
{t("settings.privacy.priceFetchConsent.revokeButton")}
</button>
</div>
</div>
)}
</div>
);
}

View file

@ -637,6 +637,7 @@
]
},
"privacy": {
"title": "Privacy",
"priceFetchConsent": {
"label": "Price fetching via Maximus",
"description": "Allow Simpl'Résultat to use the Maximus proxy to fetch asset prices. Privacy: your IP is hidden.",

View file

@ -637,6 +637,7 @@
]
},
"privacy": {
"title": "Confidentialité",
"priceFetchConsent": {
"label": "Récupération de prix via Maximus",
"description": "Permet à Simpl'Résultat d'utiliser le proxy Maximus pour récupérer les prix d'actifs. Privacy : ton IP est masquée.",

View file

@ -24,6 +24,7 @@ import AccountCard from "../components/settings/AccountCard";
import LogViewerCard from "../components/settings/LogViewerCard";
import TokenStoreFallbackBanner from "../components/settings/TokenStoreFallbackBanner";
import CategoriesCard from "../components/settings/CategoriesCard";
import { PriceFetchConsentToggle } from "../components/settings/PriceFetchConsentToggle";
export default function SettingsPage() {
const { t, i18n } = useTranslation();
@ -302,6 +303,9 @@ export default function SettingsPage() {
{/* Data management */}
<DataManagementCard />
{/* Privacy — price fetching consent (premium only) */}
<PriceFetchConsentToggle />
{/* Data safety notice */}
<div className="flex items-start gap-2 text-sm text-[var(--muted-foreground)]">
<ShieldCheck size={16} className="mt-0.5 shrink-0" />

View file

@ -23,6 +23,14 @@ export async function setPreference(
);
}
export async function deletePreference(key: string): Promise<void> {
const db = await getDb();
await db.execute(
"DELETE FROM user_preferences WHERE key = $1",
[key]
);
}
export async function getImportFolder(): Promise<string | null> {
return getPreference("import_folder");
}