diff --git a/changelog.d/auth-users-navigation.md b/changelog.d/auth-users-navigation.md new file mode 100644 index 00000000..90d50713 --- /dev/null +++ b/changelog.d/auth-users-navigation.md @@ -0,0 +1,22 @@ +### Changed +- Admin navigation groups auth collections under Users and ordinary collections under Data collections. Account links preserve the selected auth collection, including legacy record URLs, and collection settings link directly to account management. + +### Features +- User create/edit forms now support schema-defined profile fields, validation, JSON and rich text editors, relations, multiple selections, and file uploads alongside credentials. + +### Fixes +- Clearing a single select or relation during a multipart record update now clears the field instead of validating an empty option or target ID. A single select that declares `""` as an option keeps it as a value and is cleared with `null`. +- Admin account password validation uses UTF-8 byte length, matching the server policy for non-ASCII passwords. +- Admin account forms accept internationalized email addresses and keep Manage users links on the stored collection until a rename is saved. +- Float fields normalize consistently across JSON and multipart saves, including finite JSON numeric tokens beyond the integer range, with the same bounds and nonfinite-value validation. +- File changes preserve untouched nullable text, date, editor, URL, and email profile fields while keeping intentional empty-string clears. +- Large finite numeric min/max schema options survive API parsing and schema JSON round trips; string and nonfinite bounds remain invalid. +- Valid custom field names such as constructor, valueOf, and toString use their own values in forms, validation messages, JSON payloads, and file-removal state. +- Account credentials and profile controls have associated labels, including JSON, rich text, relations, and file actions, so assistive technology can identify each field. +- Focusing and leaving unchanged rich text preserves null values; deliberate clears still save. Account creation also honors an explicitly configured zero password minimum. +- Account edits submit only changed fields, preserving concurrent profile updates and allowing credential resets on legacy rows with missing required profile values. Blank usernames on creation remain null. +- Users reuses the sidebar collection metadata; one guarded refresh owns loading and errors, and failed lookups do not block later collection routes. Record drawers load their current collection definition when opened, including fields changed in another tab or through the API. +- Rich-text blur persists cleanup of unsafe stored HTML, and authentication settings preserve a zero password minimum when saved and reopened. +- Base-collection record drawers retain ordinary email, username, verified and token_epoch fields, including the email inputs in built-in mail collections. +- Authentication settings validate an integer password minimum from 0 through 255; invalid collection API values return a field error instead of silently falling back. +- Account drawers also refresh their collection definition on open, honoring required profile fields and password policy changed while Users remains open. diff --git a/docs/framework.md b/docs/framework.md index 0343f69f..2e97c834 100644 --- a/docs/framework.md +++ b/docs/framework.md @@ -2408,11 +2408,33 @@ realtime health, below), **Features** (flags & experiments, below), and **Settings** (raw KV, above). It ships as browser-native ES modules — no build step — and every asset is served with a CRC32 `ETag`. -**Users** (`/_/#/users`) manages both superusers and auth-collection users: list -and search (filter-based) either, create/edit/delete records, an admin password -reset (superuser `PATCH`), and a read-only OAuth-providers panel per auth -collection. It composes the existing collections/records/auth REST API — no new -endpoints ship for it. Per-device sessions are not yet exposed here. +The sidebar groups ordinary collections under **Data collections** and auth +collections under **Users**, using each collection's type rather than its name. +**Collection settings** lists every collection definition and offers collection +creation, fields, API rules, and authentication settings. + +**Users** (`/_/#/users/`) manages both superusers and auth-collection +accounts. Selecting an auth collection opens its account list, and the collection +stays selected across reloads and browser history. Existing +`/_/#/collections//records` links for auth collections redirect to +the same collection's Users page. The unscoped `/_/#/users` link chooses the +`users` auth collection when present, otherwise the first auth collection. + +Create/edit forms include email, username, password, and custom schema fields, +including required profile fields, JSON, rich text, relations, multiple +selections, and file uploads. Creation requires a password meeting the +collection's minimum length, as required by the records REST API; leaving the +password blank during editing preserves it. An entered password performs an +admin reset (superuser `PATCH`). Editing submits only changed profile fields +and credentials, plus file uploads/removals, preserving unrelated concurrent +updates. A blank username on creation is omitted, matching API-created accounts. +Server-managed credential and system fields remain protected. Unchecked boolean profile fields save as false, and relation +editors retain existing selections beyond the initially loaded target list. +Users links to its collection's settings, and auth collection +settings links back with **Manage users →**. Search matches email or username, +and a read-only OAuth-providers panel shows the collection's providers. +It composes the existing collections/records/auth REST API — no new endpoints +ship for it. Per-device sessions are not yet exposed here. **Files** (`/_/#/files`) covers per-collection file-field browsing: a read-only storage-backend strip (local disk vs S3, from the new superuser `GET diff --git a/src/admin/app.js b/src/admin/app.js index 252f28f8..27be5235 100644 --- a/src/admin/app.js +++ b/src/admin/app.js @@ -1,4 +1,4 @@ -import { html, render, useState, useEffect } from '/_/assets/preact.js'; +import { html, render, useState, useEffect, useRef, useCallback } from '/_/assets/preact.js'; import { API } from '/_/assets/lib/api.js'; import { go } from '/_/assets/lib/ui.js'; import { RecordsTable, SchemaEditor } from '/_/assets/views/collections.js'; @@ -25,7 +25,7 @@ function parseRoute(hash) { if (seg[0] === 'login') return { name: 'login' }; if (seg[0] === 'settings') return { name: 'settings' }; if (seg[0] === 'features') return { name: 'features' }; - if (seg[0] === 'users') return { name: 'users' }; + if (seg[0] === 'users') return { name: 'users', col: seg[1] ? decodeURIComponent(seg[1]) : '' }; if (seg[0] === 'email') return { name: 'email' }; if (seg[0] === 'files') return { name: 'files' }; if (seg[0] === 'logs') return { name: 'logs' }; @@ -120,6 +120,41 @@ function App() { function Shell({ route }) { const [cols, setCols] = useState(null); const [err, setErr] = useState(''); + const collectionRequest = useRef({ id: 0, pending: false }); + const missingCollections = useRef(new Set()); + const failedLookup = useRef(null); + const currentCol = useRef(route.col); + currentCol.current = route.col; + const [collectionsLoading, setCollectionsLoading] = useState(true); + const refreshCollections = useCallback(async missingName => { + const request = ++collectionRequest.current.id; + collectionRequest.current.pending = true; + setErr(''); + setCollectionsLoading(true); + try { + const cs = await API.collections(); + if (request !== collectionRequest.current.id) return; + if (missingName) missingCollections.current.add(missingName); + setCols(cs); + } catch (x) { + if (request !== collectionRequest.current.id) return; + if (missingName) { + // The admin has moved on; a late failure belongs to the route it was for. + if (currentCol.current !== missingName) return; + failedLookup.current = missingName; + } + setErr(x.data?.message || 'Failed to load collections'); + } finally { + if (request === collectionRequest.current.id) { + collectionRequest.current.pending = false; + setCollectionsLoading(false); + } + } + }, []); + // Mount supplies the initial list; later Users entries refresh it once. + useEffect(() => { + if (cols == null || route.name === 'users') refreshCollections(); + }, [route.name]); const [backend, setBackend] = useState(''); const [collapsed, setCollapsed] = useState(localStorage.getItem('zb_sidebar') === '1'); // `/api/analytics/events` is comptime-gated (R2-3 lean build) — unmounted (404) unless the @@ -127,7 +162,6 @@ function Shell({ route }) { // appear when the feature actually exists, rather than showing a tab that 404s. const [analyticsEnabled, setAnalyticsEnabled] = useState(false); useEffect(() => { - API.collections().then(setCols).catch(x => setErr((x.data && x.data.message) || 'Failed to load collections')); // Read-only backend badge; degrade silently if /health is unreachable. API.health().then(h => setBackend(h && h.backend ? h.backend : '')).catch(() => {}); let active = true; @@ -137,36 +171,70 @@ function Shell({ route }) { function toggle() { const v = !collapsed; setCollapsed(v); localStorage.setItem('zb_sidebar', v ? '1' : '0'); } async function logout() { try { await API.logout(); } catch (_) {} go('#/login'); } + // A failed lookup waits for the next navigation rather than retrying in a loop. + // Its error belongs to that route; a failed workspace refresh keeps showing. + useEffect(() => { + if (failedLookup.current == null) return; + failedLookup.current = null; + setErr(''); + }, [route.name, route.col]); + // Cache completed lookups per missing name. Requests stay useful across route + // changes; only a newer refresh can supersede their result. + useEffect(() => { + if (collectionsLoading || collectionRequest.current.pending || !route.col || (route.name === 'schema' && route.col === '__new__') || + cols?.some(c => c.name === route.col) || missingCollections.current.has(route.col) || failedLookup.current === route.col) return; + refreshCollections(route.col); + }, [route.name, route.col, cols, collectionsLoading, refreshCollections]); + const activeCol = route.col; + const metadataError = route.name === 'records' && cols?.some(c => c.name === route.col) ? '' : err; + const legacyAuth = route.name === 'records' && cols?.find(c => c.name === route.col && c.type === 'auth'); + useEffect(() => { + if (legacyAuth) location.replace('#/users/' + encodeURIComponent(legacyAuth.name)); + }, [legacyAuth?.name]); + function collectionLinks(type) { + const collections = (cols || []).filter(c => type === 'auth' ? c.type === 'auth' : c.type !== 'auth'); + return collections.map(c => html` + ${c.name}${collapsed ? c.name[0] : ''}`); + } return html`
zigbase${backend ? html`${backend === 'postgres' ? 'Postgres' : 'SQLite'}` : ''}
-
Collections
- ${cols == null ? html`
…
` : - cols.map(c => html` - ${c.name} ${c.type !== 'base' ? html`(${c.type})` : ''}${collapsed ? c.name[0] : ''}`)} - ${err && html`
${err}
`} +
+
Data collections
+ ${cols == null ? html`
…
` : collectionLinks('base')} + ${cols && !cols.some(c => c.type !== 'auth') && html`
No data collections
`} +
+
+ Users${collapsed ? '👤' : ''} + ${collectionLinks('auth')} +
+ ${metadataError && html`
${metadataError}
`}
- 👤 Users 📧 Email 📁 Files ${analyticsEnabled && html`📊 Logs`} 🚩 Features ⚙ Settings - ⚙ Collections + ⚙ Collection settings ⎋ Logout
- ${route.name === 'users' ? html`<${UsersView}/>` + ${route.name === 'users' ? html`<${UsersView} key=${route.col} collection=${route.col} collections=${collectionsLoading ? null : cols} collectionsError=${err}/>` : route.name === 'email' ? html`<${EmailView}/>` : route.name === 'files' ? html`<${FilesView}/>` : route.name === 'logs' && analyticsEnabled ? html`<${LogsView}/>` : route.name === 'features' ? html`<${FeaturesView}/>` : route.name === 'settings' ? html`<${SettingsView}/>` - : route.name === 'records' ? html`<${RecordsTable} col=${route.col}/>` - : route.name === 'schema' ? html`<${SchemaEditor} name=${route.col}/>` - : html`

Collections

`} + : route.name === 'records' ? ((cols == null && !err) || legacyAuth ? html`
…
` : html`<${RecordsTable} key=${route.col} col=${route.col} collection=${cols?.find(c => c.name === route.col)} metadataError=${metadataError}/>` ) + : route.name === 'schema' ? html`<${SchemaEditor} key=${route.col} name=${route.col}/>` + : html`

Collection settings

+ ${(cols || []).map(c => html`
+ ${c.name} ${c.type} + ${c.type === 'auth' && html`Manage users →`} +
`)}
`}
`; } diff --git a/src/admin/lib/api.js b/src/admin/lib/api.js index 2028b36d..752bb356 100644 --- a/src/admin/lib/api.js +++ b/src/admin/lib/api.js @@ -23,8 +23,8 @@ export const API = { logout: () => api('POST', '/collections/_superusers/auth-logout'), collections: () => api('GET', '/collections').then(r => r.items), records: (col, q) => api('GET', `/collections/${encodeURIComponent(col)}/records?${q}`), - createUser: (col, body) => api('POST', `/collections/${encodeURIComponent(col)}/records`, body), - updateUser: (col, id, body) => api('PATCH', `/collections/${encodeURIComponent(col)}/records/${encodeURIComponent(id)}`, body), + createUser: (col, body, isForm = false) => api('POST', `/collections/${encodeURIComponent(col)}/records`, body, isForm), + updateUser: (col, id, body, isForm = false) => api('PATCH', `/collections/${encodeURIComponent(col)}/records/${encodeURIComponent(id)}`, body, isForm), deleteUser: (col, id) => api('DELETE', `/collections/${encodeURIComponent(col)}/records/${encodeURIComponent(id)}`), oauthProviders: (col) => api('GET', `/collections/${encodeURIComponent(col)}/auth/oauth2/providers`), createCollection: (payload) => api('POST', '/collections', payload), diff --git a/src/admin/lib/editor.js b/src/admin/lib/editor.js index 5b9dae7a..193f8e2f 100644 --- a/src/admin/lib/editor.js +++ b/src/admin/lib/editor.js @@ -5,7 +5,7 @@ import { html, useState, useEffect, useLayoutEffect, useRef } from '/_/assets/pr // Monospace textarea with live validation, a Format button, and tab-to-indent. // Invalid JSON never propagates upward (onChange only fires on valid/empty), and // reports validity via onValidity so the record drawer can block Save. -export function JsonEditor({ value, onChange, onValidity, name }) { +export function JsonEditor({ value, onChange, onValidity, name, id, labelledBy, required }) { const initial = value == null ? '' : JSON.stringify(value, null, 2); const [text, setText] = useState(initial); const [error, setError] = useState(''); @@ -41,10 +41,10 @@ export function JsonEditor({ value, onChange, onValidity, name }) { return html`
-
- + ${error && html`Invalid JSON: ${error}`}
`; @@ -132,7 +132,7 @@ export function stripTags(dirty) { // ── Rich-text WYSIWYG editor (editor fields) ───────────────────────────────── // A contenteditable surface + toolbar. Stores sanitized HTML. execCommand is // deprecated but universally supported and the standard lean-editor mechanism. -export function RichTextEditor({ value, onChange, name }) { +export function RichTextEditor({ value, onChange, name, id, labelledBy, required }) { const ref = useRef(null); const last = useRef(''); // last HTML we emitted, to break the parent-echo loop @@ -163,6 +163,9 @@ export function RichTextEditor({ value, onChange, name }) { // (no caret churn); when it does fire, park the caret at the end so editing // continues sensibly. if (clean !== el.innerHTML) { el.innerHTML = clean; placeCaretEnd(el); } + // Preserve untouched nulls, but persist cleanup of unsafe API-stored HTML + // even when the visible surface already matches its sanitized form. + if (clean === last.current && clean === (value ?? '')) return; last.current = clean; onChange(clean); } @@ -199,12 +202,12 @@ export function RichTextEditor({ value, onChange, name }) { } const btn = (action, label, on, title) => html` - `; return html`
-
+
${btn('bold', html`B`, () => cmd('bold'), 'Bold')} ${btn('italic', html`I`, () => cmd('italic'), 'Italic')} ${btn('underline', html`U`, () => cmd('underline'), 'Underline')} @@ -219,7 +222,7 @@ export function RichTextEditor({ value, onChange, name }) { ${btn('link', '🔗', link, 'Link')} ${btn('clear', '⨯', clear, 'Clear formatting')}
-
`; } diff --git a/src/admin/views/collections.js b/src/admin/views/collections.js index e3e308b9..2228f469 100644 --- a/src/admin/views/collections.js +++ b/src/admin/views/collections.js @@ -3,15 +3,14 @@ import { api, API } from '/_/assets/lib/api.js'; import { useLiveCollection } from '/_/assets/lib/ui.js'; import { RichTextEditor, JsonEditor, stripTags } from '/_/assets/lib/editor.js'; -export function RecordsTable({ col }) { +export function RecordsTable({ col, collection, metadataError }) { const [data, setData] = useState(null); const [err, setErr] = useState(''); const [page, setPage] = useState(1); const [filter, setFilter] = useState(''); const [sort, setSort] = useState(''); const [editing, setEditing] = useState(undefined); // undefined=closed, null=new, record=edit - const [schema, setSchema] = useState([]); - useEffect(() => { API.collections().then(cs => { const c = cs.find(x => x.name === col); setSchema(c ? c.schema : []); }); }, [col]); + const schema = collection?.schema || []; const perPage = 30; function load() { setErr(''); @@ -44,13 +43,13 @@ export function RecordsTable({ col }) {
setFilter(e.target.value)} onKeyDown=${e => { if (e.key === 'Enter') { setPage(1); load(); } }}/> - +
- ${err && html`
${err}
`} + ${(err || metadataError) && html`
${err || metadataError}
`} ${columns.map(c => html``)} - ${items.map(r => html` setEditing(r)}>${columns.map(c => html``)}`)} + ${items.map(r => html` setEditing(r)}>${columns.map(c => html``)}`)}
setSort(sort === c ? '-' + c : c)} style="cursor:pointer">${c}${sort === c ? ' ▲' : sort === '-' + c ? ' ▼' : ''}
${fmt(r[c], typeOf[c])}
${fmt(ownValue(r, c), ownValue(typeOf, c))}
${data && data.totalItems === 0 && html`

No records.

`} @@ -59,7 +58,7 @@ export function RecordsTable({ col }) { Page ${data ? data.page : page} / ${data ? data.totalPages || 1 : 1} - ${editing !== undefined && html`<${RecordDrawer} col=${col} record=${editing} schema=${schema} onClose=${() => setEditing(undefined)} onSaved=${() => { setEditing(undefined); load(); }}/>`} + ${editing !== undefined && html`<${RecordDrawer} col=${col} record=${editing} onClose=${() => setEditing(undefined)} onSaved=${() => { setEditing(undefined); load(); }}/>`} `; } @@ -106,6 +105,10 @@ export function SchemaEditor({ name }) { async function save() { setErr(''); setFieldErrs({}); + if (col.type === 'auth' && !validPasswordMinimum(col.options?.auth?.minPasswordLength ?? 8)) { + setErr('Minimum password length must be an integer from 0 to 255.'); + return; + } const payload = { name: col.name, type: col.type, fields: col.name === '_superusers' ? [] : col.schema, options: col.options }; if (col.name !== '_superusers') for (const r of RULES) payload[r] = col[r]; try { @@ -113,7 +116,7 @@ export function SchemaEditor({ name }) { // Do a full page load of the saved collection's records view so the sidebar list refreshes. // location.assign() to a URL with a *new path query* forces a document reload (not just a // hashchange), avoiding the race between setting the hash and a separate location.reload(). - location.assign('/_/?saved=' + Date.now() + '#/collections/' + encodeURIComponent(saved.name) + '/records'); + location.assign('/_/?saved=' + Date.now() + (saved.type === 'auth' ? '#/users/' + encodeURIComponent(saved.name) : '#/collections/' + encodeURIComponent(saved.name) + '/records')); } catch (x) { setErr((x.data && x.data.message) || 'Save failed'); if (x.data && x.data.data) setFieldErrs(x.data.data); @@ -126,13 +129,18 @@ export function SchemaEditor({ name }) { } const isAuth = col.type === 'auth'; + // Draft names can temporarily equal a reserved name while the user types. + // Protect only fields whose persisted identity belonged to the original schema. + const originalSchema = (allCols.find(c => c.name === name) || {}).schema || []; + const protectedFieldIds = new Set(originalSchema.filter(f => f.id && isSystemField(f.name, isAuth)).map(f => f.id)); return html`

${isNew ? 'New collection' : 'Edit ' + col.name}

+ ${!isNew && isAuth && html`Manage users →`} ${!isNew && !col.system && html``} - +
${err && html`
${err}
`}
@@ -144,13 +152,13 @@ export function SchemaEditor({ name }) { ${isNew && html`
`} ${col.schema.map((f, i) => html`
- setF(i, { name: e.target.value })} disabled=${isSystemField(f.name)}/> - + setF(i, { name: e.target.value })} disabled=${protectedFieldIds.has(f.id)}/> + ${fieldOptions(f, i, setOpt, allCols)} - ${!isSystemField(f.name) && html``} - ${fieldErrs[f.name] && html`
${fieldErrs[f.name].message}
`} + ${!protectedFieldIds.has(f.id) && html``} + ${ownValue(fieldErrs, f.name) && html`
${ownValue(fieldErrs, f.name).message}
`}
`)}
`} @@ -204,7 +212,16 @@ export function SchemaEditor({ name }) {
`; } -function isSystemField(n) { return ['id','created','updated','email','username','passwordHash','tokenKey','verified'].includes(n); } +// Keep the auth entries aligned with schema.authSystemFields; request-only +// credentials and legacy externalAuths must never become profile controls either. +export const AUTH_SYSTEM_FIELDS = ['email', 'username', 'passwordHash', 'tokenKey', 'verified', 'token_epoch']; +const SYSTEM_FIELDS = new Set(['id', 'created', 'updated', 'passwordHash', 'tokenKey'].map(n => n.toLowerCase())); +const AUTH_FIELD_NAMES = new Set(AUTH_SYSTEM_FIELDS.map(n => n.toLowerCase())); +const ACCOUNT_INPUT_FIELDS = new Set(['password', 'passwordConfirm', 'oldPassword', 'externalAuths']); +export function isSystemField(name, account = false) { + return SYSTEM_FIELDS.has(name.toLowerCase()) || + (account === true && (AUTH_FIELD_NAMES.has(name.toLowerCase()) || ACCOUNT_INPUT_FIELDS.has(name))); +} function fieldOptions(f, i, setOpt, allCols) { const o = f.options || {}; @@ -215,6 +232,10 @@ function fieldOptions(f, i, setOpt, allCols) { return ''; } +function validPasswordMinimum(value) { + return typeof value === 'number' && Number.isInteger(value) && value >= 0 && value <= 255; +} + const OAUTH_PRESETS = ['google','github','microsoft','discord','generic']; function AuthTab({ col, setCol }) { @@ -228,7 +249,9 @@ function AuthTab({ col, setCol }) { return html`
setAuth({ identityFields: e.target.value.split(',').map(s=>s.trim()).filter(Boolean) })}/>
-
setAuth({ minPasswordLength: +e.target.value || 8 })}/>
+
setAuth({ minPasswordLength: e.target.value === '' ? '' : Number(e.target.value) })}/> + ${!validPasswordMinimum(auth.minPasswordLength ?? 8) && html`Minimum password length must be an integer from 0 to 255.`} +
Requires a binary built with two-factor support. Application policy can additionally require enrollment for selected users or groups.
@@ -245,7 +268,7 @@ function AuthTab({ col, setCol }) {
`; } -function RecordDrawer({ col, record, schema, onClose, onSaved }) { +function RecordDrawer({ col, record, onClose, onSaved }) { const isNew = !record; const [vals, setVals] = useState(() => ({ ...(record || {}) })); const [files, setFiles] = useState({}); // field -> FileList @@ -255,27 +278,19 @@ function RecordDrawer({ col, record, schema, onClose, onSaved }) { const [invalid, setInvalid] = useState({}); // field name -> true when unparseable const anyInvalid = Object.values(invalid).some(Boolean); const onValidity = (n, ok) => setInvalid(m => ({ ...m, [n]: !ok })); - const editable = schema.filter(f => !['id','created','updated','passwordHash','tokenKey'].includes(f.name) && f.type !== 'autodate'); + const [collection, setCollection] = useState(null); + useEffect(() => { + let active = true; + api('GET', `/collections/${encodeURIComponent(col)}`).then(c => { if (active) setCollection(c); }) + .catch(x => { if (active) setErr(x.data?.message || 'Failed to load collection fields'); }); + return () => { active = false; }; + }, [col]); + const editable = (collection?.schema || []).filter(f => !isSystemField(f.name, collection.type === 'auth') && f.type !== 'autodate'); function set(name, v) { setVals({ ...vals, [name]: v }); } async function save() { setErr(''); setFieldErrs({}); - const hasFiles = Object.values(files).some(fl => fl && fl.length); - let body, isForm = false; - if (hasFiles || Object.keys(removals).length) { - const fd = new FormData(); isForm = true; - for (const f of editable) { - if (f.type === 'file') continue; - const v = vals[f.name]; - if (v != null) fd.append(f.name, typeof v === 'object' ? JSON.stringify(v) : String(v)); - } - for (const [name, fl] of Object.entries(files)) for (const file of fl) fd.append(name, file); - for (const [name, names] of Object.entries(removals)) if (names.length) fd.append(name + '-', JSON.stringify(names)); - body = fd; - } else { - body = {}; - for (const f of editable) if (vals[f.name] !== undefined) body[f.name] = vals[f.name]; - } + const { body, isForm } = recordBody(editable, vals, files, removals); try { const saved = isNew ? await api('POST', `/collections/${encodeURIComponent(col)}/records`, body, isForm) @@ -294,40 +309,144 @@ function RecordDrawer({ col, record, schema, onClose, onSaved }) {
${isNew ? 'New record' : 'Edit record'}
${err && html`
${err}
`} ${editable.map(f => html`
- - ${control(f, vals[f.name], v => set(f.name, v), files, setFiles, removals, setRemovals, onValidity)} - ${fieldErrs[f.name] && html`
${fieldErrs[f.name].message}
`} + + ${control(f, ownValue(vals, f.name), v => set(f.name, v), files, setFiles, removals, setRemovals, onValidity, 'record-field-'+f.name)} + ${ownValue(fieldErrs, f.name) && html`
${ownValue(fieldErrs, f.name).message}
`}
`)}
- + ${anyInvalid && html`Fix invalid JSON to save`} ${!isNew && html``}
`; } -function control(f, value, set, files, setFiles, removals, setRemovals, onValidity) { +export function ownValue(values, name) { + return Object.prototype.hasOwnProperty.call(values, name) ? values[name] : undefined; +} + +function recordValue(field, value) { + if (field.type !== 'number') return value; + if (value === '') return null; + if (typeof value === 'number' && !Number.isFinite(value)) return String(value); + if ((field.options?.mode || 'float') !== 'float') return value; + if (typeof value === 'string' && value.trim() !== '') { + const number = Number(value); + if (Number.isFinite(number)) return number; + } + return value; +} + +function equalRecordValue(left, right) { + if (left === right) return true; + if (left === null || right === null || typeof left !== 'object' || typeof right !== 'object') return false; + if (Array.isArray(left) || Array.isArray(right)) { + return Array.isArray(left) && Array.isArray(right) && left.length === right.length && + left.every((value, index) => equalRecordValue(value, right[index])); + } + const keys = Object.keys(left); + return keys.length === Object.keys(right).length && keys.every(key => + Object.prototype.hasOwnProperty.call(right, key) && equalRecordValue(left[key], right[key])); +} + +export function changedRecordFields(fields, values, original) { + return fields.filter(f => !equalRecordValue(recordValue(f, ownValue(values, f.name)), + recordValue(f, ownValue(original, f.name)))); +} + +function noSelectionValue(values) { + let value = '__zigbase_no_selection__'; + while (values.includes(value)) value += '_'; + return value; +} + +function selectDeclares(field, value) { + return field.type === 'select' && (field.options?.maxSelect || 1) <= 1 && (field.options?.values || []).includes(value); +} + +export function recordBody(editable, vals, files, removals) { + const hasFiles = Object.values(files).some(fl => fl && fl.length); + let body, isForm = false; + if (hasFiles || Object.keys(removals).length) { + const fd = new FormData(); isForm = true; + for (const f of editable) { + if (f.type === 'file') continue; + const v = recordValue(f, ownValue(vals, f.name)); + if (v === undefined) continue; + // A select declaring "" cannot use an empty part for a clear. The server + // reads "null" as one, unless that is an option too and cannot be encoded. + if (v === null && selectDeclares(f, '')) { + if (!selectDeclares(f, 'null')) fd.append(f.name, 'null'); + continue; + } + // Text-like controls emit '' only for an intentional clear. Multipart + // cannot encode their SQL null, so preserve untouched nulls by omission. + if (v === null && ['text', 'date', 'editor', 'url', 'email'].includes(f.type)) continue; + // Empty form fields clear nullable values; JSON scalars need their JSON + // spelling too, so a string such as "true" never turns into a boolean. + // Quote unnormalized float strings so multipart's broader parser cannot + // accept a spelling that the JSON request would reject. + const invalidFloat = f.type === 'number' && (f.options?.mode || 'float') === 'float' && typeof v === 'string'; + const encoded = v === null ? '' : f.type === 'json' || typeof v === 'object' || invalidFloat ? JSON.stringify(v) : String(v); + fd.append(f.name, encoded); + } + for (const [name, fl] of Object.entries(files)) for (const file of fl) fd.append(name, file); + for (const [name, names] of Object.entries(removals)) if (names.length) fd.append(name + '-', JSON.stringify(names)); + body = fd; + } else { + body = Object.create(null); + for (const f of editable) { + const v = recordValue(f, ownValue(vals, f.name)); + if (v !== undefined) body[f.name] = v; + } + } + return { body, isForm }; +} + +export function control(f, value, set, files, setFiles, removals, setRemovals, onValidity, id) { const t = f.type, o = f.options || {}; - if (t === 'bool') return html` set(e.target.checked)}/>`; - if (t === 'number') return html` set(e.target.value)}/>`; - if (t === 'json') return html`<${JsonEditor} value=${value} onChange=${set} onValidity=${onValidity} name=${f.name}/>`; - if (t === 'editor') return html`<${RichTextEditor} value=${value} onChange=${set} name=${f.name}/>`; - if (t === 'date') return html` set(e.target.value)}/>`; - if (t === 'select') return html``; - if (t === 'relation') return html`<${RelationPicker} target=${o.targetCollectionId} value=${value} onChange=${set} name=${f.name}/>`; + const labelledBy = id && id+'-label'; + if (t === 'bool') return html` set(e.target.checked)}/>`; + if (t === 'number') return html` set(e.target.value)}/>`; + if (t === 'json') return html`<${JsonEditor} id=${id} labelledBy=${labelledBy} required=${f.required} value=${value} onChange=${set} onValidity=${onValidity} name=${f.name}/>`; + if (t === 'editor') return html`<${RichTextEditor} id=${id} labelledBy=${labelledBy} required=${f.required} value=${value} onChange=${set} name=${f.name}/>`; + if (t === 'date') return html` set(e.target.value)}/>`; + if (t === 'select') { + const multiple = (o.maxSelect || 1) > 1; + // A declared "" option needs its own value, distinct from "no selection". + const none = selectDeclares(f, '') ? noSelectionValue(o.values) : ''; + // Preact core needs per-option selection for array-valued controls. + return html``; + } + if (t === 'relation') return html`<${RelationPicker} id=${id} labelledBy=${labelledBy} required=${f.required} target=${o.targetCollectionId} multiple=${(o.maxSelect || 1) > 1} value=${value} onChange=${set} name=${f.name}/>`; if (t === 'file') { const existing = value == null ? [] : (Array.isArray(value) ? value : [value]).filter(Boolean); + const removed = ownValue(removals, f.name) || []; return html`
- ${existing.map(fn => html``)} - 1} data-test=${'in-'+f.name} onChange=${e => setFiles({ ...files, [f.name]: e.target.files })}/> + ${existing.map(fn => html``)} + 1} data-test=${'in-'+f.name} onChange=${e => setFiles({ ...files, [f.name]: e.target.files })}/>
`; } - return html` set(e.target.value)}/>`; + return html` set(e.target.value)}/>`; } -function RelationPicker({ target, value, onChange, name }) { +function RelationPicker({ target, multiple, value, onChange, name, id, labelledBy, required }) { const [opts, setOpts] = useState([]); - useEffect(() => { if (target) API.records(target, 'perPage=50').then(d => setOpts(d.items)).catch(() => {}); }, [target]); - return html``; + useEffect(() => { + let active = true; + setOpts([]); + if (target) API.records(target, 'perPage=50').then(d => { if (active) setOpts(d.items); }).catch(() => {}); + return () => { active = false; }; + }, [target]); + // The fetched page is a convenience list, not the complete relation value. + // Keep references outside that page visible so adding an option preserves + // them and removing one always requires an explicit selection change. + const selectedIds = multiple ? (Array.isArray(value) ? value : []) : value ? [value] : []; + const options = [...opts, ...selectedIds.filter(id => !opts.some(r => r.id === id)).map(id => ({ id }))]; + return html``; } diff --git a/src/admin/views/users.js b/src/admin/views/users.js index ca43aec5..799a5f7b 100644 --- a/src/admin/views/users.js +++ b/src/admin/views/users.js @@ -1,33 +1,62 @@ import { html, useState, useEffect } from '/_/assets/preact.js'; -import { API } from '/_/assets/lib/api.js'; +import { api, API } from '/_/assets/lib/api.js'; +import { control, recordBody, ownValue, isSystemField, changedRecordFields } from '/_/assets/views/collections.js'; const PER_PAGE = 30; -function UserDrawer({ col, user, onClose, onSaved }) { +function UserDrawer({ collection: selectedCollection, user, onClose, onSaved }) { const editing = !!user; + const col = selectedCollection.name; + const [collection, setCollection] = useState(null); + const [vals, setVals] = useState(() => user ? { ...user } : {}); + const [files, setFiles] = useState({}); + const [removals, setRemovals] = useState({}); + const [fieldErrs, setFieldErrs] = useState({}); + const [invalid, setInvalid] = useState({}); + const anyInvalid = Object.values(invalid).some(Boolean); + const onValidity = (name, ok) => setInvalid(m => ({ ...m, [name]: !ok })); + const editable = (collection?.schema || []).filter(f => !isSystemField(f.name, true) && f.type !== 'autodate'); + const minPasswordLength = collection?.options?.auth?.minPasswordLength ?? 8; const [email, setEmail] = useState((user && (user.email || '')) || ''); const [username, setUsername] = useState((user && (user.username || '')) || ''); const [password, setPassword] = useState(''); const [busy, setBusy] = useState(false); const [err, setErr] = useState(''); + useEffect(() => { + let active = true; + api('GET', `/collections/${encodeURIComponent(col)}`).then(current => { + if (!active) return; + if (!editing) setVals(values => ({ + ...Object.fromEntries((current.schema || []).filter(f => f.type === 'bool').map(f => [f.name, false])), + ...values, + })); + setCollection(current); + }).catch(x => { if (active) setErr(x.data?.message || 'Failed to load collection fields'); }); + return () => { active = false; }; + }, [col]); async function save(e) { e.preventDefault(); - setErr(''); setBusy(true); + if (!collection || busy) return; + setErr(''); setFieldErrs({}); setBusy(true); try { - if (editing) { - const body = {}; - if (password) body.password = password; // superuser PATCH -> admin reset - if (email && email !== user.email) body.email = email; - if (username !== (user.username || '')) body.username = username; - await API.updateUser(col, user.id, body); - } else { - const body = { email, password }; - if (username) body.username = username; - await API.createUser(col, body); + if ((!editing || password) && new TextEncoder().encode(password).length < minPasswordLength) { + setErr('Password is too short for this collection.'); + return; } + const credentials = []; + const values = { ...vals }; + if (!editing || email !== (user.email || '')) { credentials.push({ name: 'email' }); values.email = email; } + if (editing ? username !== (user.username || '') : username !== '') { credentials.push({ name: 'username' }); values.username = username; } + if (!editing || password) { credentials.push({ name: 'password' }); values.password = password; } + // PATCH only intentional profile changes, so a stale drawer cannot undo + // concurrent API/hook writes or validate untouched legacy profile values. + const changed = editing ? changedRecordFields(editable, vals, user) : editable; + const { body, isForm } = recordBody([...changed, ...credentials], values, files, removals); + if (editing) await API.updateUser(col, user.id, body, isForm); + else await API.createUser(col, body, isForm); onSaved(); - } catch (x) { setErr((x.data && x.data.message) || 'Save failed'); } + } catch (x) { setErr((x.data && x.data.message) || 'Save failed'); setFieldErrs(x.data?.data || {}); } finally { setBusy(false); } } async function del() { @@ -41,12 +70,18 @@ function UserDrawer({ col, user, onClose, onSaved }) {
${editing ? 'Edit user' : 'New user'}
${err && html`
${err}
`}
-
setEmail(e.target.value)}/>
-
setUsername(e.target.value)}/>
-
- setPassword(e.target.value)} placeholder=${editing ? 'leave blank to keep' : ''}/>
+
setEmail(e.target.value)}/>
+
setUsername(e.target.value)}/>
+
+ 0} value=${password} onInput=${e => setPassword(e.target.value)} placeholder=${editing ? 'leave blank to keep' : ''}/>
+ ${editable.map(f => html`
+ + ${control(f, ownValue(vals, f.name), v => setVals(m => ({ ...m, [f.name]: v })), files, setFiles, removals, setRemovals, onValidity, 'user-field-'+f.name)} + ${ownValue(fieldErrs, f.name) && html`
${ownValue(fieldErrs, f.name).message}
`} +
`)} + ${anyInvalid && html`
Fix invalid JSON to save
`}
- + ${editing && html``}
@@ -69,9 +104,8 @@ function OAuthPanel({ col }) { `; } -export function UsersView() { - const [authCols, setAuthCols] = useState(null); - const [col, setCol] = useState(''); +export function UsersView({ collection: col = '', collections, collectionsError }) { + const authCols = collections?.filter(c => c.type === 'auth'); const [rows, setRows] = useState(null); const [page, setPage] = useState(1); const [pages, setPages] = useState(1); @@ -82,47 +116,51 @@ export function UsersView() { const [drawerOpen, setDrawerOpen] = useState(false); useEffect(() => { - API.collections() - .then(cs => { - const auth = cs.filter(c => c.type === 'auth'); - setAuthCols(auth); - if (auth.length && !col) setCol(auth.some(c => c.name === 'users') ? 'users' : auth[0].name); - }) - .catch(x => setErr((x.data && x.data.message) || 'Failed to load collections')); - }, []); + if (!col && authCols?.length) { + const chosen = authCols.find(c => c.name === 'users') || authCols[0]; + location.replace('#/users/' + encodeURIComponent(chosen.name)); + } + }, [col, collections]); + const selected = authCols?.find(c => c.name === col); function load() { - if (!col) return; + if (!selected) return; + setErr(''); + let active = true; const params = new URLSearchParams({ page, perPage: PER_PAGE, sort: '-created' }); // Not every auth collection opts a field into FTS5 (`?search=`), so match on the // built-in identity columns (always present on auth collections) via a LIKE filter. if (q) params.set('filter', `email~${JSON.stringify(q)} || username~${JSON.stringify(q)}`); API.records(col, params.toString()) - .then(r => { setRows(r.items); setPages(r.totalPages || 1); }) - .catch(x => setErr((x.data && x.data.message) || 'Failed to load users')); + .then(r => { if (active) { setRows(r.items); setPages(r.totalPages || 1); } }) + .catch(x => { if (active) setErr(x.data?.message || 'Failed to load users'); }); + return () => { active = false; }; } - useEffect(load, [col, page, q]); + useEffect(load, [selected, page, q]); function runSearch(e) { e && e.preventDefault(); setPage(1); setQ(search.trim()); } return html`
-

Users

+

Users${col ? ' / ' + col : ''}

- { location.hash = '#/users/' + encodeURIComponent(e.target.value); }}> ${(authCols || []).map(c => html``)} - +
+ ${selected && html`Fields, rules & authentication →`} + ${authCols && !authCols.length && html`

No auth collections. Create a collection

`} + ${authCols && col && !selected && html`
Auth collection not found. Collection settings
`}
- setSearch(e.target.value)}/> + setSearch(e.target.value)}/>
- ${err && html`
${err}
`} - ${rows == null ? html`
…
` : html` + ${(err || collectionsError) && html`
${err || collectionsError}
`} + ${!selected ? null : rows == null ? html`
…
` : html` @@ -139,7 +177,7 @@ export function UsersView() { ${page} / ${pages}`} - <${OAuthPanel} col=${col}/> - ${drawerOpen && html`<${UserDrawer} key=${editing ? editing.id : '__new__'} col=${col} user=${editing} onClose=${() => setDrawerOpen(false)} onSaved=${() => { setDrawerOpen(false); load(); }}/>`} + ${selected && html`<${OAuthPanel} col=${col}/>`} + ${drawerOpen && selected && html`<${UserDrawer} key=${editing ? editing.id : '__new__'} collection=${selected} user=${editing} onClose=${() => setDrawerOpen(false)} onSaved=${() => { setDrawerOpen(false); load(); }}/>`} `; } diff --git a/src/api/collections.zig b/src/api/collections.zig index 34889394..33e9496f 100644 --- a/src/api/collections.zig +++ b/src/api/collections.zig @@ -118,8 +118,15 @@ pub fn create(ctx: *http.RequestCtx) anyerror!http.Response { if (try requireSuperuser(ctx)) |resp| return resp; const app = ctx.app.?; if (try rejectIfFrozen(ctx, app)) |resp| return resp; - const def = schema.parseCollectionInput(ctx.allocator.a, ctx.body) catch - return ApiError.badRequest("Invalid request body.").toResponse(ctx.allocator.a); + const def = schema.parseCollectionInput(ctx.allocator.a, ctx.body) catch |err| switch (err) { + error.InvalidMinPasswordLength => return ApiError.validation(&.{.{ + .field = "options.auth.minPasswordLength", + .code = codes.s(.validation_value), + .message = "Minimum password length must be an integer from 0 to 255.", + }}).toResponse(ctx.allocator.a), + error.OutOfMemory => return error.OutOfMemory, + else => return ApiError.badRequest("Invalid request body.").toResponse(ctx.allocator.a), + }; // Fail-closed: can't create an encrypted field with no field key configured // (the startup guard only inspects comptime collections). Without this, an // encrypted field created at runtime would be unwritable / leak intent. @@ -161,8 +168,15 @@ pub fn update(ctx: *http.RequestCtx) anyerror!http.Response { const app = ctx.app.?; if (try rejectIfFrozen(ctx, app)) |resp| return resp; const key = ctx.param("idOrName") orelse return ApiError.notFound().toResponse(ctx.allocator.a); - const def = schema.parseCollectionInput(ctx.allocator.a, ctx.body) catch - return ApiError.badRequest("Invalid request body.").toResponse(ctx.allocator.a); + const def = schema.parseCollectionInput(ctx.allocator.a, ctx.body) catch |err| switch (err) { + error.InvalidMinPasswordLength => return ApiError.validation(&.{.{ + .field = "options.auth.minPasswordLength", + .code = codes.s(.validation_value), + .message = "Minimum password length must be an integer from 0 to 255.", + }}).toResponse(ctx.allocator.a), + error.OutOfMemory => return error.OutOfMemory, + else => return ApiError.badRequest("Invalid request body.").toResponse(ctx.allocator.a), + }; if (schema.hasEncryptedField(def) and db.poolFieldCipher(app.pool) == null) return ApiError.badRequest("Encrypted fields require ZIGBASE_FIELD_KEY to be configured.").toResponse(ctx.allocator.a); const w = app.pool.acquireWriter(); diff --git a/src/records.zig b/src/records.zig index 35bf1cbb..9587b6c8 100644 --- a/src/records.zig +++ b/src/records.zig @@ -486,10 +486,10 @@ fn validateFieldValue(alloc: std.mem.Allocator, conn: *db.Db, f: schema.Field, v } }, .number => |o| if (o.mode == .float) { - const x: f64 = switch (v) { - .float => |fl| fl, - .integer => |i| @floatFromInt(i), - else => return, + if (v == .null) return; + const x = values.floatValue(v) catch |e| { + try errs.append(alloc, .{ .field = f.name, .code = convCode(e), .message = "Invalid number." }); + return; }; try appendMinMax(alloc, errs, f.name, x, o.min, o.max); } else if (v == .string) { @@ -572,15 +572,18 @@ fn validateFieldValue(alloc: std.mem.Allocator, conn: *db.Db, f: schema.Field, v /// number (all) "" -> null; float mode: parseable, finite -> JSON float /// (else left as a string); int/fixed: kept as a string /// (the accepted form) -/// select/relation (multi) "" -> null; JSON-array string -> array (the admin +/// select/relation (all) "" -> null; multi-valued JSON-array string -> array (the admin /// UI sends JSON.stringify(array)); any other string wraps as a /// one-element array of the ORIGINAL string ("123" -> ["123"]) /// json "" -> null; any parseable JSON -> the parsed value; else the /// raw string -/// everything else (text/email/url/editor/date/single select/relation/file) -/// kept verbatim — including "", which is their JSON clear form -/// So an empty multipart value clears every field type: text-likes store "", -/// bool/number/json/multi-value fields store null. Keys that match no schema +/// select/relation (single) nonempty option/target ids kept verbatim; "" stays a +/// value when a single select declares it as an option, and "null" +/// then clears the field unless it is declared too +/// everything else (text/email/url/editor/date/file) +/// kept verbatim — including "" +/// Empty multipart bool/number/json/select/relation/multi-value fields store null; +/// text-likes store "". Keys that match no schema /// field ("-" removal keys, auth password/passwordConfirm) and /// non-string values keep their value unchanged (deep-cloned as-is, never aliased). /// @@ -603,6 +606,14 @@ pub fn coerceFormFields(alloc: std.mem.Allocator, col: schema.Collection, data: return .{ .object = out }; } +fn singleSelectDeclares(f: schema.Field, value: []const u8) bool { + if (f.options != .select or f.isMultiValue()) return false; + for (f.options.select.values) |option| { + if (std.mem.eql(u8, option, value)) return true; + } + return false; +} + /// Coerce one multipart field value to its schema-shaped JSON, returning an OWNED value on `alloc` /// (free with `values.freeValue`): scalars own nothing; a passed-through string is duped; a parsed /// json/array tree is deep-cloned off its temporary `Parsed` handle, which is then freed. Nothing @@ -634,8 +645,13 @@ fn coerceFieldValue(alloc: std.mem.Allocator, col: schema.Collection, key: []con return values.cloneValue(alloc, parsed.value); }, .select, .relation => { + // Empty selects and relations represent a clear in multipart, including + // single-valued fields, unless a single select declares "" as an option. + // That select takes the JSON spelling instead, unless "null" is an option too. + const declares_empty = singleSelectDeclares(f, ""); + if (s.len == 0 and !declares_empty) return .null; + if (declares_empty and std.mem.eql(u8, s, "null") and !singleSelectDeclares(f, "null")) return .null; if (!f.isMultiValue()) return values.cloneValue(alloc, v); - if (s.len == 0) return .null; // multipart "" = clear if (std.json.parseFromSlice(std.json.Value, alloc, s, .{})) |parsed| { defer parsed.deinit(); if (parsed.value == .array) return values.cloneValue(alloc, parsed.value); @@ -670,6 +686,9 @@ fn coerceCol() schema.Collection { .{ .id = "f7", .name = "status", .options = .{ .select = .{ .values = &.{ "a", "b" }, .maxSelect = 1 } } }, .{ .id = "f8", .name = "meta", .options = .{ .json = .{} } }, .{ .id = "f9", .name = "photos", .options = .{ .file = .{ .maxSelect = 3 } } }, + .{ .id = "f10", .name = "owner", .options = .{ .relation = .{ .targetCollectionId = "groups", .maxSelect = 1 } } }, + .{ .id = "f11", .name = "mode", .options = .{ .select = .{ .values = &.{ "", "active" }, .maxSelect = 1 } } }, + .{ .id = "f12", .name = "literal", .options = .{ .select = .{ .values = &.{ "", "null" }, .maxSelect = 1 } } }, }; }; return .{ .id = "c", .name = "things", .fields = &S.fields }; @@ -780,17 +799,22 @@ test "coerce: an empty multipart value clears optional non-text fields to null" try std.testing.expect((try coerceOneField(a, "ratio", "")) == .null); try std.testing.expect((try coerceOneField(a, "meta", "")) == .null); try std.testing.expect((try coerceOneField(a, "tags", "")) == .null); - // text-like and single select keep "" (their JSON form accepts it) + try std.testing.expect((try coerceOneField(a, "owner", "")) == .null); + try expectCoerceString(a, "owner", "some-record-id", "some-record-id"); + try std.testing.expect((try coerceOneField(a, "status", "")) == .null); + // A single select that declares "" as an option keeps it as a value. + try expectCoerceString(a, "mode", "", ""); + // That select cannot use "" for a clear, so the JSON spelling clears it. + try std.testing.expect((try coerceOneField(a, "mode", "null")) == .null); + // Unless "null" is an option too, and other selects keep the literal text. + try expectCoerceString(a, "literal", "null", "null"); + try expectCoerceString(a, "status", "null", "null"); + // Text-like fields keep their literal empty string. { const tv = try coerceOneField(a, "title", ""); defer values.freeValue(a, tv); try std.testing.expect(tv == .string and tv.string.len == 0); } - { - const sv = try coerceOneField(a, "status", ""); - defer values.freeValue(a, sv); - try std.testing.expect(sv == .string and sv.string.len == 0); - } } test "coerce: json field parses any valid JSON; invalid stays string" { @@ -1403,6 +1427,33 @@ test "create rejects an over-precise fixed value with a field error" { freeLastErrors(a); } +test "float number-string validation enforces bounds and finite values" { + const a = std.testing.allocator; + var d = try db.Db.openMemory(); + defer d.close(); + const f = schema.Field{ .id = "f", .name = "rating", .options = .{ .number = .{ .mode = .float, .min = -1e20, .max = 1e20 } } }; + var errs: std.ArrayList(schema.ValidationError) = .empty; + defer errs.deinit(a); + try validateFieldValue(a, &d, f, .{ .number_string = "100000000000000000000" }, &errs); + try validateFieldValue(a, &d, f, .null, &errs); + try std.testing.expectEqual(@as(usize, 0), errs.items.len); + try validateFieldValue(a, &d, f, .{ .number_string = "200000000000000000000" }, &errs); + try std.testing.expectEqual(@as(usize, 1), errs.items.len); + try std.testing.expectEqualStrings(codes.s(.validation_max), errs.items[0].code); + errs.clearRetainingCapacity(); + try validateFieldValue(a, &d, f, .{ .number_string = "-200000000000000000000" }, &errs); + try std.testing.expectEqual(@as(usize, 1), errs.items.len); + try std.testing.expectEqualStrings(codes.s(.validation_min), errs.items[0].code); + errs.clearRetainingCapacity(); + try validateFieldValue(a, &d, f, .{ .number_string = "1e999" }, &errs); + try std.testing.expectEqual(@as(usize, 1), errs.items.len); + try std.testing.expectEqualStrings(codes.s(.validation_number), errs.items[0].code); + errs.clearRetainingCapacity(); + try validateFieldValue(a, &d, f, .{ .string = "100000000000000000000" }, &errs); + try std.testing.expectEqual(@as(usize, 1), errs.items.len); + try std.testing.expectEqualStrings(codes.s(.validation_type), errs.items[0].code); +} + test "create rejects a missing required field" { const a = std.testing.allocator; var d = try db.Db.openMemory(); diff --git a/src/schema.zig b/src/schema.zig index 0c339a7a..0077bc7f 100644 --- a/src/schema.zig +++ b/src/schema.zig @@ -1323,12 +1323,15 @@ fn getU64(v: Value, key: []const u8) !?u64 { fn getF64(v: Value, key: []const u8) !?f64 { const x = objGet(v, key) orelse return null; - return switch (x) { - .null => null, + const number: f64 = switch (x) { + .null => return null, .float => |f| f, .integer => |i| @floatFromInt(i), - else => error.InvalidSchema, + .number_string => |s| std.fmt.parseFloat(f64, s) catch return error.InvalidSchema, + else => return error.InvalidSchema, }; + if (!std.math.isFinite(number)) return error.InvalidSchema; + return number; } fn getStrArray(alloc: std.mem.Allocator, v: Value, key: []const u8) !?[]const []const u8 { @@ -1475,6 +1478,12 @@ pub fn parseCollectionInput(alloc: std.mem.Allocator, s: []const u8) !Collection defer parsed.deinit(); // everything retained is duped into `alloc` before return const obj = parsed.value; if (obj != .object) return error.InvalidSchema; + if (objGet(obj, "options")) |options| if (objGet(options, "auth")) |auth_options| { + if (objGet(auth_options, "minPasswordLength")) |minimum| { + if (minimum != .integer or minimum.integer < 0 or minimum.integer > 255) + return error.InvalidMinPasswordLength; + } + }; // The `fv`/`iv`/`ov` re-stringify-then-reparse round trips below are pure scratch (each is // immediately consumed by fieldsFromJson/indexesFromJson/optionsFromJson, which dupe every @@ -1640,6 +1649,44 @@ test "parseCollectionInput then collectionToJson round-trips the essentials" { try std.testing.expect(std.mem.indexOf(u8, out, "\"title\"") != null); } +test "large finite number bounds parse and round-trip through schema JSON" { + const a = std.testing.allocator; + for ([_][]const u8{ + \\[{"id":"rating1","name":"rating","type":"number","options":{"mode":"float","min":-100000000000000000000,"max":100000000000000000000}}] + , + \\[{"id":"rating1","name":"rating","type":"number","options":{"mode":"float","min":-1e20,"max":1e20}}] + , + }) |input| { + const fields = try fieldsFromJson(a, input); + defer freeFieldsOwned(a, fields); + try std.testing.expectEqual(@as(f64, -1e20), fields[0].options.number.min.?); + try std.testing.expectEqual(@as(f64, 1e20), fields[0].options.number.max.?); + const encoded = try fieldsToJson(a, fields); + defer a.free(encoded); + const decoded = try fieldsFromJson(a, encoded); + defer freeFieldsOwned(a, decoded); + try std.testing.expectEqual(@as(f64, -1e20), decoded[0].options.number.min.?); + try std.testing.expectEqual(@as(f64, 1e20), decoded[0].options.number.max.?); + } +} + +test "number bounds reject strings and nonfinite values" { + const a = std.testing.allocator; + var options: std.json.ObjectMap = .empty; + defer options.deinit(a); + for ([_]Value{ + .{ .string = "1e20" }, + .{ .number_string = "1e999" }, + .{ .float = std.math.inf(f64) }, + .{ .float = std.math.nan(f64) }, + }) |value| { + try options.put(a, "min", value); + try std.testing.expectError(error.InvalidSchema, getF64(.{ .object = options }, "min")); + try options.put(a, "max", value); + try std.testing.expectError(error.InvalidSchema, getF64(.{ .object = options }, "max")); + } +} + fn collectErrors(c: Collection) !std.ArrayList(ValidationError) { var list: std.ArrayList(ValidationError) = .empty; try validate(std.testing.allocator, c, &list); @@ -2329,3 +2376,19 @@ test "authInjectionPrefix answers identity, not equality" { try std.testing.expectEqual(@as(usize, 0), authInjectionPrefix(.auth, truncated)); try std.testing.expectEqual(@as(usize, 0), authInjectionPrefix(.base, injected.fields)); } + +test "collection input rejects invalid password minima and preserves boundaries" { + const a = std.testing.allocator; + for ([_][]const u8{ "-1", "256", "9.5", "null", "\"8\"" }) |value| { + const input = try std.fmt.allocPrint(a, "{{\"name\":\"members\",\"type\":\"auth\",\"fields\":[],\"options\":{{\"auth\":{{\"minPasswordLength\":{s}}}}}}}", .{value}); + defer a.free(input); + try std.testing.expectError(error.InvalidMinPasswordLength, parseCollectionInput(a, input)); + } + for ([_]u8{ 0, 255 }) |minimum| { + const input = try std.fmt.allocPrint(a, "{{\"name\":\"members\",\"type\":\"auth\",\"fields\":[],\"options\":{{\"auth\":{{\"minPasswordLength\":{d}}}}}}}", .{minimum}); + defer a.free(input); + const col = try parseCollectionInput(a, input); + defer col.deinit(a); + try std.testing.expectEqual(minimum, col.options.auth.minPasswordLength); + } +} diff --git a/src/values.zig b/src/values.zig index d7f6513d..353ec923 100644 --- a/src/values.zig +++ b/src/values.zig @@ -118,6 +118,19 @@ pub fn scaledIntToDecimal(alloc: std.mem.Allocator, v: i64, scale: u8) ![]u8 { return out.toOwnedSlice(alloc); } +/// Convert typed JSON numbers to a finite float. JSON preserves integral tokens +/// outside i64 as number_string; ordinary strings retain their type error. +pub fn floatValue(v: std.json.Value) error{ TypeMismatch, BadNumber }!f64 { + const number: f64 = switch (v) { + .float => v.float, + .integer => @floatFromInt(v.integer), + .number_string => std.fmt.parseFloat(f64, v.number_string) catch return error.BadNumber, + else => return error.TypeMismatch, + }; + if (!std.math.isFinite(number)) return error.BadNumber; + return number; +} + /// Bind a `std.json.Value` into a prepared statement parameter according to the field's type. pub fn bindValue(alloc: std.mem.Allocator, stmt: *db.Stmt, idx: c_int, field: schema.Field, v: std.json.Value) (ValueError || db.DbError || EncryptError)!void { if (v == .null) return stmt.bindNull(idx); @@ -131,14 +144,7 @@ pub fn bindValue(alloc: std.mem.Allocator, stmt: *db.Stmt, idx: c_int, field: sc try stmt.bindInt(idx, if (v.bool) 1 else 0); }, .number => |o| switch (o.mode) { - .float => { - const f: f64 = switch (v) { - .float => v.float, - .integer => @floatFromInt(v.integer), - else => return error.TypeMismatch, - }; - try stmt.bindDouble(idx, f); - }, + .float => try stmt.bindDouble(idx, try floatValue(v)), .int => { // Symmetric with reads (which RETURN `.string`): accept a JSON string // (the canonical form), a JSON integer (bind directly), or a JSON float @@ -360,6 +366,27 @@ test "float round-trips as a number" { try std.testing.expectApproxEqAbs(@as(f64, 1.5), out.float, 0.0001); } +test "float number-string tokens outside i64 round-trip as numbers" { + const a = std.testing.allocator; + const f = schema.Field{ .id = "f", .name = "ratio", .options = .{ .number = .{ .mode = .float } } }; + for ([_][]const u8{ "100000000000000000000", "-100000000000000000000", "9223372036854776000" }) |token| { + const parsed = try std.json.parseFromSlice(std.json.Value, a, token, .{}); + defer parsed.deinit(); + try std.testing.expect(parsed.value == .number_string); + const expected = try std.fmt.parseFloat(f64, token); + const out = try roundTrip(a, f, "REAL", parsed.value); + try std.testing.expectEqual(expected, out.float); + } +} + +test "float conversion rejects ordinary strings and nonfinite numbers" { + try std.testing.expectError(error.TypeMismatch, floatValue(.{ .string = "100000000000000000000" })); + try std.testing.expectError(error.BadNumber, floatValue(.{ .float = std.math.inf(f64) })); + try std.testing.expectError(error.BadNumber, floatValue(.{ .float = std.math.nan(f64) })); + try std.testing.expectError(error.BadNumber, floatValue(.{ .number_string = "1e999" })); + try std.testing.expectError(error.BadNumber, floatValue(.{ .number_string = "not-a-number" })); +} + test "int number round-trips as a string" { const a = std.testing.allocator; const f = schema.Field{ .id = "f", .name = "n", .options = .{ .number = .{ .mode = .int } } }; diff --git a/tests/admin/test_auth_feedback.py b/tests/admin/test_auth_feedback.py new file mode 100644 index 00000000..20244aff --- /dev/null +++ b/tests/admin/test_auth_feedback.py @@ -0,0 +1,427 @@ +"""Account drawer writes preserve unrelated state and Shell metadata has one owner.""" +import json +import re +import pytest +from playwright.sync_api import expect +from conftest import login, api_request, csrf +from test_auth_navigation import collection, scoped + + +@pytest.mark.parametrize('transport', ['json', 'upload', 'remove']) +def test_account_patch_only_sends_dirty_fields_and_preserves_concurrent_values(page, transport): + login(page) + collection(page, 'members', fields=[ + {'name': 'plan', 'type': 'text'}, + {'name': 'preferences', 'type': 'json'}, + {'name': 'rating', 'type': 'number'}, + {'name': 'tags', 'type': 'select', 'options': {'values': ['a', 'b'], 'maxSelect': 2}}, + {'name': 'avatar', 'type': 'file'}, + ]) + created = api_request(page, 'POST', '/api/collections/members/records', { + 'email': 'before@example.com', 'password': 'safe password here', + 'plan': 'old', 'preferences': {'theme': 'dark'}, 'rating': 1.5, 'tags': ['a', 'b']}) + assert created.ok, created.text() + endpoint = '/api/collections/members/records/' + created.json()['id'] + if transport == 'remove': + response = page.request.patch(endpoint, headers={'X-CSRF-Token': csrf(page)}, multipart={ + 'avatar': {'name': 'old.txt', 'mimeType': 'text/plain', 'buffer': b'old'}}) + assert response.ok, response.text() + page.goto('/_/#/users/members') + page.click('[data-test=user-row]') + page.fill('[data-test=in-plan]', 'temporary') + page.fill('[data-test=in-plan]', 'old') + page.fill('[data-test=in-rating]', '2') + page.fill('[data-test=in-rating]', '1.5') + response = api_request(page, 'PATCH', endpoint, {'plan': 'concurrent', 'preferences': {'theme': 'light'}, 'rating': 3, 'tags': ['b']}) + assert response.ok, response.text() + page.fill('[data-test=user-email]', 'after@example.com') + if transport == 'upload': + page.set_input_files('[data-test=in-avatar]', {'name': 'new.txt', 'mimeType': 'text/plain', 'buffer': b'new'}) + elif transport == 'remove': + page.check('[data-test=rm-avatar]') + with page.expect_response(lambda r: r.url.endswith(endpoint) and r.request.method == 'PATCH') as write: + page.click('[data-test=user-save]') + assert write.value.ok, write.value.text() + expect(page.locator('[data-test=user-drawer]')).to_have_count(0) + if transport == 'json': + assert write.value.request.post_data_json == {'email': 'after@example.com'} + else: + assert 'multipart/form-data' in write.value.request.headers['content-type'] + saved = api_request(page, 'GET', endpoint).json() + assert saved['plan'] == 'concurrent' + assert saved['rating'] == 3 + tags = saved['tags'] + assert (json.loads(tags) if isinstance(tags, str) else tags) == ['b'] + preferences = saved['preferences'] + assert (json.loads(preferences) if isinstance(preferences, str) else preferences) == {'theme': 'light'} + assert saved['email'] == 'after@example.com' + if transport == 'upload': + assert saved['avatar'] + elif transport == 'remove': + assert not saved['avatar'] + + +def test_credential_reset_omits_missing_legacy_required_profile(page): + login(page) + metadata = collection(page, 'members', fields=[{'name': 'plan', 'type': 'text'}]) + created = api_request(page, 'POST', '/api/collections/members/records', { + 'email': 'legacy@example.com', 'password': 'safe password here'}) + assert created.ok, created.text() + # Adding a required field leaves older rows empty. Tighten an existing nullable + # field so the real collection definition, rather than a mocked form, exercises it. + fields = [f for f in metadata['schema'] if f['name'] == 'plan'] + fields[0]['required'] = True + changed = api_request(page, 'PATCH', '/api/collections/members', {'name': 'members', 'type': 'auth', 'fields': fields}) + assert changed.ok, changed.text() + endpoint = '/api/collections/members/records/' + created.json()['id'] + page.goto('/_/#/users/members') + page.click('[data-test=user-row]') + page.fill('[data-test=user-password]', 'new safe password here') + with page.expect_response(lambda r: r.url.endswith(endpoint) and r.request.method == 'PATCH') as write: + page.click('[data-test=user-save]') + assert write.value.ok, write.value.text() + assert write.value.request.post_data_json == {'password': 'new safe password here'} + assert api_request(page, 'GET', endpoint).json()['plan'] is None + response = page.request.post('/api/collections/members/auth-with-password', data={ + 'identity': 'legacy@example.com', 'password': 'new safe password here'}) + assert response.ok, response.text() + + +def test_shell_collection_failure_mounts_records_and_shows_user_error(page): + login(page) + collection(page, 'notes', 'base') + failures = [] + def fail(route): + failures.append(route.request.url) + route.fulfill(status=503, json={'message': 'Collection list unavailable'}) + page.route('**/api/collections', fail) + page.goto('/_/#/collections/notes/records') + page.reload() + expect(page.locator('[data-test=records-view]')).to_be_visible() + expect(page.locator('[data-test=empty]')).to_be_visible() + expect(page.locator('[data-test=records-error]')).to_contain_text('Collection list unavailable') + page.evaluate("location.hash = '#/users'") + expect(page.locator('[data-test=users-error]')).to_contain_text('Collection list unavailable') + expect(page.locator('[data-test=user-new]')).to_be_disabled() + + +def test_failed_lookup_error_clears_when_navigating_to_a_cached_collection(page): + login(page) + collection(page, 'members') + page.goto('/_/?lookup-error=1#/users/members') + scoped(page, 'members') + requests = [] + def metadata(route): + requests.append(route.request) + route.fulfill(status=503, json={'message': 'Temporary collection failure'}) + page.route('**/api/collections', metadata) + page.evaluate("location.hash = '#/users/missing'") + expect(page.locator('[data-test=users-error]')).to_have_text('Temporary collection failure') + expect(page.locator('.sidebar .error')).to_have_text('Temporary collection failure') + page.evaluate("location.hash = '#/users/members'") + scoped(page, 'members') + expect(page.locator('[data-test=users-error]')).to_have_count(0) + expect(page.locator('.sidebar .error')).to_have_count(0) + assert len(requests) == 1 + + +def test_late_lookup_failure_stays_off_the_route_the_admin_moved_to(page): + login(page) + collection(page, 'members') + page.goto('/_/?late-lookup=1#/users/members') + scoped(page, 'members') + pending = [] + page.route('**/api/collections', lambda route: pending.append(route)) + with page.expect_request('**/api/collections'): + page.evaluate("location.hash = '#/users/missing'") + page.evaluate("location.hash = '#/users/members'") + expect(page).to_have_url(re.compile(r"#/users/members$")) + # Route-change effects flush after paint; a failure landing sooner is cleared by them. + page.wait_for_timeout(300) + with page.expect_response('**/api/collections') as late: + pending[0].fulfill(status=503, json={'message': 'Late lookup failure'}) + late.value.finished() + scoped(page, 'members') + # A count of zero passes at once, so give the late failure time to render first. + page.wait_for_timeout(500) + expect(page.locator('[data-test=users-error]')).to_have_count(0) + expect(page.locator('.sidebar .error')).to_have_count(0) + assert len(pending) == 1 + + +def test_collection_switches_reuse_shell_metadata(page): + login(page) + collection(page, 'members') + collection(page, 'staff') + collection(page, 'notes', 'base') + lists = [] + page.on('request', lambda request: lists.append(request.url) if re.search(r'/api/collections$', request.url) else None) + page.goto('/_/?metadata-test=1#/users/members') # Fresh Shell: exactly one initial fetch. + scoped(page, 'members') + page.select_option('[data-test=users-collection]', 'staff') + scoped(page, 'staff') + assert len(lists) == 1 + page.click('[data-test=nav-notes]') + expect(page.locator('[data-test=records-view]')).to_be_visible() + expect(page.locator('[data-test=empty]')).to_be_visible() + with page.expect_request(lambda request: re.search(r'/api/collections$', request.url)): + page.click('[data-test=nav-members]') + scoped(page, 'members') + assert len(lists) == 2 # Re-entering Users refreshes once; collection switches do not. + + +@pytest.mark.parametrize('kind', ['base', 'auth']) +def test_rich_text_blur_persists_stored_cleanup_and_preserves_untouched_null(page, kind): + login(page) + collection(page, 'profiles', kind, fields=[ + {'name': 'biography', 'type': 'editor'}, {'name': 'note', 'type': 'text'}]) + endpoint = '/api/collections/profiles/records' + body = {'biography': '

Safe

', 'note': 'before'} + if kind == 'auth': + body.update(email='editor@example.com', password='safe password here') + created = api_request(page, 'POST', endpoint, body) + assert created.ok, created.text() + endpoint += '/' + created.json()['id'] + page.goto('/_/#/users/profiles' if kind == 'auth' else '/_/#/collections/profiles/records') + row = '[data-test=user-row]' if kind == 'auth' else '[data-test=row]' + save = '[data-test=user-save]' if kind == 'auth' else '[data-test=record-save]' + drawer = '[data-test=user-drawer]' if kind == 'auth' else '[data-test=record-drawer]' + page.click(row) + editor = page.get_by_role('textbox', name='biography', exact=True) + expect(editor).to_have_text('Safe') + editor.focus() + page.get_by_label('note', exact=True).fill('after') + page.click(save) + expect(page.locator(drawer)).to_have_count(0) + assert api_request(page, 'GET', endpoint).json()['biography'] == '

Safe

' + assert api_request(page, 'PATCH', endpoint, {'biography': None}).ok + page.reload() + page.click(row) + editor.focus() + page.get_by_label('note', exact=True).fill('keep null') + page.click(save) + expect(page.locator(drawer)).to_have_count(0) + assert api_request(page, 'GET', endpoint).json()['biography'] is None + # A real edit can still clear stored nonempty HTML explicitly. + assert api_request(page, 'PATCH', endpoint, {'biography': '

Again

'}).ok + page.reload() + page.click(row) + editor.evaluate("el => { el.innerHTML = ''; el.dispatchEvent(new InputEvent('input', {bubbles:true})); }") + page.click(save) + expect(page.locator(drawer)).to_have_count(0) + assert api_request(page, 'GET', endpoint).json()['biography'] == '' + + +@pytest.mark.parametrize('multipart', [False, True]) +def test_blank_username_create_is_null_and_existing_username_can_clear(page, multipart): + login(page) + collection(page, 'members', fields=[{'name': 'avatar', 'type': 'file'}]) + page.goto('/_/#/users/members') + page.click('[data-test=user-new]') + page.fill('[data-test=user-email]', 'blank@example.com') + page.fill('[data-test=user-password]', 'safe password here') + if multipart: + page.set_input_files('[data-test=in-avatar]', {'name': 'avatar.txt', 'mimeType': 'text/plain', 'buffer': b'avatar'}) + page.click('[data-test=user-save]') + expect(page.locator('[data-test=user-drawer]')).to_have_count(0) + record = api_request(page, 'GET', '/api/collections/members/records').json()['items'][0] + assert record['username'] is None + page.click('[data-test=user-row]') + page.fill('[data-test=user-username]', 'named') + page.click('[data-test=user-save]') + expect(page.locator('[data-test=user-drawer]')).to_have_count(0) + page.click('[data-test=user-row]') + page.fill('[data-test=user-username]', '') + page.click('[data-test=user-save]') + expect(page.locator('[data-test=user-drawer]')).to_have_count(0) + assert api_request(page, 'GET', '/api/collections/members/records/' + record['id']).json()['username'] == '' + + +def test_protected_fields_match_injected_auth_schema(page): + login(page) + metadata = collection(page, 'members') + from pathlib import Path + source = (Path(__file__).resolve().parents[2] / 'src/schema.zig').read_text() + auth_fields = source.split('pub fn authSystemFields()', 1)[1].split('return &S.fields;', 1)[0] + expected = re.findall(r'\.name = "([^"]+)"', auth_fields) + result = page.evaluate("""async names => { + const { AUTH_SYSTEM_FIELDS, isSystemField } = await import('/_/assets/views/collections.js'); + return { fields: AUTH_SYSTEM_FIELDS, protected: names.every(name => isSystemField(name, true)), + special: ['id','created','updated','password','passwordConfirm','oldPassword','externalAuths','TOKEN_EPOCH'].every(name => isSystemField(name, true)) }; + }""", expected) + assert sorted(result['fields']) == sorted(expected) + assert result['protected'] and result['special'] + + +def test_auth_settings_zero_minimum_saves_and_reloads(page): + login(page) + collection(page, 'members') + page.goto('/_/#/collections/members') + page.click('[data-test=tab-auth]') + page.fill('[data-test=min-pw]', '0') + expect(page.locator('[data-test=min-pw]')).to_have_value('0') + page.click('[data-test=save-collection]') + scoped(page, 'members') + saved = next(c for c in api_request(page, 'GET', '/api/collections').json()['items'] if c['name'] == 'members') + assert saved['options']['auth']['minPasswordLength'] == 0 + page.click('[data-test=user-new]') + page.fill('[data-test=user-email]', 'zero-settings@example.com') + page.click('[data-test=user-save]') + expect(page.locator('[data-test=user-drawer]')).to_have_count(0) + page.click('[data-test=users-settings]') + page.click('[data-test=tab-auth]') + expect(page.locator('[data-test=min-pw]')).to_have_value('0') + page.click('[data-test=tab-fields]') + page.click('[data-test=add-field]') + page.locator('[data-test=field-name]').last.fill('newProfile') + page.click('[data-test=save-collection]') + scoped(page, 'members') + page.click('[data-test=user-new]') + expect(page.get_by_label('newProfile', exact=True)).to_be_visible() + assert page.get_by_label('Password', exact=True).get_attribute('required') is None + + +def test_request_credential_names_remain_editable_base_profile_fields(page): + login(page) + collection(page, 'notes', 'base', fields=[{'name': 'password', 'type': 'text'}, {'name': 'externalAuths', 'type': 'json'}]) + page.goto('/_/#/collections/notes/records') + page.click('[data-test=new-record]') + page.get_by_label('password', exact=True).fill('custom profile value') + page.get_by_label('externalAuths', exact=True).fill('{"provider":"custom"}') + page.click('[data-test=record-save]') + expect(page.locator('[data-test=record-drawer]')).to_have_count(0) + record = api_request(page, 'GET', '/api/collections/notes/records').json()['items'][0] + assert record['password'] == 'custom profile value' + assert record['externalAuths'] == {'provider': 'custom'} + page.goto('/_/#/collections/notes') + # Schema controls also preserve valid custom field names. + fields = page.locator('[data-test=field-name]') + expect(fields).to_have_count(2) + assert fields.evaluate_all('(els) => els.every(el => !el.disabled)') + + +@pytest.mark.parametrize('kind', ['base', 'auth']) +def test_record_metadata_fallback_recovers_shell_and_auth_handoff(page, kind): + login(page) + collection(page, 'profiles', kind, fields=[{'name': 'note', 'type': 'text'}]) + requests = [] + def fail_initial(route): + requests.append(route.request.url) + if len(requests) == 1: + route.fulfill(status=500, json={'message': 'Initial metadata failed'}) + else: + route.continue_() + page.route('**/api/collections', fail_initial) + page.goto('/_/?metadata-recovery=1#/collections/profiles/records') + if kind == 'auth': + scoped(page, 'profiles') + expect(page.locator('[data-test=records-view]')).to_have_count(0) + expect(page.locator('[data-test=users-error]')).to_have_count(0) + page.click('[data-test=user-new]') + expect(page.get_by_label('Password', exact=True)).to_be_visible() + else: + expect(page.locator('[data-test=records-view]')).to_be_visible() + expect(page.locator('[data-test=nav-profiles]')).to_be_visible() + page.click('[data-test=new-record]') + expect(page.get_by_label('note', exact=True)).to_be_visible() + expect(page.locator('.sidebar .error')).to_have_count(0) + assert len(requests) == (3 if kind == 'auth' else 2) + + +def test_collection_switch_during_entry_refresh_keeps_request_live(page): + login(page) + collection(page, 'members') + collection(page, 'staff') + metadata = api_request(page, 'GET', '/api/collections').json() + expect(page.locator('[data-test=nav-_superusers]')).to_be_visible() + pending = [] + page.route('**/api/collections', lambda route: pending.append(route)) + with page.expect_request('**/api/collections'): + page.goto('/_/#/users/members') + expect(page.locator('[data-test=user-new]')).to_be_disabled() + page.evaluate("location.hash = '#/users/staff'") + expect(page).to_have_url(re.compile(r'#/users/staff$')) + assert len(pending) == 1 + with page.expect_response('**/api/collections') as loaded: + pending[0].fulfill(json=metadata) + loaded.value.finished() + scoped(page, 'staff') + assert len(pending) == 1 + + +@pytest.mark.parametrize('stale_error', [False, True]) +def test_history_refresh_ignores_older_inflight_result_and_retains_completed_miss(page, stale_error): + login(page) + collection(page, 'members') + page.goto('/_/?history-refresh=1#/users/members') + scoped(page, 'members') + cached = api_request(page, 'GET', '/api/collections').json() + collection(page, 'late') + requests, pending = [], [] + def metadata(route): + requests.append(route.request) + if len(requests) == 1: + pending.append(route) + else: + route.continue_() + page.route('**/api/collections', metadata) + with page.expect_request('**/api/collections'): + page.evaluate("location.hash = '#/users/late'") + expect(page.locator('[data-test=user-new]')).to_be_disabled() + page.click('[data-test=nav-collections]') + expect(page.locator('[data-test=collections-home]')).to_be_visible() + # Entering Users through history starts a fresh request; the older one stays + # in flight, with no route-cancellation marker to release. + with page.expect_request('**/api/collections'): + page.go_back() + scoped(page, 'late') + assert len(requests) == 2 + with page.expect_response('**/api/collections') as stale: + if stale_error: + pending[0].fulfill(status=503, json={'message': 'Superseded failure'}) + else: + pending[0].fulfill(json=cached) + stale.value.finished() + page.evaluate('() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)))') + scoped(page, 'late') + expect(page.locator('.sidebar .error')).to_have_count(0) + with page.expect_response('**/api/collections') as missing: + page.evaluate("location.hash = '#/users/missing'") + missing.value.finished() + expect(page.locator('[data-test=users-not-found]')).to_be_visible() + page.select_option('[data-test=users-collection]', 'late') + scoped(page, 'late') + page.go_back() + expect(page.locator('[data-test=users-not-found]')).to_be_visible() + page.evaluate('() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)))') + assert len(requests) == 3 + + +def test_record_cache_miss_has_one_metadata_owner_and_keeps_newer_sidebar(page): + login(page) + collection(page, 'known', 'base') + with page.expect_response('**/api/collections/_superusers/auth-refresh') as refreshed: + page.goto('/_/?metadata-owner=1#/collections/known/records') + refreshed.value.finished() + expect(page.locator('[data-test=new-record]')).to_be_enabled() + cached = api_request(page, 'GET', '/api/collections').json() + collection(page, 'newer', 'base') + fresh = api_request(page, 'GET', '/api/collections').json() + pending = [] + page.route('**/api/collections', lambda route: pending.append(route)) + with page.expect_request('**/api/collections'): + page.evaluate("location.hash = '#/collections/missing/records'") + page.evaluate('() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)))') + assert len(pending) in (1, 2) + # Older versions launched a child fallback alongside Shell's lookup. Let + # the newer response publish first, then deliver the child's older snapshot. + pending[-1].fulfill(json=fresh) + expect(page.locator('[data-test=nav-newer]')).to_be_visible() + if len(pending) > 1: + with page.expect_response('**/api/collections') as stale: + pending[0].fulfill(json=cached) + stale.value.finished() + page.evaluate('() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)))') + expect(page.locator('[data-test=nav-newer]')).to_be_visible() + expect(page.locator('[data-test=new-record]')).to_be_disabled() + assert len(pending) == 1 diff --git a/tests/admin/test_auth_json_order.py b/tests/admin/test_auth_json_order.py new file mode 100644 index 00000000..1134cf9f --- /dev/null +++ b/tests/admin/test_auth_json_order.py @@ -0,0 +1,58 @@ +"""JSON dirty tracking follows values rather than object member order.""" +import json +import pytest +from playwright.sync_api import expect +from conftest import login, api_request +from test_auth_navigation import collection + + +@pytest.mark.parametrize('multipart', [False, True]) +@pytest.mark.parametrize('edit', ['reordered', 'nested_value', 'array_order']) +def test_account_json_comparison_preserves_concurrent_preferences(page, multipart, edit): + login(page) + collection(page, 'members', fields=[ + {'name': 'preferences', 'type': 'json'}, + {'name': 'avatar', 'type': 'file'}, + ]) + original = {'theme': 'dark', 'nested': {'language': 'en', 'enabled': True}, + 'items': [{'name': 'first', 'id': 1}, {'name': 'second', 'id': 2}]} + created = api_request(page, 'POST', '/api/collections/members/records', { + 'email': 'json-order@example.com', 'password': 'safe password here', + 'preferences': original}) + assert created.ok, created.text() + endpoint = '/api/collections/members/records/' + created.json()['id'] + page.goto('/_/#/users/members') + page.click('[data-test=user-row]') + # Reorder members at every object depth, including objects inside arrays. + edited = {'items': [{'id': 1, 'name': 'first'}, {'id': 2, 'name': 'second'}], + 'nested': {'enabled': True, 'language': 'en'}, 'theme': 'dark'} + if edit == 'nested_value': + edited['nested']['language'] = 'fr' + elif edit == 'array_order': + edited['items'].reverse() + page.fill('[data-test=in-preferences]', json.dumps(edited)) + concurrent = {'theme': 'light', 'concurrent': True} + response = api_request(page, 'PATCH', endpoint, {'preferences': concurrent}) + assert response.ok, response.text() + page.fill('[data-test=user-password]', 'replacement safe password') + if multipart: + page.set_input_files('[data-test=in-avatar]', { + 'name': 'new.txt', 'mimeType': 'text/plain', 'buffer': b'new'}) + with page.expect_response(lambda r: r.url.endswith(endpoint) and r.request.method == 'PATCH') as write: + page.click('[data-test=user-save]') + assert write.value.ok, write.value.text() + expect(page.locator('[data-test=user-drawer]')).to_have_count(0) + if multipart: + assert 'multipart/form-data' in write.value.request.headers['content-type'] + else: + payload = {'password': 'replacement safe password'} + if edit != 'reordered': + payload['preferences'] = edited + assert write.value.request.post_data_json == payload + saved = api_request(page, 'GET', endpoint).json()['preferences'] + if isinstance(saved, str): + saved = json.loads(saved) + assert saved == (concurrent if edit == 'reordered' else edited) + authenticated = page.request.post('/api/collections/members/auth-with-password', data={ + 'identity': 'json-order@example.com', 'password': 'replacement safe password'}) + assert authenticated.ok, authenticated.text() diff --git a/tests/admin/test_auth_navigation.py b/tests/admin/test_auth_navigation.py new file mode 100644 index 00000000..1d89ee37 --- /dev/null +++ b/tests/admin/test_auth_navigation.py @@ -0,0 +1,997 @@ +"""Collection type determines the account workspace and complete account form.""" +import json +import re +import pytest +from playwright.sync_api import expect +from conftest import login, api_request + + +def collection(page, name, type="auth", fields=None, options=None): + body = {"name": name, "type": type, "fields": [{"id": "", "options": {}, **f} for f in (fields or [])]} + if options is not None: + body["options"] = options + response = api_request(page, "POST", "/api/collections", body) + assert response.ok, response.text() + return response.json() + + +def scoped(page, name): + expect(page).to_have_url(re.compile(r"#/users/" + name + r"$")) + expect(page.locator('[data-test=users-collection]')).to_have_value(name) + expect(page.locator('[data-test=user-new]')).to_be_enabled() + + +@pytest.mark.parametrize('kind', ['auth', 'base']) +def test_profile_controls_have_accessible_names(page, kind): + login(page) + groups = collection(page, 'groups', 'base') + group = api_request(page, 'POST', '/api/collections/groups/records', {}).json()['id'] + fields = [ + {'name': 'displayName', 'type': 'text', 'required': True}, + {'name': 'consent', 'type': 'bool', 'required': True}, + {'name': 'rating', 'type': 'number', 'required': True}, + {'name': 'visits', 'type': 'number', 'options': {'mode': 'int'}}, + {'name': 'balance', 'type': 'number', 'options': {'mode': 'fixed', 'scale': 2}}, + {'name': 'birthday', 'type': 'date'}, + {'name': 'contact', 'type': 'email'}, + {'name': 'website', 'type': 'url'}, + {'name': 'level', 'type': 'select', 'required': True, 'options': {'values': ['reader', 'editor']}}, + {'name': 'tags', 'type': 'select', 'options': {'values': ['a', 'b'], 'maxSelect': 2}}, + {'name': 'group', 'type': 'relation', 'options': {'targetCollectionId': groups['id']}}, + {'name': 'groups', 'type': 'relation', 'options': {'targetCollectionId': groups['id'], 'maxSelect': 2}}, + {'name': 'preferences', 'type': 'json', 'required': True}, + {'name': 'biography', 'type': 'editor', 'required': True}, + {'name': 'avatar', 'type': 'file', 'required': True, 'options': {'maxSelect': 2}}, + ] + collection(page, 'members', kind, fields) + page.goto('/_/#/users/members' if kind == 'auth' else '/_/#/collections/members/records') + page.click('[data-test=user-new]' if kind == 'auth' else '[data-test=new-record]') + drawer = page.locator('[data-test=user-drawer]' if kind == 'auth' else '[data-test=record-drawer]') + for field in fields: + control = drawer.get_by_label(field['name'], exact=True) + expect(control).to_have_count(1) + expect(control).to_have_accessible_name(field['name']) + assert control.get_attribute('id') + if field.get('required'): + expect(control).to_have_attribute('aria-required', 'true') + ids = drawer.locator('input[id], select[id], textarea[id], [contenteditable][id]').evaluate_all('(els) => els.map(el => el.id)') + assert len(ids) == len(set(ids)) + prefix = 'user-field-' if kind == 'auth' else 'record-field-' + drawer.locator(f'label[for={prefix}displayName]').click() + expect(drawer.get_by_label('displayName', exact=True)).to_be_focused() + drawer.locator(f'label[for={prefix}consent]').click() + expect(drawer.get_by_label('consent', exact=True)).to_be_checked() + expect(drawer.get_by_role('combobox', name='group', exact=True)).to_be_visible() + expect(drawer.get_by_role('listbox', name='groups', exact=True)).to_be_visible() + expect(drawer.get_by_role('listbox', name='tags', exact=True)).to_be_visible() + expect(drawer.get_by_role('textbox', name='preferences', exact=True)).to_be_visible() + if kind == 'auth': + drawer.get_by_label('Email', exact=True).fill('labels@example.com') + drawer.get_by_label('Username', exact=True).fill('labels') + drawer.get_by_label('Password', exact=True).fill('safe password here') + drawer.get_by_label('displayName', exact=True).fill('Accessible profile') + drawer.get_by_label('consent', exact=True).check() + for name, value in {'rating': '1.5', 'visits': '9007199254740993', 'balance': '12.34', 'birthday': '2026-11-07', 'contact': 'profile@example.com', 'website': 'https://example.com'}.items(): + drawer.get_by_label(name, exact=True).fill(value) + drawer.get_by_label('level', exact=True).select_option('reader') + drawer.get_by_label('tags', exact=True).select_option(['a', 'b']) + drawer.get_by_label('group', exact=True).select_option(group) + drawer.get_by_label('groups', exact=True).select_option([group]) + drawer.get_by_label('preferences', exact=True).fill('{"theme":"dark"}') + drawer.get_by_role('button', name='Format preferences JSON', exact=True).click() + rich_text = drawer.get_by_role('textbox', name='biography', exact=True) + expect(rich_text).to_have_attribute('aria-multiline', 'true') + rich_text.fill('Accessible biography') + expect(drawer.get_by_role('group', name='biography formatting', exact=True)).to_be_visible() + expect(drawer.get_by_role('button', name='Bold', exact=True)).to_be_visible() + drawer.get_by_label('avatar', exact=True).set_input_files({'name': 'avatar.txt', 'mimeType': 'text/plain', 'buffer': b'avatar'}) + save = '[data-test=user-save]' if kind == 'auth' else '[data-test=record-save]' + drawer.locator(save).click() + expect(drawer).to_have_count(0) + record = api_request(page, 'GET', '/api/collections/members/records').json()['items'][0] + assert record['displayName'] == 'Accessible profile' + assert record['biography'] == 'Accessible biography' + page.click('[data-test=user-row]' if kind == 'auth' else '[data-test=row]') + expect(drawer.locator('input[id], select[id], textarea[id], [contenteditable][id]')).to_have_count(len(ids)) + assert drawer.locator('input[id], select[id], textarea[id], [contenteditable][id]').evaluate_all('(els) => els.map(el => el.id)') == ids + expect(drawer.get_by_label('displayName', exact=True)).to_have_value('Accessible profile') + if kind == 'auth': + expect(drawer.get_by_label('Set new password (admin reset)', exact=True)).to_be_visible() + expect(drawer.get_by_role('checkbox', name=re.compile(r'^Remove avatar file '))).to_have_count(1) + drawer.get_by_role('checkbox', name=re.compile(r'^Remove avatar file ')).check() + # Replace the required file and preserve the same names on edit. + drawer.get_by_label('avatar', exact=True).set_input_files({'name': 'replacement.txt', 'mimeType': 'text/plain', 'buffer': b'replacement'}) + drawer.locator(save).click() + expect(drawer).to_have_count(0) + updated = api_request(page, 'GET', '/api/collections/members/records').json()['items'][0] + assert 'replacement' in str(updated['avatar']) + assert 'avatar' not in str(updated['avatar']) + + +def test_type_groups_multiple_auth_and_base_named_users(page): + login(page) + collection(page, "users", "base") + collection(page, "members") + collection(page, "staff") + page.reload() + for name in ("members", "staff", "_superusers"): + expect(page.locator(f'[data-test=nav-users-group] [data-test=nav-{name}]')).to_be_visible() + expect(page.locator(f'[data-test=nav-data-group] [data-test=nav-{name}]')).to_have_count(0) + page.click('[data-test=nav-members]') + scoped(page, "members") + expect(page.locator('[data-test=new-record]')).to_have_count(0) + page.click('[data-test=nav-users-group] [data-test=nav-staff]') + scoped(page, "staff") + page.click('[data-test=nav-data-group] [data-test=nav-users]') + expect(page.locator('[data-test=records-view]')).to_be_visible() + expect(page.locator('[data-test=new-record]')).to_be_visible() + + +def test_scoped_links_reload_history_and_legacy_handoff(page): + login(page) + collection(page, "members") + collection(page, "staff") + page.goto('/_/#/users/members') + scoped(page, "members") + page.select_option('[data-test=users-collection]', 'staff') + scoped(page, "staff") + page.go_back() + scoped(page, "members") + page.go_forward() + scoped(page, "staff") + page.reload() + scoped(page, "staff") + page.goto('/_/#/collections/members/records') + scoped(page, "members") + expect(page.locator('[data-test=new-record]')).to_have_count(0) + page.click('[data-test=users-settings]') + expect(page.locator('[data-test=schema-editor]')).to_be_visible() + expect(page.locator('[data-test=col-name]')).to_have_value('members') + expect(page.locator('[data-test=manage-users]')).to_have_attribute('href', '#/users/members') + page.click('[data-test=manage-users]') + scoped(page, "members") + page.click('[data-test=nav-collections]') + page.click('[data-test=settings-staff]') + expect(page.locator('[data-test=manage-users]')).to_have_attribute('href', '#/users/staff') + page.click('[data-test=nav-collections]') + expect(page.locator('[data-test=new-collection]')).to_be_visible() + + +def test_custom_required_profile_create_edit_and_password(page): + login(page) + collection(page, "members", fields=[ + {"name": "displayName", "type": "text", "required": True}, + {"name": "level", "type": "select", "required": True, "options": {"values": ["reader", "editor"]}}, + {"name": "preferences", "type": "json"}, + {"name": "joined", "type": "autodate"}, + ], options={"auth": {"identityFields": ["username"], "minPasswordLength": 12}}) + page.goto('/_/#/users/members') + scoped(page, "members") + page.click('[data-test=user-new]') + assert page.locator('[data-test=user-password]').get_attribute('minlength') is None + expect(page.locator('[data-test=user-password]')).to_have_attribute('required', '') + page.fill('[data-test=user-username]', 'river') + page.fill('[data-test=user-password]', 'a long safe password') + page.click('[data-test=user-save]') + expect(page.locator('[data-test=err-displayName]')).to_be_visible() + page.fill('[data-test=in-displayName]', 'River') + page.select_option('[data-test=in-level]', 'reader') + page.fill('[data-test=in-preferences]', '{broken') + expect(page.locator('[data-test=user-save]')).to_be_disabled() + page.fill('[data-test=in-preferences]', '{"theme":"dark"}') + expect(page.locator('[data-test=user-save]')).to_be_enabled() + for name in ('joined', 'verified', 'passwordHash', 'tokenKey', 'token_epoch', 'externalAuths', 'id'): + expect(page.locator(f'[data-test=in-{name}]')).to_have_count(0) + page.click('[data-test=user-save]') + expect(page.locator('[data-test=user-drawer]')).to_have_count(0) + page.click('[data-test=user-row]:has-text("river")') + expect(page.locator('[data-test=in-displayName]')).to_have_value('River') + expect(page.locator('[data-test=in-level]')).to_have_value('reader') + page.fill('[data-test=in-displayName]', 'River Updated') + page.select_option('[data-test=in-level]', 'editor') + page.fill('[data-test=user-password]', 'another long safe password') + page.click('[data-test=user-save]') + expect(page.locator('[data-test=user-drawer]')).to_have_count(0) + user = api_request(page, 'GET', '/api/collections/members/records').json()['items'][0] + assert user['displayName'] == 'River Updated' + assert user['level'] == 'editor' + preferences = user['preferences'] + assert (json.loads(preferences) if isinstance(preferences, str) else preferences) == {'theme': 'dark'} + response = page.request.post('/api/collections/members/auth-with-password', data={ + 'identity': 'river', 'password': 'another long safe password'}) + assert response.ok, response.text() + + +def test_profile_files_and_multi_select_roundtrip(page): + login(page) + groups = collection(page, 'groups', 'base') + group_ids = [api_request(page, 'POST', '/api/collections/groups/records', {}).json()['id'] for _ in range(2)] + # Relation membership is independent of the option-list order. + def reversed_options(route): + response = route.fetch() + data = response.json() + data['items'].sort(key=lambda record: group_ids.index(record['id']), reverse=True) + route.fulfill(response=response, json=data) + page.route(f"**/api/collections/{groups['id']}/records?*", reversed_options) + collection(page, 'members', fields=[ + {'name': 'groups', 'type': 'relation', 'options': {'targetCollectionId': groups['id'], 'maxSelect': 2}}, + {'name': 'avatar', 'type': 'file'}, + {'name': 'tags', 'type': 'select', 'options': {'values': ['a', 'b', 'c'], 'maxSelect': 3}}, + ]) + page.goto('/_/#/users/members') + scoped(page, 'members') + page.click('[data-test=user-new]') + page.fill('[data-test=user-email]', 'files@example.com') + page.fill('[data-test=user-password]', 'safe password here') + page.select_option('[data-test=in-groups]', group_ids) + page.select_option('[data-test=in-tags]', ['a', 'b']) + page.set_input_files('[data-test=in-avatar]', {'name': 'avatar.txt', 'mimeType': 'text/plain', 'buffer': b'avatar'}) + page.click('[data-test=user-save]') + expect(page.locator('[data-test=user-drawer]')).to_have_count(0) + page.click('[data-test=user-row]') + expect(page.locator('[data-test=rm-avatar]')).to_have_count(1) + expect(page.locator('[data-test=in-tags]')).to_have_values(['a', 'b']) + expect(page.locator('[data-test=in-groups] option')).to_have_text(list(reversed(group_ids))) + selected = page.locator('[data-test=in-groups] option:checked') + expect(selected).to_have_count(2) + assert sorted(selected.evaluate_all('(options) => options.map(option => option.value)')) == sorted(group_ids) + page.check('[data-test=rm-avatar]') + page.select_option('[data-test=in-tags]', ['c']) + page.click('[data-test=user-save]') + expect(page.locator('[data-test=user-drawer]')).to_have_count(0) + user = api_request(page, 'GET', '/api/collections/members/records').json()['items'][0] + assert not user['avatar'] + relations = user['groups'] + assert sorted(json.loads(relations) if isinstance(relations, str) else relations) == sorted(group_ids) + tags = user['tags'] + assert (json.loads(tags) if isinstance(tags, str) else tags) == ['c'] + + +def test_superuser_settings_and_collapsed_navigation(page): + login(page) + page.click('[data-test=nav-_superusers]') + scoped(page, '_superusers') + page.click('[data-test=users-settings]') + expect(page.locator('[data-test=delete-collection]')).to_have_count(0) + expect(page.locator('[data-test=col-name]')).to_be_disabled() + expect(page.locator('[data-test=manage-users]')).to_have_attribute('href', '#/users/_superusers') + page.click('[data-test=sidebar-toggle]') + page.click('[data-test=nav-_superusers]') + scoped(page, '_superusers') + page.click('[data-test=nav-collections]') + expect(page.locator('[data-test=new-collection]')).to_be_visible() + + +def test_unknown_auth_collection_does_not_select_default(page): + login(page) + page.goto('/_/#/users/missing') + expect(page.locator('[data-test=users-not-found]')).to_be_visible() + expect(page.locator('[data-test=user-new]')).to_be_disabled() + expect(page.locator('[data-test=user-row]')).to_have_count(0) + + +def test_empty_users_workspace(page): + login(page) + page.route('**/api/collections', lambda route: route.fulfill(json={'items': []})) + page.goto('/_/#/users') + page.reload() # Start a fresh Shell with the mocked collection-list snapshot. + expect(page.locator('[data-test=users-empty]')).to_be_visible() + expect(page.locator('[data-test=user-new]')).to_be_disabled() + expect(page.locator('[data-test=new-collection]')).to_have_count(0) + page.click('[data-test=users-empty] a') + expect(page.locator('[data-test=schema-editor]')).to_be_visible() + + +def test_auth_collection_creation_lands_in_its_users_workspace(page): + from conftest import save_collection_and_wait + login(page) + page.click('[data-test=nav-collections]') + page.click('[data-test=new-collection]') + page.fill('[data-test=col-name]', 'subscribers') + page.select_option('[data-test=col-type]', 'auth') + save_collection_and_wait(page) + scoped(page, 'subscribers') + expect(page.locator('[data-test=new-record]')).to_have_count(0) + page.click('[data-test=users-settings]') + expect(page.locator('[data-test=manage-users]')).to_have_attribute('href', '#/users/subscribers') + + +def test_float_and_exact_decimal_profile_fields_without_files(page): + login(page) + collection(page, 'members', fields=[ + {'name': 'rating', 'type': 'number', 'options': {'mode': 'float'}}, + {'name': 'visits', 'type': 'number', 'options': {'mode': 'int'}}, + {'name': 'balance', 'type': 'number', 'options': {'mode': 'fixed', 'scale': 2}}, + ]) + page.goto('/_/#/users/members') + scoped(page, 'members') + page.click('[data-test=user-new]') + page.fill('[data-test=user-email]', 'numbers@example.com') + page.fill('[data-test=user-password]', 'safe password here') + page.fill('[data-test=in-rating]', 'invalid') + page.fill('[data-test=in-visits]', '9007199254740993') + page.fill('[data-test=in-balance]', '12.34') + page.click('[data-test=user-save]') + expect(page.locator('[data-test=user-error]')).to_be_visible() + assert api_request(page, 'GET', '/api/collections/members/records').json()['totalItems'] == 0 + page.fill('[data-test=in-rating]', '1.5') + page.click('[data-test=user-save]') + expect(page.locator('[data-test=user-drawer]')).to_have_count(0) + user = api_request(page, 'GET', '/api/collections/members/records').json()['items'][0] + assert user['rating'] == 1.5 + assert user['visits'] == '9007199254740993' + assert user['balance'] == '12.34' + page.click('[data-test=user-row]') + page.fill('[data-test=in-rating]', '2.75') + page.click('[data-test=user-save]') + expect(page.locator('[data-test=user-drawer]')).to_have_count(0) + user = api_request(page, 'GET', '/api/collections/members/records').json()['items'][0] + assert user['rating'] == 2.75 + assert user['visits'] == '9007199254740993' + assert user['balance'] == '12.34' + page.click('[data-test=user-row]') + for name in ('rating', 'visits', 'balance'): + page.fill(f'[data-test=in-{name}]', '') + page.click('[data-test=user-save]') + expect(page.locator('[data-test=user-drawer]')).to_have_count(0) + user = api_request(page, 'GET', '/api/collections/members/records').json()['items'][0] + assert all(user[name] is None for name in ('rating', 'visits', 'balance')) + + +def test_required_boolean_profile_defaults_false_and_preserves_edits(page): + login(page) + collection(page, 'members', fields=[{'name': 'optedIn', 'type': 'bool', 'required': True}]) + page.goto('/_/#/users/members') + scoped(page, 'members') + page.click('[data-test=user-new]') + page.fill('[data-test=user-email]', 'boolean@example.com') + page.fill('[data-test=user-password]', 'safe password here') + expect(page.locator('[data-test=in-optedIn]')).not_to_be_checked() + page.click('[data-test=user-save]') + expect(page.locator('[data-test=user-drawer]')).to_have_count(0) + user = api_request(page, 'GET', '/api/collections/members/records').json()['items'][0] + assert user['optedIn'] is False + page.click('[data-test=user-row]') + page.check('[data-test=in-optedIn]') + page.click('[data-test=user-save]') + expect(page.locator('[data-test=user-drawer]')).to_have_count(0) + page.click('[data-test=user-row]') + expect(page.locator('[data-test=in-optedIn]')).to_be_checked() + page.click('[data-test=user-save]') + expect(page.locator('[data-test=user-drawer]')).to_have_count(0) + user = api_request(page, 'GET', '/api/collections/members/records').json()['items'][0] + assert user['optedIn'] is True + + +@pytest.mark.parametrize('profile_value', ['true', '', True, False, 1.5, {'a': [1]}, [1, 'x'], None]) +def test_multipart_profile_json_types_and_explicit_clears(page, profile_value): + login(page) + groups = collection(page, 'groups', 'base') + group = api_request(page, 'POST', '/api/collections/groups/records', {}).json()['id'] + collection(page, 'members', fields=[ + {'name': 'profile', 'type': 'json'}, + {'name': 'group', 'type': 'relation', 'options': {'targetCollectionId': groups['id'], 'maxSelect': 1}}, + {'name': 'avatar', 'type': 'file'}, + ]) + response = api_request(page, 'POST', '/api/collections/members/records', { + 'email': 'multipart@example.com', 'password': 'safe password here', 'profile': {'old': True}, 'group': group}) + assert response.ok, response.text() + user_id = response.json()['id'] + page.goto('/_/#/users/members') + scoped(page, 'members') + page.click('[data-test=user-row]') + page.fill('[data-test=in-profile]', '' if profile_value is None else json.dumps(profile_value)) + page.select_option('[data-test=in-group]', '') + page.set_input_files('[data-test=in-avatar]', {'name': 'avatar.txt', 'mimeType': 'text/plain', 'buffer': b'avatar'}) + page.click('[data-test=user-save]') + expect(page.locator('[data-test=user-drawer]')).to_have_count(0) + user = api_request(page, 'GET', f'/api/collections/members/records/{user_id}').json() + assert user['profile'] == profile_value + assert type(user['profile']) is type(profile_value) + assert not user['group'] + assert user['avatar'] + # A second multipart PATCH clears both fields while removing the existing + # file, rather than uploading one. Re-seed the relation for a real clear. + response = api_request(page, 'PATCH', f'/api/collections/members/records/{user_id}', {'group': group, 'profile': {'old': True}}) + assert response.ok, response.text() + page.reload() + page.click('[data-test=user-row]') + page.fill('[data-test=in-profile]', '') + page.select_option('[data-test=in-group]', '') + page.check('[data-test=rm-avatar]') + page.click('[data-test=user-save]') + expect(page.locator('[data-test=user-drawer]')).to_have_count(0) + user = api_request(page, 'GET', f'/api/collections/members/records/{user_id}').json() + assert user['profile'] is None + assert not user['group'] + assert not user['avatar'] + + +def test_relation_picker_retains_off_page_references_until_removed(page): + login(page) + groups = collection(page, 'groups', 'base') + for _ in range(55): + response = api_request(page, 'POST', '/api/collections/groups/records', {}) + assert response.ok, response.text() + first_page = api_request(page, 'GET', '/api/collections/groups/records?perPage=50').json()['items'] + all_groups = api_request(page, 'GET', '/api/collections/groups/records?perPage=100').json()['items'] + visible = first_page[0]['id'] + outside = next(g['id'] for g in all_groups if g['id'] not in {r['id'] for r in first_page}) + collection(page, 'members', fields=[ + {'name': 'groups', 'type': 'relation', 'options': {'targetCollectionId': groups['id'], 'maxSelect': 2}}, + ]) + response = api_request(page, 'POST', '/api/collections/members/records', { + 'email': 'relations@example.com', 'password': 'safe password here', 'groups': [outside]}) + assert response.ok, response.text() + page.goto('/_/#/users/members') + scoped(page, 'members') + page.click('[data-test=user-row]') + picker = page.locator('[data-test=in-groups]') + expect(picker.locator(f'option[value="{visible}"]')).to_have_count(1) + expect(picker).to_have_values([outside]) + # Add a visible option as a user would with Ctrl-click, keeping the existing + # off-page option selected. Every selected reference must exist in the DOM. + picker.evaluate("""(select, id) => { + [...select.options].find(option => option.value === id).selected = true; + select.dispatchEvent(new Event('change', { bubbles: true })); + }""", visible) + page.click('[data-test=user-save]') + expect(page.locator('[data-test=user-drawer]')).to_have_count(0) + user = api_request(page, 'GET', '/api/collections/members/records').json()['items'][0] + assert sorted(user['groups']) == sorted([outside, visible]) + page.click('[data-test=user-row]') + page.select_option('[data-test=in-groups]', [visible]) + page.click('[data-test=user-save]') + expect(page.locator('[data-test=user-drawer]')).to_have_count(0) + user = api_request(page, 'GET', '/api/collections/members/records').json()['items'][0] + assert user['groups'] == [visible] + + +@pytest.mark.parametrize('kind', ['base', 'auth']) +@pytest.mark.parametrize('initial_status', [None, 'active']) +@pytest.mark.parametrize('file_change', ['upload', 'remove', 'none']) +def test_optional_single_select_file_changes_and_clear(page, kind, initial_status, file_change): + from conftest import csrf + login(page) + collection(page, 'profiles', kind, fields=[ + {'name': 'status', 'type': 'select', 'options': {'values': ['active', 'paused'], 'maxSelect': 1}}, + {'name': 'avatar', 'type': 'file'}, + {'name': 'note', 'type': 'text'}, + ]) + body = {'note': 'initial'} + if initial_status is not None: + body['status'] = initial_status + if kind == 'auth': + body.update(email='select@example.com', password='safe password here') + response = api_request(page, 'POST', '/api/collections/profiles/records', body) + assert response.ok, response.text() + record_id = response.json()['id'] + endpoint = f'/api/collections/profiles/records/{record_id}' + if file_change == 'remove': + response = page.request.patch(endpoint, headers={'X-CSRF-Token': csrf(page)}, multipart={ + 'avatar': {'name': 'avatar.txt', 'mimeType': 'text/plain', 'buffer': b'avatar'}}) + assert response.ok, response.text() + page.goto('/_/#/users/profiles' if kind == 'auth' else '/_/#/collections/profiles/records') + page.click('[data-test=user-row]' if kind == 'auth' else '[data-test=row]') + expect(page.locator('[data-test=in-status]')).to_have_value(initial_status or '') + page.fill('[data-test=in-note]', 'updated profile') + if initial_status: + page.select_option('[data-test=in-status]', '') + if file_change == 'upload': + page.set_input_files('[data-test=in-avatar]', {'name': 'avatar.txt', 'mimeType': 'text/plain', 'buffer': b'avatar'}) + elif file_change == 'remove': + page.check('[data-test=rm-avatar]') + with page.expect_request(lambda request: request.url.endswith(endpoint) and request.method == 'PATCH') as write: + page.click('[data-test=user-save]' if kind == 'auth' else '[data-test=record-save]') + submitted = write.value + if file_change == 'none': + assert 'application/json' in submitted.headers['content-type'] + if kind == 'auth' and initial_status is None: + assert 'status' not in submitted.post_data_json # unchanged profile values are omitted + else: + assert submitted.post_data_json['status'] is None + assert submitted.post_data_json['note'] == 'updated profile' + else: + assert 'multipart/form-data' in submitted.headers['content-type'] + expect(page.locator('[data-test=user-drawer]' if kind == 'auth' else '[data-test=record-drawer]')).to_have_count(0) + saved = api_request(page, 'GET', endpoint).json() + assert saved['status'] is None + assert saved['note'] == 'updated profile' + assert bool(saved['avatar']) == (file_change == 'upload') + + +@pytest.mark.parametrize('kind', ['base', 'auth']) +@pytest.mark.parametrize('initial,choice,upload,expected', [ + ('active', '(empty)', False, ''), + ('active', '(empty)', True, ''), + ('active', '—', False, None), + ('active', '—', True, None), + (None, None, True, None), + ('', None, False, ''), +]) +def test_declared_empty_select_option_stays_distinct_from_no_selection(page, kind, initial, choice, upload, expected): + login(page) + collection(page, 'profiles', kind, fields=[ + {'name': 'status', 'type': 'select', 'options': {'values': ['', 'active'], 'maxSelect': 1}}, + {'name': 'avatar', 'type': 'file'}, + {'name': 'note', 'type': 'text'}, + ]) + body = {'note': 'initial'} + if initial is not None: + body['status'] = initial + if kind == 'auth': + body.update(email='empty@example.com', password='safe password here') + response = api_request(page, 'POST', '/api/collections/profiles/records', body) + assert response.ok, response.text() + endpoint = f"/api/collections/profiles/records/{response.json()['id']}" + page.goto('/_/#/users/profiles' if kind == 'auth' else '/_/#/collections/profiles/records') + page.click('[data-test=user-row]' if kind == 'auth' else '[data-test=row]') + shown = page.locator('[data-test=in-status] option:checked') + expect(shown).to_have_text('active' if initial == 'active' else '(empty)' if initial == '' else '—') + page.fill('[data-test=in-note]', 'updated profile') + if choice is not None: + page.select_option('[data-test=in-status]', label=choice) + if upload: + page.set_input_files('[data-test=in-avatar]', {'name': 'avatar.txt', 'mimeType': 'text/plain', 'buffer': b'avatar'}) + page.click('[data-test=user-save]' if kind == 'auth' else '[data-test=record-save]') + expect(page.locator('[data-test=user-drawer]' if kind == 'auth' else '[data-test=record-drawer]')).to_have_count(0) + saved = api_request(page, 'GET', endpoint).json() + assert saved['status'] == expected + assert saved['note'] == 'updated profile' + + +def test_select_placeholder_never_collides_with_a_declared_option(page): + login(page) + option = '__zigbase_no_selection__' + collection(page, 'profiles', 'base', fields=[ + {'name': 'status', 'type': 'select', 'options': {'values': ['', option], 'maxSelect': 1}}, + ]) + response = api_request(page, 'POST', '/api/collections/profiles/records', {}) + assert response.ok, response.text() + endpoint = f"/api/collections/profiles/records/{response.json()['id']}" + page.goto('/_/#/collections/profiles/records') + page.click('[data-test=row]') + page.select_option('[data-test=in-status]', label=option) + page.click('[data-test=record-save]') + expect(page.locator('[data-test=record-drawer]')).to_have_count(0) + assert api_request(page, 'GET', endpoint).json()['status'] == option + + +def test_non_ascii_password_creation_and_admin_reset_use_utf8_bytes(page): + login(page) + collection(page, 'members') + page.goto('/_/#/users/members') + scoped(page, 'members') + page.click('[data-test=user-new]') + page.fill('[data-test=user-email]', 'unicode@example.com') + page.fill('[data-test=user-password]', 'ééé') # Six UTF-8 bytes: below the default minimum. + page.click('[data-test=user-save]') + expect(page.locator('[data-test=user-error]')).to_contain_text('too short') + page.fill('[data-test=user-password]', 'éééé') # Four UTF-16 units, eight UTF-8 bytes. + page.click('[data-test=user-save]') + expect(page.locator('[data-test=user-drawer]')).to_have_count(0) + page.click('[data-test=user-row]') + page.fill('[data-test=user-password]', '😀😀') # Another valid eight-byte password. + page.click('[data-test=user-save]') + expect(page.locator('[data-test=user-drawer]')).to_have_count(0) + response = page.request.post('/api/collections/members/auth-with-password', data={ + 'identity': 'unicode@example.com', 'password': '😀😀'}) + assert response.ok, response.text() + + +@pytest.mark.parametrize('multipart', [False, True], ids=['json', 'multipart']) +def test_zero_password_minimum_create_and_edit(page, multipart): + login(page) + collection(page, 'members', fields=[{'name': 'note', 'type': 'text'}, {'name': 'avatar', 'type': 'file'}], + options={'auth': {'minPasswordLength': 0}}) + # A present empty password is supported by the owning REST create path. + reference = api_request(page, 'POST', '/api/collections/members/records', {'email': 'reference@example.com', 'password': ''}) + assert reference.ok, reference.text() + page.goto('/_/#/users/members') + page.click('[data-test=user-new]') + page.get_by_label('Email', exact=True).fill('zero@example.com') + page.get_by_label('note', exact=True).fill('initial') + assert page.get_by_label('Password', exact=True).get_attribute('required') is None + + def upload(name): + if multipart: + page.get_by_label('avatar', exact=True).set_input_files({'name': name, 'mimeType': 'text/plain', 'buffer': b'avatar'}) + + def save(method, endpoint): + with page.expect_response(lambda response: response.url.endswith(endpoint) and response.request.method == method) as write: + page.click('[data-test=user-save]') + assert write.value.ok, write.value.text() + expect(page.locator('[data-test=user-drawer]')).to_have_count(0) + request = write.value.request + if multipart: + assert 'multipart/form-data' in request.headers['content-type'] + # File-bearing request bodies are unavailable through Chromium's + # request.post_data; successful creation and login prove the value. + return None + assert 'application/json' in request.headers['content-type'] + return [request.post_data_json['password']] if 'password' in request.post_data_json else [] + + upload('initial.txt') + assert save('POST', '/api/collections/members/records') == (None if multipart else ['']) + record = next(user for user in api_request(page, 'GET', '/api/collections/members/records').json()['items'] if user['email'] == 'zero@example.com') + endpoint = '/api/collections/members/records/' + record['id'] + page.locator('[data-test=user-row]').filter(has_text='zero@example.com').click() + page.get_by_label('note', exact=True).fill('changed without password') + upload('profile.txt') + assert save('PATCH', endpoint) == (None if multipart else []) + response = page.request.post('/api/collections/members/auth-with-password', data={'identity': 'zero@example.com', 'password': ''}) + assert response.ok, response.text() + login(page) + page.goto('/_/#/users/members') + page.locator('[data-test=user-row]').filter(has_text='zero@example.com').click() + page.get_by_label('Set new password (admin reset)', exact=True).fill('é') + upload('reset.txt') + assert save('PATCH', endpoint) == (None if multipart else ['é']) + page.locator('[data-test=user-row]').filter(has_text='zero@example.com').click() + page.get_by_label('note', exact=True).fill('blank reset preserves nonempty password') + upload('unchanged-password.txt') + assert save('PATCH', endpoint) == (None if multipart else []) + saved = api_request(page, 'GET', endpoint).json() + assert saved['note'] == 'blank reset preserves nonempty password' + response = page.request.post('/api/collections/members/auth-with-password', data={'identity': 'zero@example.com', 'password': 'é'}) + assert response.ok, response.text() + + +@pytest.mark.parametrize('unsaved_name', ['not_saved_yet', 'staff']) +def test_manage_users_keeps_persisted_collection_for_unsaved_name(page, unsaved_name): + login(page) + collection(page, 'members') + collection(page, 'staff') + for name in ('members', 'staff'): + response = api_request(page, 'POST', f'/api/collections/{name}/records', { + 'email': f'{name}@example.com', 'password': 'safe password here'}) + assert response.ok, response.text() + page.goto('/_/#/collections/members') + page.fill('[data-test=col-name]', unsaved_name) + expect(page.locator('[data-test=manage-users]')).to_have_attribute('href', '#/users/members') + page.click('[data-test=manage-users]') + scoped(page, 'members') + expect(page.locator('[data-test=user-row]')).to_contain_text('members@example.com') + definitions = api_request(page, 'GET', '/api/collections').json()['items'] + assert any(c['name'] == 'members' for c in definitions) + + +@pytest.mark.parametrize('existing', [False, True], ids=['create', 'existing']) +def test_international_email_account_creation_profile_and_password_reset(page, existing): + login(page) + collection(page, 'members', fields=[{'name': 'note', 'type': 'text'}]) + email = 'élise@example.com' + if existing: + response = api_request(page, 'POST', '/api/collections/members/records', { + 'email': email, 'password': 'safe password here', 'note': 'initial'}) + assert response.ok, response.text() + page.goto('/_/#/users/members') + scoped(page, 'members') + if not existing: + page.click('[data-test=user-new]') + page.fill('[data-test=user-email]', email) + page.fill('[data-test=user-password]', 'safe password here') + page.fill('[data-test=in-note]', 'initial') + page.click('[data-test=user-save]') + expect(page.locator('[data-test=user-drawer]')).to_have_count(0) + page.click('[data-test=user-row]') + expect(page.locator('[data-test=user-email]')).to_have_value(email) + page.fill('[data-test=in-note]', 'updated profile') + page.click('[data-test=user-save]') + expect(page.locator('[data-test=user-drawer]')).to_have_count(0) + page.click('[data-test=user-row]') + expect(page.locator('[data-test=user-email]')).to_have_value(email) + page.fill('[data-test=user-password]', 'new safe password here') + page.click('[data-test=user-save]') + expect(page.locator('[data-test=user-drawer]')).to_have_count(0) + saved = api_request(page, 'GET', '/api/collections/members/records').json()['items'][0] + assert saved['email'] == email + assert saved['note'] == 'updated profile' + response = page.request.post('/api/collections/members/auth-with-password', data={ + 'identity': email, 'password': 'new safe password here'}) + assert response.ok, response.text() + + +@pytest.mark.parametrize('kind', ['base', 'auth']) +@pytest.mark.parametrize('file_change', ['none', 'upload', 'remove']) +@pytest.mark.parametrize('input_value,expected,accepted', [ + pytest.param('1.5', 1.5, True, id='decimal'), + pytest.param('0b10', 2, True, id='binary'), + pytest.param('0x10', 16, True, id='hexadecimal'), + pytest.param('0o10', 8, True, id='octal'), + pytest.param('1e2', 100, True, id='exponent'), + pytest.param('1e20', 1e20, True, id='large-integral-token'), + pytest.param('-1e20', -1e20, True, id='negative-large-integral-token'), + pytest.param('9223372036854775808', float(9223372036854775808), True, id='i64-positive-edge'), + pytest.param('-9223372036854775808', float(-9223372036854775808), True, id='i64-negative-edge'), + pytest.param('1e21', 1e21, True, id='large-exponent-token'), + pytest.param('-1e21', -1e21, True, id='negative-large-exponent-token'), + pytest.param('1.7976931348623157e308', 1.7976931348623157e308, True, id='maximum-finite'), + pytest.param('1e-999', 0, True, id='underflow'), + pytest.param('-0', 0, True, id='negative-zero'), + pytest.param(' 1.5 ', 1.5, True, id='padded-decimal'), + pytest.param('', None, True, id='empty-clear'), + pytest.param(' ', None, False, id='whitespace'), + pytest.param('NaN', None, False, id='nan'), + pytest.param('Infinity', None, False, id='infinity'), + pytest.param('1e999', None, False, id='overflow'), + pytest.param('1_000', None, False, id='underscore'), + pytest.param('0x1p4', None, False, id='hex-float'), + pytest.param('-0x10', None, False, id='signed-hex'), +]) +def test_float_transport_parity_and_exact_decimals(page, kind, file_change, input_value, expected, accepted): + from conftest import csrf + login(page) + collection(page, 'profiles', kind, fields=[ + {'name': 'rating', 'type': 'number', 'options': {'mode': 'float'}}, + {'name': 'visits', 'type': 'number', 'options': {'mode': 'int'}}, + {'name': 'balance', 'type': 'number', 'options': {'mode': 'fixed', 'scale': 2}}, + {'name': 'note', 'type': 'text'}, + {'name': 'avatar', 'type': 'file'}, + ]) + body = {'rating': 1.25, 'visits': '9007199254740993', 'balance': '12.34', 'note': 'initial'} + if kind == 'auth': + body.update(email='numeric@example.com', password='safe password here') + response = api_request(page, 'POST', '/api/collections/profiles/records', body) + assert response.ok, response.text() + record_id = response.json()['id'] + endpoint = f'/api/collections/profiles/records/{record_id}' + if file_change == 'remove': + response = page.request.patch(endpoint, headers={'X-CSRF-Token': csrf(page)}, multipart={ + 'avatar': {'name': 'avatar.txt', 'mimeType': 'text/plain', 'buffer': b'avatar'}}) + assert response.ok, response.text() + page.goto('/_/#/users/profiles' if kind == 'auth' else '/_/#/collections/profiles/records') + page.click('[data-test=user-row]' if kind == 'auth' else '[data-test=row]') + page.fill('[data-test=in-rating]', input_value) + page.fill('[data-test=in-visits]', '9007199254740995') + page.fill('[data-test=in-balance]', '34.56') + page.fill('[data-test=in-note]', 'updated profile') + if file_change == 'upload': + page.set_input_files('[data-test=in-avatar]', {'name': 'avatar.txt', 'mimeType': 'text/plain', 'buffer': b'avatar'}) + elif file_change == 'remove': + page.check('[data-test=rm-avatar]') + with page.expect_response(lambda response: response.url.endswith(endpoint) and response.request.method == 'PATCH') as write: + page.click('[data-test=user-save]' if kind == 'auth' else '[data-test=record-save]') + assert write.value.status == (200 if accepted else 400), write.value.text() + submitted = write.value.request + if file_change == 'none': + assert 'application/json' in submitted.headers['content-type'] + wire = submitted.post_data_json + if accepted and expected is not None: + assert isinstance(wire['rating'], (int, float)) + assert float(wire['rating']) == expected + else: + assert wire['rating'] == (expected if accepted else input_value) + if input_value == '1e20': + assert '"rating":100000000000000000000' in submitted.post_data + assert wire['visits'] == '9007199254740995' + assert wire['balance'] == '34.56' + else: + assert 'multipart/form-data' in submitted.headers['content-type'] + saved = api_request(page, 'GET', endpoint).json() + if accepted: + expect(page.locator('[data-test=user-drawer]' if kind == 'auth' else '[data-test=record-drawer]')).to_have_count(0) + if expected is None: + assert saved['rating'] is None + else: + assert isinstance(saved['rating'], (int, float)) + assert float(saved['rating']) == expected + assert saved['visits'] == '9007199254740995' + assert saved['balance'] == '34.56' + assert saved['note'] == 'updated profile' + assert bool(saved['avatar']) == (file_change == 'upload') + else: + assert saved['rating'] == 1.25 + assert saved['visits'] == '9007199254740993' + assert saved['balance'] == '12.34' + assert saved['note'] == 'initial' + assert bool(saved['avatar']) == (file_change == 'remove') + + +@pytest.mark.parametrize('kind', ['base', 'auth']) +@pytest.mark.parametrize('token,expected,error_bound', [ + pytest.param('100000000000000000000', 1e20, None, id='positive-integral-token'), + pytest.param('-100000000000000000000', -1e20, None, id='negative-integral-token'), + pytest.param('9223372036854775808', float(9223372036854775808), None, id='positive-i64-overflow'), + pytest.param('-9223372036854775809', float(-9223372036854775809), None, id='negative-i64-overflow'), + pytest.param('200000000000000000000', None, 'maximum', id='above-max'), + pytest.param('-200000000000000000000', None, 'minimum', id='below-min'), + pytest.param('1e999', None, None, id='exponent-overflow'), + pytest.param('9' * 400, None, None, id='integral-overflow'), + pytest.param('"100000000000000000000"', None, None, id='quoted-integral-string'), + pytest.param('"1e20"', None, None, id='quoted-exponent-string'), + pytest.param('NaN', None, None, id='nan-token'), + pytest.param('Infinity', None, None, id='infinity-token'), +]) +def test_raw_json_float_tokens_enforce_finite_bounds(page, kind, token, expected, error_bound): + from conftest import csrf + login(page) + collection(page, 'profiles', kind, fields=[ + {'name': 'rating', 'type': 'number', 'options': {'mode': 'float'}}, + {'name': 'bounded', 'type': 'number', 'options': {'mode': 'float', 'min': -1e20, 'max': 1e20}}, + {'name': 'visits', 'type': 'number', 'options': {'mode': 'int'}}, + {'name': 'balance', 'type': 'number', 'options': {'mode': 'fixed', 'scale': 2}}, + ]) + credentials = '"email":"raw@example.com","password":"safe password here",' if kind == 'auth' else '' + # Pass the integral token verbatim: Python's json.dumps(1e20) would emit an + # exponent and miss std.json's number_string branch for oversized integers. + bounded = token if error_bound else '0' + raw = '{' + credentials + '"rating":' + token + ',"bounded":' + bounded + ',"visits":"9007199254740993","balance":"12.34"}' + headers = {'X-CSRF-Token': csrf(page), 'Content-Type': 'application/json'} + response = page.request.post('/api/collections/profiles/records', headers=headers, data=raw) + assert response.status == (201 if expected is not None else 400), response.text() + records = api_request(page, 'GET', '/api/collections/profiles/records').json() + if expected is None: + assert records['totalItems'] == 0 + if error_bound: + assert error_bound in response.json()['data']['bounded']['message'] + return + assert records['totalItems'] == 1 + saved = records['items'][0] + assert isinstance(saved['rating'], (int, float)) + assert float(saved['rating']) == expected + assert saved['bounded'] == 0 + assert saved['visits'] == '9007199254740993' + assert saved['balance'] == '12.34' + endpoint = f'/api/collections/profiles/records/{saved["id"]}' + response = page.request.patch(endpoint, headers=headers, data='{"bounded":200000000000000000000}') + assert response.status == 400, response.text() + assert 'maximum' in response.json()['data']['bounded']['message'] + saved = api_request(page, 'GET', endpoint).json() + assert float(saved['rating']) == expected + assert saved['bounded'] == 0 + assert saved['visits'] == '9007199254740993' + assert saved['balance'] == '12.34' + + +@pytest.mark.parametrize('kind', ['base', 'auth']) +@pytest.mark.parametrize('file_change', ['none', 'upload', 'remove']) +def test_nullable_text_profile_fields_preserve_null_and_explicit_clear(page, kind, file_change): + from conftest import csrf + login(page) + nullable = {'nickname': 'text', 'birthday': 'date', 'bio': 'editor', 'website': 'url', 'contact': 'email'} + collection(page, 'profiles', kind, fields=[ + *({'name': name, 'type': field_type} for name, field_type in nullable.items()), + {'name': 'note', 'type': 'text'}, + {'name': 'clearable', 'type': 'text'}, + {'name': 'clearableBio', 'type': 'editor'}, + {'name': 'draftBio', 'type': 'editor'}, + {'name': 'avatar', 'type': 'file'}, + ]) + body = {name: None for name in nullable} + body.update(note='initial', clearable='erase me', clearableBio='

erase me

', draftBio=None) + if kind == 'auth': + body.update(email='nullable@example.com', password='safe password here') + response = api_request(page, 'POST', '/api/collections/profiles/records', body) + assert response.ok, response.text() + record_id = response.json()['id'] + endpoint = f'/api/collections/profiles/records/{record_id}' + if file_change == 'remove': + response = page.request.patch(endpoint, headers={'X-CSRF-Token': csrf(page)}, multipart={ + 'avatar': {'name': 'avatar.txt', 'mimeType': 'text/plain', 'buffer': b'avatar'}}) + assert response.ok, response.text() + initial = api_request(page, 'GET', endpoint).json() + assert all(initial[name] is None for name in nullable) + page.goto('/_/#/users/profiles' if kind == 'auth' else '/_/#/collections/profiles/records') + page.click('[data-test=user-row]' if kind == 'auth' else '[data-test=row]') + page.get_by_label('bio', exact=True).focus() + page.get_by_label('note', exact=True).focus() # Blur alone must preserve SQL null. + page.get_by_label('clearableBio', exact=True).fill('') + page.get_by_label('draftBio', exact=True).fill('temporary draft') + page.get_by_label('draftBio', exact=True).fill('') # Typing then clearing is intentional. + page.fill('[data-test=in-note]', 'updated profile') + page.fill('[data-test=in-clearable]', '') + if file_change == 'upload': + page.set_input_files('[data-test=in-avatar]', {'name': 'avatar.txt', 'mimeType': 'text/plain', 'buffer': b'avatar'}) + elif file_change == 'remove': + page.check('[data-test=rm-avatar]') + page.click('[data-test=user-save]' if kind == 'auth' else '[data-test=record-save]') + expect(page.locator('[data-test=user-drawer]' if kind == 'auth' else '[data-test=record-drawer]')).to_have_count(0) + saved = api_request(page, 'GET', endpoint).json() + assert all(saved[name] is None for name in nullable) + assert saved['note'] == 'updated profile' + assert saved['clearable'] == '' + # Chromium can retain empty paragraph/line-break markup after user clearing. + # Both deliberate clears must persist nonnull HTML with no remaining text. + for name in ('clearableBio', 'draftBio'): + assert saved[name] is not None + assert re.sub(r'<[^>]*>', '', saved[name]) == '' + assert bool(saved['avatar']) == (file_change == 'upload') + + +@pytest.mark.parametrize('kind', ['base', 'auth']) +@pytest.mark.parametrize('minimum,maximum,accepted', [ + pytest.param('-100000000000000000000', '100000000000000000000', True, id='integral-bounds'), + pytest.param('-1e20', '1e20', True, id='exponent-bounds'), + pytest.param('"-1e20"', '100000000000000000000', False, id='string-min'), + pytest.param('-100000000000000000000', '"1e20"', False, id='string-max'), + pytest.param('-1e999', '100000000000000000000', False, id='overflow-min'), + pytest.param('-100000000000000000000', '9' * 400, False, id='overflow-max'), + pytest.param('NaN', '100000000000000000000', False, id='nan-min'), + pytest.param('-100000000000000000000', 'Infinity', False, id='infinite-max'), +]) +def test_large_numeric_schema_bounds_api_roundtrip(page, kind, minimum, maximum, accepted): + from conftest import csrf + login(page) + raw = ('{"name":"profiles","type":"' + kind + '","fields":[' + '{"id":"","name":"rating","type":"number","options":{"mode":"float","min":' + + minimum + ',"max":' + maximum + '}}]}') + response = page.request.post('/api/collections', headers={ + 'X-CSRF-Token': csrf(page), 'Content-Type': 'application/json'}, data=raw) + assert response.status == (201 if accepted else 400), response.text() + definitions = api_request(page, 'GET', '/api/collections').json()['items'] + if not accepted: + assert not any(c['name'] == 'profiles' for c in definitions) + return + saved = api_request(page, 'GET', '/api/collections/profiles').json() + rating = next(f for f in saved['schema'] if f['name'] == 'rating') + assert float(rating['options']['min']) == -1e20 + assert float(rating['options']['max']) == 1e20 + from_list = next(c for c in definitions if c['name'] == 'profiles') + rating = next(f for f in from_list['schema'] if f['name'] == 'rating') + assert float(rating['options']['min']) == -1e20 + assert float(rating['options']['max']) == 1e20 + + +@pytest.mark.parametrize('kind', ['base', 'auth']) +@pytest.mark.parametrize('file_change', ['none', 'upload', 'remove']) +def test_prototype_named_fields_remain_own_values_in_forms(page, kind, file_change): + login(page) + collection(page, 'profiles', kind, fields=[ + {'name': 'constructor', 'type': 'text'}, + {'name': 'valueOf', 'type': 'number', 'options': {'mode': 'float'}}, + {'name': 'hasOwnProperty', 'type': 'text'}, + {'name': 'toString', 'type': 'file'}, + ]) + page.goto('/_/#/users/profiles' if kind == 'auth' else '/_/#/collections/profiles/records') + page.click('[data-test=user-new]' if kind == 'auth' else '[data-test=new-record]') + for name in ('constructor', 'valueOf', 'hasOwnProperty'): + expect(page.locator(f'[data-test=in-{name}]')).to_have_value('') + expect(page.locator(f'[data-test=err-{name}]')).to_have_count(0) + expect(page.locator('[data-test=rm-toString]')).to_have_count(0) + if kind == 'auth': + page.fill('[data-test=user-email]', 'own-fields@example.com') + page.fill('[data-test=user-password]', 'safe password here') + if file_change != 'none': + page.set_input_files('[data-test=in-toString]', {'name': 'first.txt', 'mimeType': 'text/plain', 'buffer': b'first'}) + save = '[data-test=user-save]' if kind == 'auth' else '[data-test=record-save]' + drawer = '[data-test=user-drawer]' if kind == 'auth' else '[data-test=record-drawer]' + row = '[data-test=user-row]' if kind == 'auth' else '[data-test=row]' + page.click(save) + expect(page.locator(drawer)).to_have_count(0) + saved = api_request(page, 'GET', '/api/collections/profiles/records').json()['items'][0] + assert all(saved[name] is None for name in ('constructor', 'valueOf', 'hasOwnProperty')) + if file_change == 'remove': + page.click(row) + page.check('[data-test=rm-toString]') + page.uncheck('[data-test=rm-toString]') + page.check('[data-test=rm-toString]') + page.click(save) + expect(page.locator(drawer)).to_have_count(0) + saved = api_request(page, 'GET', '/api/collections/profiles/records').json()['items'][0] + assert all(saved[name] is None for name in ('constructor', 'valueOf', 'hasOwnProperty')) + assert not saved['toString'] + page.click(row) + page.fill('[data-test=in-constructor]', 'entered constructor') + page.fill('[data-test=in-valueOf]', '1.5') + page.fill('[data-test=in-hasOwnProperty]', 'entered own value') + if file_change == 'upload': + page.set_input_files('[data-test=in-toString]', {'name': 'replacement.txt', 'mimeType': 'text/plain', 'buffer': b'replacement'}) + page.click(save) + expect(page.locator(drawer)).to_have_count(0) + saved = api_request(page, 'GET', '/api/collections/profiles/records').json()['items'][0] + assert saved['constructor'] == 'entered constructor' + assert saved['valueOf'] == 1.5 + assert saved['hasOwnProperty'] == 'entered own value' + assert bool(saved['toString']) == (file_change == 'upload') + + +@pytest.mark.parametrize('kind', ['base', 'auth']) +def test_backend_rejects_prototype_setter_field_name(page, kind): + login(page) + response = api_request(page, 'POST', '/api/collections', { + 'name': 'profiles', 'type': kind, + 'fields': [{'id': '', 'name': '__proto__', 'type': 'text', 'options': {}}]}) + assert response.status == 400, response.text() + assert response.json()['data']['__proto__']['message'] + assert not any(c['name'] == 'profiles' for c in api_request(page, 'GET', '/api/collections').json()['items']) diff --git a/tests/admin/test_editor.py b/tests/admin/test_editor.py index b2930e0b..d7f50722 100644 --- a/tests/admin/test_editor.py +++ b/tests/admin/test_editor.py @@ -121,6 +121,7 @@ def test_live_surface_reflects_sanitized_html(page): page.wait_for_selector('[data-test=records-view]') page.click('[data-test=new-record]') page.wait_for_selector('[data-test=record-drawer]') + page.locator('[data-test=in-body]').wait_for(state='visible') page.evaluate("""() => { const el = document.querySelector('[data-test=in-body]'); diff --git a/tests/admin/test_owner_feedback.py b/tests/admin/test_owner_feedback.py new file mode 100644 index 00000000..705a704f --- /dev/null +++ b/tests/admin/test_owner_feedback.py @@ -0,0 +1,228 @@ +"""Owner review regressions for collection types, freshness and auth policy.""" +import pytest +from playwright.sync_api import expect +from conftest import api_request, login, save_collection_and_wait +from test_auth_navigation import collection + + +def records(page, name): + with page.expect_response('**/api/collections/_superusers/auth-refresh') as refreshed: + page.evaluate("name => location.hash = '#/collections/' + name + '/records'", name) + refreshed.value.finished() + expect(page.locator('[data-test=new-record]')).to_be_enabled() + + +@pytest.mark.parametrize('name', ['_suppressions', '_sender_identities']) +def test_builtin_base_email_create_and_edit(page, name): + login(page) + records(page, name) + page.click('[data-test=new-record]') + page.get_by_label('account', exact=True).fill('test-account') + page.get_by_label('email', exact=True).fill('before@example.com') + page.click('[data-test=record-save]') + expect(page.locator('[data-test=record-drawer]')).to_have_count(0) + page.click('[data-test=row]') + page.get_by_label('email', exact=True).fill('after@example.com') + page.click('[data-test=record-save]') + expect(page.locator('[data-test=record-drawer]')).to_have_count(0) + saved = api_request(page, 'GET', f'/api/collections/{name}/records').json()['items'][0] + assert saved['email'] == 'after@example.com' + + +def test_field_filter_applies_auth_names_only_to_auth_collections(page): + login(page) + result = page.evaluate("""async () => { + const { isSystemField } = await import('/_/assets/views/collections.js'); + const auth = ['email', 'username', 'verified', 'token_epoch']; + return { + base: auth.some(name => isSystemField(name, false)), + auth: auth.every(name => isSystemField(name, true)), + legacy: ['id', 'created', 'updated', 'passwordHash', 'tokenKey'].every(name => isSystemField(name, false)), + }; + }""") + assert result == {'base': False, 'auth': True, 'legacy': True} + + +def test_failed_missing_collection_does_not_poison_later_routes(page): + login(page) + collection(page, 'known', 'base') + records(page, 'known') + collection(page, 'first', 'base') + collection(page, 'second', 'base') + requests = [] + def metadata(route): + requests.append(route.request) + if len(requests) == 1: + route.fulfill(status=503, json={'message': 'Temporary collection failure'}) + else: + route.continue_() + page.route('**/api/collections', metadata) + page.evaluate("location.hash = '#/collections/first/records'") + expect(page.locator('[data-test=records-error]')).to_have_text('Temporary collection failure') + page.click('[data-test=nav-known]') + expect(page.locator('[data-test=new-record]')).to_be_enabled() + expect(page.locator('[data-test=records-error]')).to_have_count(0) + expect(page.locator('.sidebar .error')).to_have_count(0) + page.evaluate("location.hash = '#/collections/second/records'") + expect(page.locator('[data-test=new-record]')).to_be_enabled() + expect(page.locator('[data-test=nav-second]')).to_be_visible() + expect(page.locator('[data-test=records-error]')).to_have_count(0) + assert len(requests) == 2 + + +def test_failed_collection_lookup_retries_when_the_route_is_revisited(page): + login(page) + collection(page, 'known', 'base') + records(page, 'known') + collection(page, 'late', 'base') + requests = [] + def metadata(route): + requests.append(route.request) + if len(requests) == 1: + route.fulfill(status=503, json={'message': 'Temporary collection failure'}) + else: + route.continue_() + page.route('**/api/collections', metadata) + page.evaluate("location.hash = '#/collections/late/records'") + expect(page.locator('[data-test=records-error]')).to_have_text('Temporary collection failure') + expect(page.locator('[data-test=new-record]')).to_be_disabled() + # Staying on the failed route must not start a retry loop. + page.wait_for_timeout(300) + assert len(requests) == 1 + page.click('[data-test=nav-known]') + expect(page.locator('[data-test=new-record]')).to_be_enabled() + page.evaluate("location.hash = '#/collections/late/records'") + expect(page.locator('[data-test=new-record]')).to_be_enabled() + expect(page.locator('[data-test=nav-late]')).to_be_visible() + expect(page.locator('[data-test=records-error]')).to_have_count(0) + assert len(requests) == 2 + + +@pytest.mark.parametrize('editing', [False, True]) +def test_drawer_fetches_new_required_schema_on_every_open(page, editing): + login(page) + original = collection(page, 'posts', 'base', fields=[{'name': 'title', 'type': 'text'}]) + if editing: + response = api_request(page, 'POST', '/api/collections/posts/records', {'title': 'existing'}) + assert response.ok, response.text() + records(page, 'posts') + fields = original['schema'] + [{'id': '', 'name': 'summary', 'type': 'text', 'required': True, 'options': {}}] + response = api_request(page, 'PATCH', '/api/collections/posts', {'name': 'posts', 'type': 'base', 'fields': fields}) + assert response.ok, response.text() + page.click('[data-test=row]' if editing else '[data-test=new-record]') + page.get_by_label('summary', exact=True).fill('fresh required field') + page.click('[data-test=record-save]') + expect(page.locator('[data-test=record-drawer]')).to_have_count(0) + saved = api_request(page, 'GET', '/api/collections/posts/records').json()['items'][0] + assert saved['summary'] == 'fresh required field' + + +@pytest.mark.parametrize('minimum', ['-1', '300', '9.5']) +def test_invalid_password_minimum_blocks_schema_save(page, minimum): + login(page) + collection(page, 'members') + page.evaluate("location.hash = '#/collections/members'") + page.click('[data-test=tab-auth]') + page.fill('[data-test=min-pw]', minimum) + expect(page.locator('[data-test=min-pw]')).to_have_value(minimum) + expect(page.locator('[data-test=min-pw-error]')).to_be_visible() + expect(page.locator('[data-test=save-collection]')).to_be_disabled() + page.fill('[data-test=min-pw]', '8') + expect(page.locator('[data-test=min-pw-error]')).to_have_count(0) + expect(page.locator('[data-test=save-collection]')).to_be_enabled() + + +@pytest.mark.parametrize('minimum', [0, 255]) +def test_password_minimum_boundaries_save_and_reload(page, minimum): + login(page) + collection(page, 'members') + page.evaluate("location.hash = '#/collections/members'") + page.click('[data-test=tab-auth]') + page.fill('[data-test=min-pw]', str(minimum)) + save_collection_and_wait(page) + page.click('[data-test=users-settings]') + page.click('[data-test=tab-auth]') + expect(page.locator('[data-test=min-pw]')).to_have_value(str(minimum)) + + +@pytest.mark.parametrize('minimum', [-1, 300, 9.5]) +@pytest.mark.parametrize('editing', [False, True]) +def test_api_rejects_invalid_password_minimum_without_mutation(page, minimum, editing): + login(page) + if editing: + collection(page, 'members') + endpoint = '/api/collections/members' if editing else '/api/collections' + response = api_request(page, 'PATCH' if editing else 'POST', endpoint, { + 'name': 'members', 'type': 'auth', 'fields': [], 'options': {'auth': {'minPasswordLength': minimum}}, + }) + assert response.status == 400, response.text() + assert 'options.auth.minPasswordLength' in response.json()['data'] + rows = api_request(page, 'GET', '/api/collections').json()['items'] + found = next((c for c in rows if c['name'] == 'members'), None) + if editing: + assert found['options']['auth']['minPasswordLength'] == 8 + else: + assert found is None + + +@pytest.mark.parametrize('editing', [False, True]) +def test_user_drawer_refreshes_required_profile_and_password_policy(page, editing): + login(page) + original = collection(page, 'members') + if editing: + response = api_request(page, 'POST', '/api/collections/members/records', { + 'email': 'existing@example.com', 'password': 'original password', + }) + assert response.ok, response.text() + page.evaluate("location.hash = '#/users/members'") + expect(page.locator('[data-test=user-new]')).to_be_enabled() + list_requests = [] + def lists(route): + list_requests.append(route.request) + route.continue_() + page.route('**/api/collections', lists) + fields = original['schema'] + [{'id': '', 'name': 'summary', 'type': 'text', 'required': True, 'options': {}}] + if not editing: + fields.append({'id': '', 'name': 'consent', 'type': 'bool', 'required': True, 'options': {}}) + response = api_request(page, 'PATCH', '/api/collections/members', { + 'name': 'members', 'type': 'auth', 'fields': fields, 'options': {'auth': {'minPasswordLength': 12}}, + }) + assert response.ok, response.text() + writes = [] + page.on('request', lambda request: writes.append(request) if request.method in ('POST', 'PATCH') and '/members/records' in request.url else None) + page.click('[data-test=user-row]' if editing else '[data-test=user-new]') + expect(page.get_by_label('summary', exact=True)).to_be_visible() + page.get_by_label('summary', exact=True).fill('current required profile') + if not editing: + page.fill('[data-test=user-email]', 'new@example.com') + page.fill('[data-test=user-password]', 'short123') + page.click('[data-test=user-save]') + expect(page.locator('[data-test=user-error]')).to_have_text('Password is too short for this collection.') + assert writes == [] + page.fill('[data-test=user-password]', 'updated long password') + page.click('[data-test=user-save]') + expect(page.locator('[data-test=user-drawer]')).to_have_count(0) + assert len(writes) == 1 and list_requests == [] + saved = api_request(page, 'GET', '/api/collections/members/records').json()['items'][0] + assert saved['summary'] == 'current required profile' + if not editing: + assert saved['consent'] is False + authenticated = page.request.post('/api/collections/members/auth-with-password', data={ + 'identity': saved['email'], 'password': 'updated long password', + }) + assert authenticated.ok, authenticated.text() + + +def test_user_drawer_blocks_saves_while_definition_loading_or_failed(page): + login(page) + collection(page, 'members') + page.evaluate("location.hash = '#/users/members'") + expect(page.locator('[data-test=user-new]')).to_be_enabled() + pending = [] + page.route('**/api/collections/members', lambda route: pending.append(route)) + with page.expect_request('**/api/collections/members'): + page.click('[data-test=user-new]') + expect(page.locator('[data-test=user-save]')).to_be_disabled() + pending[0].fulfill(status=503, json={'message': 'Definition unavailable'}) + expect(page.locator('[data-test=user-error]')).to_have_text('Definition unavailable') + expect(page.locator('[data-test=user-save]')).to_be_disabled() diff --git a/tests/admin/test_schema_field_names.py b/tests/admin/test_schema_field_names.py new file mode 100644 index 00000000..31c6d0f0 --- /dev/null +++ b/tests/admin/test_schema_field_names.py @@ -0,0 +1,89 @@ +"""Schema controls follow persisted field identity, never an in-progress name.""" + +import pytest +from playwright.sync_api import expect + +from conftest import api_request, login, save_collection_and_wait + + +def field_row(page, name): + return next( + row for row in page.locator('[data-test=field-row]').all() + if row.locator('[data-test=field-name]').input_value() == name + ) + + +@pytest.mark.parametrize("collection_type", ["base", "auth"]) +@pytest.mark.parametrize("field_name", ["EmailAddress", "token_epoch_notes"]) +def test_new_field_name_stays_editable_through_reserved_prefix(page, collection_type, field_name): + login(page) + page.goto("/_/#/collections/__new__") + page.wait_for_selector('[data-test=schema-editor]') + page.fill('[data-test=col-name]', 'field_names') + page.select_option('[data-test=col-type]', collection_type) + page.click('[data-test=add-field]') + row = page.locator('[data-test=field-row]').last + name = row.locator('[data-test=field-name]') + # fill() skips the intermediate reserved names and would miss this bug. + name.press_sequentially(field_name, delay=15) + expect(name).to_have_value(field_name) + expect(name).to_be_enabled() + expect(row.locator('[data-test=field-type]')).to_be_enabled() + expect(row.locator('[data-test=del-field]')).to_be_visible() + save_collection_and_wait(page) + collection = next(c for c in api_request(page, "GET", "/api/collections").json()["items"] if c["name"] == "field_names") + assert any(f["name"] == field_name for f in collection["schema"]) + + +def test_existing_custom_field_stays_editable_and_injected_auth_fields_stay_protected(page): + login(page) + response = api_request(page, "POST", "/api/collections", { + "name": "field_names", "type": "auth", "fields": [ + {"id": "", "name": "token_epoch_notes", "type": "text", "options": {}}, + ], + }) + assert response.ok, response.text() + collection = response.json() + system_names = {"email", "username", "verified"} + injected = [f for f in collection["schema"] if f["name"] in system_names] + assert {f["name"] for f in injected} == system_names + # Verify the actual API identity used by the editor, including injected fields. + assert all(f["id"] for f in injected), injected + assert not {"passwordHash", "tokenKey", "token_epoch"} & {f["name"] for f in collection["schema"]} + page.goto("/_/?field-names=edit#/collections/field_names") + page.wait_for_selector('[data-test=schema-editor]') + for field in injected: + row = field_row(page, field["name"]) + expect(row.locator('[data-test=field-name]')).to_be_disabled() + expect(row.locator('[data-test=field-type]')).to_be_disabled() + expect(row.locator('[data-test=del-field]')).to_have_count(0) + + row = field_row(page, "token_epoch_notes") + name = row.locator('[data-test=field-name]') + name.fill("") + name.press_sequentially("token_epoch_details", delay=15) + expect(name).to_have_value("token_epoch_details") + expect(row.locator('[data-test=field-type]')).to_be_enabled() + expect(row.locator('[data-test=del-field]')).to_be_visible() + save_collection_and_wait(page) + saved = api_request(page, "GET", "/api/collections/field_names").json() + assert any(f["name"] == "token_epoch_details" for f in saved["schema"]) + assert {f["name"]: f["id"] for f in saved["schema"] if f["name"] in system_names} == {f["name"]: f["id"] for f in injected} + + +def test_new_collection_navigation_fetches_metadata_once(page): + with page.expect_response('**/api/collections') as initial: + login(page) + initial.value.finished() + expect(page.locator('[data-test=new-collection]')).to_be_visible() + page.evaluate('() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)))') + requests = [] + def metadata(route): + requests.append(route.request) + route.continue_() + page.route('**/api/collections', metadata) + page.click('[data-test=new-collection]') + expect(page.locator('[data-test=schema-editor]')).to_be_visible() + expect(page.locator('[data-test=col-name]')).to_have_value('') + page.evaluate('() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)))') + assert len(requests) == 1 diff --git a/tests/admin/test_shell.py b/tests/admin/test_shell.py index 949d0513..8631ac3b 100644 --- a/tests/admin/test_shell.py +++ b/tests/admin/test_shell.py @@ -27,9 +27,9 @@ def test_unauthenticated_deeplink_bounces_to_login(page): page.goto("/_/#/collections/_superusers/records") page.wait_for_selector('[data-test=email]', timeout=5000) -def test_browse_records_table_renders(page): +def test_browse_builtin_accounts_renders(page): login(page) page.click('[data-test=nav-_superusers]') - page.wait_for_selector('[data-test=records-view]', timeout=5000) - page.wait_for_selector('[data-test=row]', timeout=5000) - assert page.locator('[data-test=row]').count() >= 1 + page.wait_for_selector('[data-test=users-view]', timeout=5000) + page.wait_for_selector('[data-test=user-row]', timeout=5000) + assert page.locator('[data-test=user-row]').count() >= 1
Email / UsernameVerifiedCreated