fix(onboarding): skip the D9 transaction once its flag is set
`initializeGuidedPath` opened BEGIN, read the flag, then COMMIT at every mount of the provider: start-up, and every profile switch, creation or deletion. While a `withTransaction` callback runs, `db.ts` lets every other query bypass its lock, the overlap its serialization exists to prevent. The flag is now read first, outside any transaction, through the queued connection: once D9 ran, an opening costs that one read. Without it, the transaction re-reads the flag before writing, so two first loads still cannot apply the rule twice (CWE-362). Pinned: no transaction and no write with the flag set; the flag counts only for "1", on both reads; a first load that sets the flag between the two reads wins; the in-transaction reads still follow BEGIN. Review of #396 (PR #402). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
a58f8b98bc
commit
696b83fb10
2 changed files with 68 additions and 9 deletions
|
|
@ -260,9 +260,19 @@ describe("initializeGuidedPath — rule D9 (#396)", () => {
|
||||||
["INSERT", ONBOARDING_GUIDED_INITIALIZED_KEY],
|
["INSERT", ONBOARDING_GUIDED_INITIALIZED_KEY],
|
||||||
["COMMIT", undefined],
|
["COMMIT", undefined],
|
||||||
]);
|
]);
|
||||||
// The flag and the transaction count are read after BEGIN.
|
// The flag is read once outside any transaction, then again after BEGIN,
|
||||||
const firstSelect = fake.db.select.mock.invocationCallOrder[0];
|
// with the transaction count.
|
||||||
expect(firstSelect).toBeGreaterThan(fake.db.execute.mock.invocationCallOrder[0]);
|
const begin = fake.db.execute.mock.invocationCallOrder[0];
|
||||||
|
const reads = fake.db.select.mock.calls.map(([sql], i) => ({
|
||||||
|
sql: String(sql),
|
||||||
|
order: fake.db.select.mock.invocationCallOrder[i],
|
||||||
|
}));
|
||||||
|
const flagReads = reads.filter((r) => r.sql.includes("FROM user_preferences WHERE key = $1"));
|
||||||
|
expect(flagReads).toHaveLength(2);
|
||||||
|
expect(flagReads[0].order).toBeLessThan(begin);
|
||||||
|
expect(flagReads[1].order).toBeGreaterThan(begin);
|
||||||
|
const count = reads.find((r) => r.sql.includes("COUNT(*) AS transactions FROM transactions"))!;
|
||||||
|
expect(count.order).toBeGreaterThan(begin);
|
||||||
expect(fake.prefs()).toEqual({
|
expect(fake.prefs()).toEqual({
|
||||||
[ONBOARDING_CHECKLIST_HIDDEN_KEY]: "1",
|
[ONBOARDING_CHECKLIST_HIDDEN_KEY]: "1",
|
||||||
[ONBOARDING_GUIDED_INITIALIZED_KEY]: "1",
|
[ONBOARDING_GUIDED_INITIALIZED_KEY]: "1",
|
||||||
|
|
@ -278,17 +288,57 @@ describe("initializeGuidedPath — rule D9 (#396)", () => {
|
||||||
expect(logInfo).not.toHaveBeenCalled();
|
expect(logInfo).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("with the flag already set: no write at all, even with transactions", async () => {
|
it("with the flag already set: no transaction and no write, even with transactions", async () => {
|
||||||
const fake = useFakeDb({
|
const fake = useFakeDb({
|
||||||
transactions: 3,
|
transactions: 3,
|
||||||
prefs: { [ONBOARDING_GUIDED_INITIALIZED_KEY]: "1" },
|
prefs: { [ONBOARDING_GUIDED_INITIALIZED_KEY]: "1" },
|
||||||
});
|
});
|
||||||
await expect(initializeGuidedPath()).resolves.toEqual({ hidden: false });
|
await expect(initializeGuidedPath()).resolves.toEqual({ hidden: false });
|
||||||
expect(fake.statements).toEqual(["BEGIN", "COMMIT"]);
|
expect(withTransaction).not.toHaveBeenCalled();
|
||||||
|
expect(fake.statements).toEqual([]);
|
||||||
|
expect(fake.db.select).toHaveBeenCalledTimes(1);
|
||||||
expect(fake.prefs()).toEqual({ [ONBOARDING_GUIDED_INITIALIZED_KEY]: "1" });
|
expect(fake.prefs()).toEqual({ [ONBOARDING_GUIDED_INITIALIZED_KEY]: "1" });
|
||||||
expect(logInfo).not.toHaveBeenCalled();
|
expect(logInfo).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('counts the flag as set only for the value "1", outside and inside the transaction', async () => {
|
||||||
|
for (const value of ["0", "true", ""]) {
|
||||||
|
vi.mocked(withTransaction).mockClear();
|
||||||
|
const fake = useFakeDb({
|
||||||
|
transactions: 3,
|
||||||
|
prefs: { [ONBOARDING_GUIDED_INITIALIZED_KEY]: value },
|
||||||
|
});
|
||||||
|
await expect(initializeGuidedPath()).resolves.toEqual({ hidden: true });
|
||||||
|
expect(withTransaction).toHaveBeenCalledTimes(1);
|
||||||
|
expect(fake.statements).toEqual([
|
||||||
|
"BEGIN",
|
||||||
|
"INSERT INTO user_preferences",
|
||||||
|
"INSERT INTO user_preferences",
|
||||||
|
"COMMIT",
|
||||||
|
]);
|
||||||
|
expect(fake.prefs()[ONBOARDING_GUIDED_INITIALIZED_KEY]).toBe("1");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("re-reads the flag inside the transaction: a first load that set it in between wins (CWE-362)", async () => {
|
||||||
|
const fake = useFakeDb({ transactions: 3 });
|
||||||
|
const read = fake.db.select.getMockImplementation()!;
|
||||||
|
let flagReads = 0;
|
||||||
|
fake.db.select.mockImplementation(async (sql: string, params?: unknown[]) => {
|
||||||
|
const rows = await read(sql, params);
|
||||||
|
if (params?.[0] === ONBOARDING_GUIDED_INITIALIZED_KEY && ++flagReads === 1) {
|
||||||
|
// Another opening runs D9 to its end right after this read.
|
||||||
|
fake.prefs()[ONBOARDING_GUIDED_INITIALIZED_KEY] = "1";
|
||||||
|
}
|
||||||
|
return rows;
|
||||||
|
});
|
||||||
|
await expect(initializeGuidedPath()).resolves.toEqual({ hidden: false });
|
||||||
|
expect(flagReads).toBe(2);
|
||||||
|
expect(fake.statements).toEqual(["BEGIN", "COMMIT"]);
|
||||||
|
expect(fake.prefs()[ONBOARDING_CHECKLIST_HIDDEN_KEY]).toBeUndefined();
|
||||||
|
expect(logInfo).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
it("a profile shown again after D9 is not hidden a second time", async () => {
|
it("a profile shown again after D9 is not hidden a second time", async () => {
|
||||||
// D9 hid it, the user showed it again from Settings → Users.
|
// D9 hid it, the user showed it again from Settings → Users.
|
||||||
const fake = useFakeDb({ transactions: 3 });
|
const fake = useFakeDb({ transactions: 3 });
|
||||||
|
|
|
||||||
|
|
@ -123,15 +123,24 @@ export async function getOnboardingSnapshot(): Promise<OnboardingSnapshot> {
|
||||||
* Rule D9, once per profile: at the first opening under this version, a
|
* Rule D9, once per profile: at the first opening under this version, a
|
||||||
* profile that already has transactions gets the path hidden (same preference
|
* profile that already has transactions gets the path hidden (same preference
|
||||||
* as "Hide", so Settings → Users shows it again); an empty or new profile sees
|
* as "Hide", so Settings → Users shows it again); an empty or new profile sees
|
||||||
* it. Runs as one transaction under the DB lock, flag re-read inside it, so two
|
* it.
|
||||||
* concurrent first loads cannot apply it twice (CWE-362): the hidden preference
|
*
|
||||||
* is written first, then the flag, then COMMIT; any failure rolls both back and
|
* The flag is read first, outside any transaction: once D9 ran, every later
|
||||||
* leaves the flag unset, so the next opening tries again.
|
* opening costs that one queued read and opens no transaction (while a
|
||||||
|
* `withTransaction` callback runs, `db.ts` lets every other query bypass its
|
||||||
|
* lock). Without the flag, D9 runs as one transaction under the DB lock, flag
|
||||||
|
* re-read inside it, so two concurrent first loads cannot apply it twice
|
||||||
|
* (CWE-362): the hidden preference is written first, then the flag, then
|
||||||
|
* COMMIT; any failure rolls both back and leaves the flag unset, so the next
|
||||||
|
* opening tries again. Only the value "1" counts as set, on both reads.
|
||||||
*
|
*
|
||||||
* Returns `{ hidden: true }` only when this call hid the path, which it also
|
* Returns `{ hidden: true }` only when this call hid the path, which it also
|
||||||
* writes to the in-app log. Not announced on the data channel.
|
* writes to the in-app log. Not announced on the data channel.
|
||||||
*/
|
*/
|
||||||
export async function initializeGuidedPath(): Promise<{ hidden: boolean }> {
|
export async function initializeGuidedPath(): Promise<{ hidden: boolean }> {
|
||||||
|
if ((await getPreference(ONBOARDING_GUIDED_INITIALIZED_KEY)) === "1") {
|
||||||
|
return { hidden: false };
|
||||||
|
}
|
||||||
const hidden = await withTransaction(async (db) => {
|
const hidden = await withTransaction(async (db) => {
|
||||||
await db.execute("BEGIN");
|
await db.execute("BEGIN");
|
||||||
try {
|
try {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue