Files
StefanandClaude Opus 5.5 4443bba63c
Playwright Tests / test (push) Canceled after 0s
fix(forms): never submit an invalid form; unlock password-change dialog
serializeObject() returns null for an invalid form, but most callers sent
the result anyway. Add $.fn.rejectInvalid (stop the spinner, let the
browser report the offending field) and use it at every call site.

Bugs fixed:
- Password change: on a mismatch the spinner kept running, and the custom
  validity on "npw" was never cleared, so the browser blocked every later
  submit even after the passwords matched. It is now cleared as soon as
  either password field is edited.
- sendForm: options.complete referenced an undefined jqXHR (ReferenceError)
  and an invalid form was posted with partial data.
- login.send: an invalid form threw a TypeError on qs.loginaccount.
- postXT({ form }): an invalid form was posted with empty data.
- Form dialogs without required fields had no "Bitte überprüfen Sie Ihre
  Eingaben" note, so a rejected submit showed nothing.

Hardening: null guards for the user, user-group, re-login, SMS password,
admin specialties and report-parameter submits.

Add e2e_tests/form-validation.cjs covering each path against the real
scripts; it fails on the previous code.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 18:11:05 +02:00

131 lines
6.8 KiB
JavaScript

// Run with: node --test e2e_tests/form-validation.cjs
// Every caller of serializeObject() must treat a null result (invalid form) as "do not submit":
// stop the loading indicator, keep the form open and let the browser show the offending field.
const { test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const { chromium } = require('playwright');
const root = path.resolve(__dirname, '..');
const read = (file) => fs.readFileSync(path.join(root, file), 'utf8');
test('invalid forms are rejected instead of submitted with partial data', async () => {
const browser = await chromium.launch({ headless: true,
channel: process.env.PLAYWRIGHT_CHANNEL || (process.platform === 'win32' ? 'msedge' : undefined) });
try {
const page = await browser.newPage();
const errors = [];
page.on('pageerror', (e) => errors.push(e.message));
await page.setContent('<html><body></body></html>');
await page.addScriptTag({ content: 'var $fis = {};' });
for (const file of [
'Fuchs/wwwroot/lib/jquery/jquery.js',
'Fuchs/js/intranet/oci_texts_basic_de.js',
'Fuchs/js/intranet/oci_basic.js',
'Fuchs/js/intranet/oci_mainbase.js',
'Fuchs/js/intranet/oci_main.js',
'Fuchs/js/intranet/fis_texts_gui_de.js',
'Fuchs/js/intranet/fis_main.js'
]) {
await page.addScriptTag({ content: read(file) });
}
const r = await page.evaluate(() => {
const out = {};
const posts = [];
const realPostXT = $ocms.postXT;
$ocms.postXT = function (o) { posts.push(o); if (o && typeof o.complete === 'function') { o.complete.call(this, { status: 200 }); } };
const ajax = [];
const realAjax = $.ajax;
$.ajax = (o) => { ajax.push(o); return $.Deferred().promise(); };
/* A required hidden field is never checked by the browser, so serializeObject is the only guard. */
const invalidForm = () => $('<form><input type="hidden" name="h" required value=""><input name="t" value="x"></form>').appendTo('body');
// rejectInvalid: stops the spinner and asks the browser to report the field
let reported = 0;
const f1 = invalidForm().ldng(1);
f1[0].reportValidity = () => { reported++; return false; };
f1.rejectInvalid();
out.rejectStopsLoading = !f1.hasClass('loading');
out.rejectReported = reported;
// postXT({ form }) sends nothing for an invalid form
realPostXT.call(document, { url: '/x', form: invalidForm() });
out.postXTAjaxCalls = ajax.length;
$.ajax = realAjax;
// sendForm: no post for an invalid form; a valid one posts and calls options.complete without throwing
posts.length = 0;
invalidForm().sendForm('/x');
out.sendFormInvalidPosts = posts.length;
let completed = null;
$('<form><input name="t" value="x"></form>').appendTo('body').sendForm('/x', null, { complete: (x) => { completed = x; } });
out.sendFormValidPosts = posts.length;
out.sendFormCompleteArg = completed;
// login.send: no TypeError on an invalid form, nothing posted
posts.length = 0;
const lf = invalidForm();
out.loginSendResult = $ocms.login.send.call(lf[0], { preventDefault() {} });
out.loginSendPosts = posts.length;
// dlg: the "check your input" note exists on every form dialog, even without required fields
const d = $ocms.dlg($$.dc('x').append('<input name="a">'), { form: true, confirm: () => {} });
out.noteInvalidWithoutRequired = d.ft.find('.note_invalid').length;
d.c.trigger('modal_close');
// dlgform (success mode): invalid form keeps the dialog open and never calls success
let succeeded = false;
const d2 = $ocms.dlgform([{ name: 'n', type: 'hidden', required: true, value: '' }], { success: () => { succeeded = true; } });
const form2 = $('body > .modal:last form');
form2.trigger('submit');
out.dlgformSucceeded = succeeded;
out.dlgformStillOpen = $('body > .modal').length;
out.dlgformLoading = form2.hasClass('loading');
out.dlgformInvalidNoteShown = form2.hasClass('invalid') && form2.find('.note_invalid').length;
form2.trigger('modal_close');
// password change: mismatch must not leave the spinner on or lock the form for good
window.confirm = () => true;
posts.length = 0;
$fis.resetPass();
const pf = $('body > .modal:last form');
pf.find('[name=opw]').val('old-password');
pf.find('[name=npw]').val('new-password-1');
pf.find('[name=npwc]').val('new-password-2');
pf.find('[name=code]').val('123456');
pf.trigger('submit');
const npw = pf.find('[name=npw]')[0];
out.mismatchPosts = posts.filter((p) => /changepassword/.test(p.url)).length;
out.mismatchLoading = pf.hasClass('loading');
out.mismatchMessage = npw.validationMessage;
pf.find('[name=npwc]').val('new-password-1').trigger('input');
out.afterEditValid = npw.checkValidity();
pf.trigger('submit');
out.matchPosts = posts.filter((p) => /changepassword/.test(p.url)).length;
return out;
});
assert.deepEqual(errors, []);
assert.equal(r.rejectStopsLoading, true);
assert.equal(r.rejectReported, 1);
assert.equal(r.postXTAjaxCalls, 0, 'postXT must not send an invalid form');
assert.equal(r.sendFormInvalidPosts, 0, 'sendForm must not send an invalid form');
assert.equal(r.sendFormValidPosts, 1);
assert.deepEqual(r.sendFormCompleteArg, { status: 200 }, 'sendForm passes the jqXHR to options.complete');
assert.equal(r.loginSendResult, false);
assert.equal(r.loginSendPosts, 0);
assert.equal(r.noteInvalidWithoutRequired, 1);
assert.equal(r.dlgformSucceeded, false);
assert.equal(r.dlgformStillOpen, 1);
assert.equal(r.dlgformLoading, false);
assert.ok(r.dlgformInvalidNoteShown);
assert.equal(r.mismatchPosts, 0);
assert.equal(r.mismatchLoading, false, 'spinner must stop on a password mismatch');
assert.equal(r.mismatchMessage, 'Die Passwörter stimmen nicht überein');
assert.equal(r.afterEditValid, true, 'editing a password field must clear the mismatch error');
assert.equal(r.matchPosts, 1);
} finally {
await browser.close();
}
});