fix: run the forced password change on the login path, not only token-in-URL

`initgui` runs the verification gates in two places: the token-in-URL branch and
the session-restore/login branch. The password gate went into the first only, so
it never fired for the case it exists for -- a seat signing in through the login
form with the credential its administrator issued. The account reached the
desktop and then failed at every gated call with no prompt, which is the state
the gate was written to prevent.

Caught by manual testing, not by any test: both chains looked right in
isolation. Added an invariant test over initgui's source asserting each gate
appears on both paths and each loops until cleared, which is the shape of the
mistake rather than the instance of it.

Falsified: removing the login-path gate fails "runs the forced password change
on both paths" with `expected 1 to be 2`.
This commit is contained in:
Juan Castro
2026-09-10 18:27:01 -04:00
parent efe6ef09bd
commit fa36f2e3cf
2 changed files with 58 additions and 0 deletions
+43
View File
@@ -0,0 +1,43 @@
import { describe, expect, it } from 'vitest';
import { readFileSync } from 'node:fs';
/**
* `initgui` runs the verification gates in two places: the token-in-URL path
* and the session-restore/login path. A gate added to one and not the other
* looks correct in review and is only visible by signing in the wrong way --
* which is how the forced password change shipped running on neither the login
* form nor a normal reload.
*/
const src = readFileSync(
new URL('./initgui.js', import.meta.url),
'utf8',
);
const countGates = (flag) =>
src.split(`whoami.${flag}`).length - 1;
describe('the verification gates in initgui', () => {
it('runs the email gate on both paths', () => {
expect(countGates('requires_email_confirmation')).toBe(2);
});
it('runs the card gate on both paths', () => {
expect(countGates('requires_card_verification')).toBe(2);
});
it('runs the forced password change on both paths', () => {
// A seat signs in through the login form; a gate only on the
// token-in-URL path never fires for it.
expect(countGates('requires_password_change')).toBe(2);
});
it('loops each gate until it is cleared, so none can be dismissed', () => {
// Every gate is a `do { ... } while (!x)`; a plain `if` would let the
// window close and the account through.
const opens = src.split('UIWindowPasswordChangeRequired({').length - 1;
expect(opens).toBe(2);
for (const chunk of src.split('UIWindowPasswordChangeRequired({').slice(1)) {
expect(chunk).toContain('} while (!changed);');
}
});
});
+15
View File
@@ -2078,6 +2078,21 @@ window.initgui = async function (options) {
});
} while (!is_verified);
}
// Last, matching assertVerifiedAccount's order.
if (whoami.requires_password_change) {
let changed;
do {
changed = await UIWindowPasswordChangeRequired({
show_close_button: false,
stay_on_top: true,
has_head: false,
window_options: {
is_draggable: false,
cover_page: window.is_embedded,
},
});
} while (!changed);
}
await window.update_auth_data(
whoami.token || window.auth_token,
whoami,