diff --git a/src/app/admin/api-keys/api-keys.service.ts b/src/app/admin/api-keys/api-keys.service.ts index 6ed31c5b5..628ab27ba 100644 --- a/src/app/admin/api-keys/api-keys.service.ts +++ b/src/app/admin/api-keys/api-keys.service.ts @@ -21,6 +21,17 @@ import { AdminDataService } from '../admin-data.service'; import { PlaceAPIKeyDetails } from './api-key-details.class'; import { APIKeyModalComponent } from './api-key-modal.component'; +/** + * Expiry for a new API key in unix seconds, or `undefined` if it never expires. + * An explicit `expires_at` wins. Otherwise `ttl` (seconds) counts from `now` (ms). + */ +export function apiKeyExpiry( + { expires_at, ttl }: { expires_at?: number | null; ttl?: number | null }, + now = Date.now(), +): number | undefined { + return expires_at ?? (ttl ? getUnixTime(now) + ttl : undefined); +} + @Service() export class APIKeyService { private _dialog = inject(MatDialog); @@ -120,10 +131,7 @@ export class APIKeyService { path: 'api_keys', form_data: { ...api_key, - expires_at: - (api_key.expires_at ?? api_key.ttl) - ? getUnixTime(Date.now() + api_key.ttl) - : undefined, + expires_at: apiKeyExpiry(api_key), authority_id: domain.id, }, }).catch((_) => { diff --git a/src/app/admin/build-list.component.ts b/src/app/admin/build-list.component.ts index 0d311b8cb..3b7f686d4 100644 --- a/src/app/admin/build-list.component.ts +++ b/src/app/admin/build-list.component.ts @@ -1,4 +1,4 @@ -import { Component, computed, inject, OnInit, signal } from '@angular/core'; +import { Component, inject, OnInit, signal } from '@angular/core'; import { MatRippleModule } from '@angular/material/core'; import { MatDialog } from '@angular/material/dialog'; import { MatProgressBarModule } from '@angular/material/progress-bar'; @@ -55,7 +55,7 @@ function cancelBuildJob(id, q = {}) { /> "). + * - Handles quoted cells with separators, escaped quotes ("" -> ") and newlines. + * - Accepts `\n` and `\r\n` line endings. * * @param csv - The CSV string. * @param separator - The delimiter (comma by default). @@ -194,16 +195,10 @@ export function parseCSV( csv: string, separator = ',', ): Record[] { - // Split on newlines, remove any empty lines - const lines = csv.split('\n').filter((line) => line.trim() !== ''); - if (!lines.length) return []; - - const [headerLine, ...dataLines] = lines; - const headers = splitCsvLine(headerLine, separator); - - return dataLines.map((line) => { - const cells = splitCsvLine(line, separator); + const [headers, ...data_rows] = splitCsvRows(csv, separator); + if (!headers) return []; + return data_rows.map((cells) => { const record: Record = {}; headers.forEach((header, idx) => { @@ -223,50 +218,60 @@ export function parseCSV( } /** - * Splits a CSV line into cells, handling: - * - quoted strings - * - escaped quotes - * - * This is a simplified parser that expects CSV in the format produced by `jsonToCSV`. - * For more robust parsing (multiline fields, etc.), consider a specialized library. + * Splits CSV text into rows of cells in one pass over the characters. + * Quotes are tracked across lines, so a quoted cell can hold separators, + * escaped quotes ("") and newlines. Blank rows are removed. */ -function splitCsvLine(line: string, separator: string): string[] { - const cells: string[] = []; - let current = ''; - let inQuotes = false; - - for (let i = 0; i < line.length; i++) { - const char = line[i]; - const nextChar = line[i + 1]; - - if (char === '"') { - if (inQuotes && nextChar === '"') { - // Escaped quote ("") - current += '"'; - i++; // Skip the next quote +function splitCsvRows(csv: string, separator: string): string[][] { + const rows: string[][] = []; + let row: string[] = []; + let cell = ''; + let in_quotes = false; + const endRow = () => { + row.push(cell); + if (row.length > 1 || row[0].trim() !== '') rows.push(row); + row = []; + cell = ''; + }; + + // `i` only moves forward, so the loop ends after `csv.length` steps + for (let i = 0; i < csv.length; i++) { + const char = csv[i]; + if (in_quotes) { + if (char !== '"') { + cell += char; + } else if (csv[i + 1] === '"') { + cell += '"'; + i++; } else { - // Toggle quote mode - inQuotes = !inQuotes; + in_quotes = false; } - } else if (char === separator && !inQuotes) { - // End of current cell - cells.push(current); - current = ''; + } else if (char === '"') { + in_quotes = true; + } else if (separator && csv.startsWith(separator, i)) { + row.push(cell); + cell = ''; + i += separator.length - 1; + } else if (char === '\n') { + endRow(); + } else if (char === '\r' && csv[i + 1] === '\n') { + endRow(); + i++; } else { - current += char; + cell += char; } } + endRow(); - // Push the last cell - cells.push(current); - - return cells; + return rows; } /** * Converts an array of JSON objects into a CSV string. * * @param data - The JSON array to convert. + * @param use_keys - Columns to output, in order. When empty, uses the union + * of keys across all rows, in first-seen order. * @param separator - The optional field separator (comma by default). * @returns A string in CSV format. */ @@ -277,9 +282,9 @@ export function jsonToCsv>( ): string { if (!data.length) return ''; - const headers = Object.keys(data[0]).filter( - (key) => !use_keys.length || use_keys.includes(key), - ); + const headers = use_keys.length + ? use_keys + : [...new Set(data.flatMap((item) => Object.keys(item)))]; const headerRow = headers.join(separator); const rows = data.map((item) => { @@ -299,7 +304,8 @@ export function jsonToCsv>( if ( cellStr.includes(separator) || cellStr.includes('"') || - cellStr.includes('\n') + cellStr.includes('\n') || + cellStr.includes('\r') ) { // Escape quotes const escaped = cellStr.replace(/"/g, '""'); diff --git a/src/app/common/hierarchy.ts b/src/app/common/hierarchy.ts new file mode 100644 index 000000000..4a3f494fd --- /dev/null +++ b/src/app/common/hierarchy.ts @@ -0,0 +1,60 @@ +/** Item in a parent/child tree, such as a zone or a group */ +export interface HierarchyItem { + id: string; + parent_id?: string; +} + +/** Upper bound for parent chain walks. Deeper chains, or cycles in bad data, stop here. */ +export const MAX_HIERARCHY_DEPTH = 32; + +/** Whether `id` is `root_id` or one of its descendants */ +export async function isInSubtree( + id: string, + root_id: string, + parentOf: (id: string) => Promise, +): Promise { + let current = id; + for (let depth = 0; current && depth < MAX_HIERARCHY_DEPTH; depth++) { + if (current === root_id) return true; + current = await parentOf(current); + } + return false; +} + +/** + * Creates a filter that removes `root_id` and its descendants from a list. + * Parent pickers use it so that a user cannot make an item its own ancestor. + * + * The filter walks up the parent chain of each item. Items in the list give + * their own parent ID. `load` fetches the other ancestors once, and the result + * is cached, so the cost is one request per unknown ancestor. + * An ancestor that fails to load counts as a root item. + */ +export function subtreeFilter(load: (id: string) => Promise) { + const parents = new Map>(); + const parentOf = (id: string) => { + if (!parents.has(id)) { + parents.set( + id, + load(id).then( + (item) => item?.parent_id || '', + () => '', + ), + ); + } + return parents.get(id); + }; + return async ( + items: T[], + root_id: string, + ): Promise => { + if (!root_id) return items; + for (const item of items) { + parents.set(item.id, Promise.resolve(item.parent_id || '')); + } + const hidden = await Promise.all( + items.map((item) => isInSubtree(item.id, root_id, parentOf)), + ); + return items.filter((_, index) => !hidden[index]); + }; +} diff --git a/src/app/common/item.service.ts b/src/app/common/item.service.ts index f03b0c24c..353a288b9 100644 --- a/src/app/common/item.service.ts +++ b/src/app/common/item.service.ts @@ -103,6 +103,8 @@ export class ActiveItemService extends AsyncHandler { /** Number of items */ private _count = signal(0); private _scope_version = 0; + /** Id of the latest `setItem` call, so stale responses are dropped */ + private _item_request = 0; public readonly count = this._count.asReadonly(); @@ -198,6 +200,7 @@ export class ActiveItemService extends AsyncHandler { /** Update the active item */ public async setItem(id: string) { + const request = ++this._item_request; await waitForSignalValue(this._user.user, (user) => !!user); if (!hasSupportRole() && !hasSupportSubsystem()) return; const scope_version = this._scope_version; @@ -214,7 +217,11 @@ export class ActiveItemService extends AsyncHandler { const item = await this.actions .show(id) .catch(() => notifyError(`Error loading ${id}`)); - if (scope_version !== this._scope_version) return; + if ( + scope_version !== this._scope_version || + request !== this._item_request + ) + return; this._active_item.set(item as PlaceResource); const name = this._type[0].toUpperCase() + this._type.slice(1); this._name.set(name); @@ -547,7 +554,7 @@ export class ActiveItemService extends AsyncHandler { }, }); ref.componentInstance.event.subscribe((e: DialogEvent) => { - if (e.reason === 'done') { + if (e.reason === 'done' && e.metadata?.[0]) { this._active_item.set(e.metadata[0] as PlaceResource); this.replaceItem(e.metadata[0] as unknown as Identity); } diff --git a/src/app/common/settings.service.ts b/src/app/common/settings.service.ts index 77af1316e..b21f3587e 100644 --- a/src/app/common/settings.service.ts +++ b/src/app/common/settings.service.ts @@ -194,22 +194,35 @@ export class SettingsService extends AsyncHandler { element.innerText = css_string; } + /** + * Save pending settings to the user's metadata. + * Settings changed while the request is in flight stay pending for the + * next save. On failure, the unsaved settings go back into the pending list. + */ private async _savePendingChanges() { const user = currentUser(); if (!user?.id || !Object.keys(this._pending_settings).length) return; - await updateMetadata(user.id, { - name: 'settings', - description: '', - details: { - ...this._user_settings(), - ...this._pending_settings, - }, - }); - this._user_settings.set({ - ...this._user_settings(), - ...this._pending_settings, - } as HashMap); + const pending = this._pending_settings; this._pending_settings = {}; + const details = { ...this._user_settings(), ...pending } as HashMap; + this._user_settings.set(details); + try { + await updateMetadata(user.id, { + name: 'settings', + description: '', + details, + }); + } catch (err) { + // Newer changes win over the failed ones + this._pending_settings = { ...pending, ...this._pending_settings }; + log( + 'Settings', + 'Failed to save user settings', + [err as object], + 'warn', + true, + ); + } } private _setFontSize() { diff --git a/src/app/domains/application-form.component.ts b/src/app/domains/application-form.component.ts index 319eb3267..05a698ac5 100644 --- a/src/app/domains/application-form.component.ts +++ b/src/app/domains/application-form.component.ts @@ -240,7 +240,6 @@ export class ApplicationFormComponent extends AsyncHandler implements OnInit { public readonly form = form(this.formModel, applyApplicationFormSchema); public readonly loading = signal(null); public readonly heading = signal(''); - public default_redirect_uri: string; public readonly client_id = signal(''); public readonly separators: number[] = [ENTER, COMMA, SPACE]; public subsystem_list = computed(() => this.formModel().subsystems || []); @@ -260,11 +259,8 @@ export class ApplicationFormComponent extends AsyncHandler implements OnInit { const item = this._data.item; const edit = !!item.id; this.heading.set(i18n(`DOMAINS.APPLICATION_${edit ? 'EDIT' : 'NEW'}`)); - const { redirect_uri } = this.formModel(); - this.default_redirect_uri = redirect_uri || ''; effect( () => { - const preserve = this.formModel().preserve_client_id; const redirect_value = `${this.formModel().redirect_uri || ''}`; const trimmed_value = redirect_value.trim(); if (redirect_value !== trimmed_value) { @@ -273,10 +269,18 @@ export class ApplicationFormComponent extends AsyncHandler implements OnInit { redirect_uri: trimmed_value, })); } - const uri = preserve - ? this.default_redirect_uri - : trimmed_value; - this.client_id.set(uri ? Md5.hashStr(uri) : ''); + // The backend ignores a uid sent by the client. It sets + // md5(lowercase redirect_uri) on create and never changes it + // on update, so existing apps always keep the stored uid. + if (item.uid) { + this.client_id.set(item.uid); + return; + } + this.client_id.set( + trimmed_value + ? Md5.hashStr(trimmed_value.toLowerCase()) + : '', + ); }, { injector: this._injector }, ); diff --git a/src/app/domains/applications.utilities.ts b/src/app/domains/applications.utilities.ts index 48d808bd7..06c37204d 100644 --- a/src/app/domains/applications.utilities.ts +++ b/src/app/domains/applications.utilities.ts @@ -23,7 +23,7 @@ export function generateApplicationFormModel( skip_authorization: !!app?.skip_authorization, redirect_uri: app?.redirect_uri || '', client_id: app?.uid || '', - preserve_client_id: false, + preserve_client_id: !!app?.preserve_client_id, }; } diff --git a/src/app/domains/domain-about.component.ts b/src/app/domains/domain-about.component.ts index 7b6650ba4..7a05610b1 100644 --- a/src/app/domains/domain-about.component.ts +++ b/src/app/domains/domain-about.component.ts @@ -66,6 +66,7 @@ import { DomainStateService } from './domain-state.service'; default matRipple [matTooltip]="'COMMON.SAVE_CHANGES' | translate" + [disabled]="saving()" (click)="saveChanges()" > save @@ -140,6 +141,8 @@ export class DomainAboutComponent extends AsyncHandler { : undefined, ); }); + /** Whether a settings save is in flight */ + public readonly saving = signal(false); /** Index of the active tab */ public index: number; @@ -162,16 +165,34 @@ export class DomainAboutComponent extends AsyncHandler { /** Save changes to the settings fields */ public async saveChanges() { + if (this.saving()) return; await submit(this.form, async () => undefined); if (this.form().invalid()) return notifyError(i18n('DOMAINS.SETTINGS_ERROR')); - const domain = new PlaceDomain({ - ...this.item, - config: JSON.parse(this.formModel().config), - internals: JSON.parse(this.formModel().internals), - }); - await this._service.update(domain); - notifySuccess(i18n('DOMAINS.SETTINGS_SAVED')); + this.saving.set(true); + try { + const { config, internals } = this.formModel(); + // An empty editor is valid and means no settings + const domain = new PlaceDomain({ + ...this.item, + config: JSON.parse(config || '{}'), + internals: JSON.parse(internals || '{}'), + }); + await this._service.update(domain); + notifySuccess(i18n('DOMAINS.SETTINGS_SAVED')); + } catch (err) { + notifyError( + i18n('COMMON.SETTINGS_SAVE_ERROR', { + error: JSON.stringify( + (err as { response?: unknown }).response || + (err as Error).message || + err, + ), + }), + ); + } finally { + this.saving.set(false); + } } /** Load settings fields for active item */ diff --git a/src/app/groups/group-form.component.ts b/src/app/groups/group-form.component.ts index 6175b6b5a..213b622c9 100644 --- a/src/app/groups/group-form.component.ts +++ b/src/app/groups/group-form.component.ts @@ -32,6 +32,7 @@ import { getInvalidSignalFields, removeSignalChipItem, } from '../common/forms'; +import { subtreeFilter } from '../common/hierarchy'; import { HotkeysService } from '../common/hotkeys.service'; import { i18n } from '../common/locale.service'; import { notifyError, notifySuccess } from '../common/notifications'; @@ -105,7 +106,7 @@ import { }} @for ( domain of domain_list(); @@ -273,8 +275,26 @@ export class GroupFormComponent extends AsyncHandler implements OnInit { params: () => this.formModel().authority_id, loader: ({ params }) => hasStaffGroupSearch(params), }); - public readonly query_parent_groups = (_: string) => - queryGroups({ q: _, limit: 20 }).then(({ data }) => data); + private readonly _excludeSubtree = subtreeFilter((id) => showGroup(id)); + private readonly _authority_id = computed( + () => this.formModel().authority_id, + ); + /** + * Lists groups in the selected authority, as a parent must be in the same + * authority. Hides this group's descendants, so the parent can't create a + * cycle. A new function per authority makes the search field query again. + */ + public readonly query_parent_groups = computed(() => { + const authority_id = this._authority_id(); + return (q: string) => + queryGroups({ q, limit: 20 }).then(({ data }) => + this._excludeSubtree( + data.filter((group) => group.authority_id === authority_id), + this._data.item.id, + ), + ); + }); + /** A group can't be its own parent */ public readonly exclude_parent_group = (group: PlaceGroup, __: string) => group.id === this._data.item.id; diff --git a/src/app/triggers/trigger-state.service.ts b/src/app/triggers/trigger-state.service.ts index d60a93336..c85af8203 100644 --- a/src/app/triggers/trigger-state.service.ts +++ b/src/app/triggers/trigger-state.service.ts @@ -213,6 +213,14 @@ export class TriggerStateService { const index = list.findIndex( (i) => JSON.stringify(i) === JSON.stringify(condition), ); + if (index < 0) { + details.close(); + return notifyError( + i18n('TRIGGERS.REMOVE_CONDITION_ERROR', { + error: 'Condition not found on trigger', + }), + ); + } list.splice(index, 1); const resp = await updateTrigger(item.id, { ...item.toJSON(), @@ -254,6 +262,14 @@ export class TriggerStateService { ? item.actions.mailers : item.actions.functions ).findIndex((i) => JSON.stringify(i) === JSON.stringify(action)); + if (index < 0) { + details.close(); + return notifyError( + i18n('TRIGGERS.REMOVE_ACTION_ERROR', { + error: 'Action not found on trigger', + }), + ); + } ((action as TriggerMailer).emails ? actions.mailers : actions.functions diff --git a/src/app/ui/custom-fields/item-search-field.component.ts b/src/app/ui/custom-fields/item-search-field.component.ts index d8b0290e7..a1dda3147 100644 --- a/src/app/ui/custom-fields/item-search-field.component.ts +++ b/src/app/ui/custom-fields/item-search-field.component.ts @@ -235,7 +235,7 @@ export class ItemSearchFieldComponent public readonly minLength = input(0); /** Whether item list is loading */ public readonly loading = model(false); - /** Service used for searching items */ + /** Service used for searching items. A new function queries again */ public readonly query_fn = input<(_: string) => Promise>(() => Promise.resolve([]), ); @@ -249,9 +249,10 @@ export class ItemSearchFieldComponent query: this._debounced_search(), options: this.options(), min_length: this.minLength(), + query_fn: this.query_fn(), }), loader: async ({ params }) => { - const { query, options, min_length } = params; + const { query, options, min_length, query_fn } = params; const trimmed_query = query.trim(); this.loading.set(true); try { @@ -259,7 +260,7 @@ export class ItemSearchFieldComponent options && options.length > 0 ? options : !min_length || trimmed_query.length >= min_length - ? await this.query_fn()(trimmed_query).catch( + ? await query_fn(trimmed_query).catch( () => [] as T[], ) : []; @@ -358,6 +359,7 @@ export class ItemSearchFieldComponent */ public writeValue(value: T) { this.active_item.set(value); + if (!value) this.search_str.set(''); this.resetSearchString(); } diff --git a/src/app/ui/forms/trigger-action-modal.component.ts b/src/app/ui/forms/trigger-action-modal.component.ts index ec5513fe3..8070d23e3 100644 --- a/src/app/ui/forms/trigger-action-modal.component.ts +++ b/src/app/ui/forms/trigger-action-modal.component.ts @@ -227,8 +227,6 @@ export class TriggerActionModalComponent generateTriggerActionFormModel(this._data.action), ); public readonly form = form(this.formModel, applyTriggerActionFormSchema); - /** Store for changes to actions */ - public actions: TriggerActions; /** List of seperators for storing emails */ public readonly separators: number[] = [ENTER, COMMA, SPACE]; /** Variable to hold new email addresses */ @@ -241,7 +239,7 @@ export class TriggerActionModalComponent /** Whether the triggers is new or not */ public get is_new(): boolean { - return !!this._data.action; + return !this._data.action; } /** Template system to use for status variable bindings */ @@ -300,15 +298,19 @@ export class TriggerActionModalComponent if (this.form().invalid()) { return; } - this.loading.set('Save trigger action...'); - if (this.formModel().action_type === 'emails') { - this.updateMailers(); - } else { - this.updateFunctions(); + const actions = + this.formModel().action_type === 'emails' + ? this.updateMailers() + : this.updateFunctions(); + if (!actions) { + return notifyError( + 'Error updating trigger action. The action was changed or removed elsewhere.', + ); } + this.loading.set('Save trigger action...'); await updateTrigger(this.trigger.id, { ...this.trigger, - actions: this.actions, + actions, }).then( (item) => { this.event.emit({ @@ -335,35 +337,45 @@ export class TriggerActionModalComponent ); } - private updateMailers() { - const mailers = this.trigger.actions.mailers; + /** + * Copy of the trigger actions with the mailer added or replaced. + * `null` if the edited mailer is no longer on the trigger. + */ + private updateMailers(): TriggerActions | null { + const mailers = [...(this.trigger.actions.mailers || [])]; const new_mailer = { emails: this.formModel().emails, content: this.formModel().content, }; if (this._data.action) { - const old_mailer = JSON.stringify(this._data.action || {}); + const old_mailer = JSON.stringify(this._data.action); const index = mailers.findIndex( (a_mailer) => JSON.stringify(a_mailer) === old_mailer, ); + if (index < 0) return null; mailers.splice(index, 1, new_mailer); } else { mailers.push(new_mailer); } - this.actions = { ...this.trigger.actions, mailers }; + return { ...this.trigger.actions, mailers }; } - private updateFunctions() { - const functions = this.trigger.actions.functions; + /** + * Copy of the trigger actions with the function added or replaced. + * `null` if the edited function is no longer on the trigger. + */ + private updateFunctions(): TriggerActions | null { + const functions = [...(this.trigger.actions.functions || [])]; if (this._data.action) { const old_function = JSON.stringify(this._data.action); const index = functions.findIndex( (fn) => JSON.stringify(fn) === old_function, ); + if (index < 0) return null; functions.splice(index, 1, this.formModel().method_call); } else { functions.push(this.formModel().method_call); } - this.actions = { ...this.trigger.actions, functions }; + return { ...this.trigger.actions, functions }; } } diff --git a/src/app/zones/zone-form.component.ts b/src/app/zones/zone-form.component.ts index 59fd99673..338ebf96a 100644 --- a/src/app/zones/zone-form.component.ts +++ b/src/app/zones/zone-form.component.ts @@ -20,6 +20,7 @@ import { showZone, updateZone as updateZoneRequest, } from '@placeos/ts-client'; +import { subtreeFilter } from '../common/hierarchy'; import { querySupportZones as queryZones } from '../common/support-access'; import { MatAutocompleteModule } from '@angular/material/autocomplete'; @@ -364,6 +365,9 @@ export class ZoneFormComponent extends AsyncHandler implements OnInit { constructor() { super(); effect(() => { + // Sync only after the user changes the picker. The parent zone + // loads async, so an early save must keep the stored `parent_id`. + if (!this.form.parent_zone().dirty()) return; const parent_zone = this.formModel().parent_zone; const parent_id = parent_zone?.id || ''; if (parent_id !== this.formModel().parent_id) { @@ -374,9 +378,12 @@ export class ZoneFormComponent extends AsyncHandler implements OnInit { /** List of separator characters for tags */ public readonly separators: number[] = [ENTER, COMMA, SPACE]; - /** Query function for zones */ + private readonly _excludeSubtree = subtreeFilter((id) => showZone(id)); + /** Query function for zones. Hides this zone's descendants to prevent cycles */ public readonly query_fn = (_: string) => - queryZones({ q: _ }).then((resp) => resp.data as PlaceZone[]); + queryZones({ q: _ }).then((resp) => + this._excludeSubtree(resp.data as PlaceZone[], this.formModel().id), + ); /** Function to exclude zones */ public readonly exclude = (zone: PlaceZone) => zone.id === this.formModel().id; @@ -453,17 +460,14 @@ export class ZoneFormComponent extends AsyncHandler implements OnInit { } } - /** Update parent zone details if set */ + /** Show the stored parent zone in the picker. `parent_id` stays as stored */ private async updateZone() { const parent_id = this.formModel().parent_id; - if (parent_id) { - const zone = await showZone(parent_id); - this.formModel.update((value) => ({ - ...value, - parent_zone: zone, - parent_id: zone?.id || '', - })); - } + if (!parent_id) return; + const zone = await showZone(parent_id).catch(() => null); + // Keep the user's choice if they changed the picker while loading + if (!zone || this.form.parent_zone().dirty()) return; + this.formModel.update((value) => ({ ...value, parent_zone: zone })); } private async newSettings(item: Identity, settings_string: string) { diff --git a/src/tests/admin/api-keys.service.spec.ts b/src/tests/admin/api-keys.service.spec.ts index 8cb90817c..848e1c7c2 100644 --- a/src/tests/admin/api-keys.service.spec.ts +++ b/src/tests/admin/api-keys.service.spec.ts @@ -3,7 +3,10 @@ import { TestBed } from '@angular/core/testing'; import { MatDialog } from '@angular/material/dialog'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { AdminDataService } from '../../app/admin/admin-data.service'; -import { APIKeyService } from '../../app/admin/api-keys/api-keys.service'; +import { + APIKeyService, + apiKeyExpiry, +} from '../../app/admin/api-keys/api-keys.service'; const mocks = vi.hoisted(() => ({ create: vi.fn(), @@ -104,3 +107,24 @@ describe('APIKeyService', () => { ); }); }); + +describe('apiKeyExpiry', () => { + const now = Date.UTC(2026, 0, 1); + const now_seconds = now / 1000; + + it('adds the TTL in seconds to the current time', () => { + expect(apiKeyExpiry({ ttl: 3600 }, now)).toBe(now_seconds + 3600); + }); + + it('uses an explicit expiry when only expires_at is set', () => { + expect(apiKeyExpiry({ expires_at: now_seconds + 60 }, now)).toBe( + now_seconds + 60, + ); + }); + + it('never expires without an expiry or TTL', () => { + expect(apiKeyExpiry({ expires_at: null, ttl: null }, now)).toBe( + undefined, + ); + }); +}); diff --git a/src/tests/common/general.spec.ts b/src/tests/common/general.spec.ts index cff523516..536b9037f 100644 --- a/src/tests/common/general.spec.ts +++ b/src/tests/common/general.spec.ts @@ -329,6 +329,43 @@ describe('general.ts utilities', () => { expect(result).toEqual([{ name: 'John', age: 30 }]); }); + it('should parse CRLF line endings', () => { + const csv = 'name,age\r\nJohn,30\r\nJane,25\r\n'; + expect(parseCSV(csv)).toEqual([ + { name: 'John', age: 30 }, + { name: 'Jane', age: 25 }, + ]); + }); + + it('should keep newlines inside quoted cells', () => { + const csv = 'name,notes\r\nTest,"line 1\r\nline 2\nline 3"\nNext,x'; + expect(parseCSV(csv)).toEqual([ + { name: 'Test', notes: 'line 1\r\nline 2\nline 3' }, + { name: 'Next', notes: 'x' }, + ]); + }); + + it('should keep escaped quotes next to separators and newlines', () => { + const csv = 'name,quote\n"a,b","say ""hi,\nthere"""'; + expect(parseCSV(csv)).toEqual([ + { name: 'a,b', quote: 'say "hi,\nthere"' }, + ]); + }); + + it('should round-trip data exported by jsonToCsv', () => { + const data = [ + { + id: 'zone-1', + name: 'Level "1", North', + description: 'Line 1\nLine 2\r\nLine 3', + tags: ['level', 'north'], + }, + { id: 'zone-2', name: 'Level 2', description: '', tags: [] }, + ]; + expect(parseCSV(jsonToCsv(data))).toEqual(data); + expect(parseCSV(jsonToCsv(data, [], '\t'), '\t')).toEqual(data); + }); + it('csvToJson should be alias for parseCSV', () => { expect(csvToJson).toBe(parseCSV); }); @@ -390,6 +427,17 @@ describe('general.ts utilities', () => { const result = jsonToCsv(data, [], ';'); expect(result).toBe('name;age\nJohn;30'); }); + + it('should keep columns missing from the first row', () => { + const data: Record[] = [ + { id: '1' }, + { id: '2', code: 'B2' }, + ]; + expect(jsonToCsv(data)).toBe('id,code\n1,\n2,B2'); + expect(jsonToCsv(data, ['id', 'name', 'code'])).toBe( + 'id,name,code\n1,,\n2,,B2', + ); + }); }); describe('eventToPoint', () => { diff --git a/src/tests/common/hierarchy.spec.ts b/src/tests/common/hierarchy.spec.ts new file mode 100644 index 000000000..c147205a1 --- /dev/null +++ b/src/tests/common/hierarchy.spec.ts @@ -0,0 +1,53 @@ +import { describe, expect, it, vi } from 'vitest'; +import { HierarchyItem, subtreeFilter } from '../../app/common/hierarchy'; + +// campus -> building -> level-1 -> room-1, and campus -> other +const TREE: Record = { + campus: { id: 'campus', parent_id: '' }, + building: { id: 'building', parent_id: 'campus' }, + 'level-1': { id: 'level-1', parent_id: 'building' }, + 'room-1': { id: 'room-1', parent_id: 'level-1' }, + other: { id: 'other', parent_id: 'campus' }, +}; + +describe('subtreeFilter', () => { + it('removes the root and its descendants', async () => { + const load = vi.fn(async (id: string) => TREE[id]); + const exclude = subtreeFilter(load); + const items = ['campus', 'building', 'room-1', 'other'].map( + (id) => TREE[id], + ); + const result = await exclude(items, 'building'); + expect(result.map(({ id }) => id)).toEqual(['campus', 'other']); + }); + + it('loads each unknown ancestor once', async () => { + const load = vi.fn(async (id: string) => TREE[id]); + const exclude = subtreeFilter(load); + await exclude([TREE['room-1']], 'other'); + await exclude([TREE['room-1']], 'building'); + expect(load.mock.calls.map(([id]) => id)).toEqual([ + 'level-1', + 'building', + 'campus', + ]); + }); + + it('keeps items whose ancestors fail to load', async () => { + const exclude = subtreeFilter(() => Promise.reject(new Error('403'))); + const result = await exclude([TREE['room-1']], 'building'); + expect(result).toEqual([TREE['room-1']]); + }); + + it('stops at a parent cycle in bad data', async () => { + const load = async (id: string) => ({ + id, + parent_id: id === 'a' ? 'b' : 'a', + }); + const result = await subtreeFilter(load)( + [{ id: 'x', parent_id: 'a' }], + 'root', + ); + expect(result).toHaveLength(1); + }); +}); diff --git a/src/tests/domains/applications.utilities.spec.ts b/src/tests/domains/applications.utilities.spec.ts index 19c820e2e..e5b9dabc2 100644 --- a/src/tests/domains/applications.utilities.spec.ts +++ b/src/tests/domains/applications.utilities.spec.ts @@ -23,6 +23,7 @@ describe('applications.utilities', () => { skip_authorization: true, redirect_uri: 'https://example.com/callback', uid: 'client-id', + preserve_client_id: true, } as any); expect(model).toEqual({ @@ -32,7 +33,7 @@ describe('applications.utilities', () => { skip_authorization: true, redirect_uri: 'https://example.com/callback', client_id: 'client-id', - preserve_client_id: false, + preserve_client_id: true, }); }); }); diff --git a/src/tests/zones/zone-form.component.spec.ts b/src/tests/zones/zone-form.component.spec.ts index fef16663a..6aabc4bf5 100644 --- a/src/tests/zones/zone-form.component.spec.ts +++ b/src/tests/zones/zone-form.component.spec.ts @@ -61,7 +61,9 @@ describe('saving zones', () => { it('saves the selected parent ID without sending the picker object', async () => { fixture.componentInstance.form .parent_zone() - .value.set(new PlaceZone({ id: 'building-1', name: 'Building' })); + .controlValue.set( + new PlaceZone({ id: 'building-1', name: 'Building' }), + ); await fixture.whenStable(); await fixture.componentInstance.submit(); expect(mocks.updateZone).toHaveBeenCalledExactlyOnceWith( @@ -80,9 +82,9 @@ describe('saving zones', () => { it('saves an empty parent ID when the user removes the parent', async () => { fixture.componentInstance.form .parent_zone() - .value.set(new PlaceZone({ id: 'building-1' })); + .controlValue.set(new PlaceZone({ id: 'building-1' })); await fixture.whenStable(); - fixture.componentInstance.form.parent_zone().value.set(null); + fixture.componentInstance.form.parent_zone().controlValue.set(null); await fixture.whenStable(); await fixture.componentInstance.submit(); expect(mocks.updateZone).toHaveBeenCalledWith( @@ -119,3 +121,56 @@ describe('saving zones', () => { expect(dialog.close).toHaveBeenCalledOnce(); }); }); + +describe('saving zones with a stored parent', () => { + const dialog = { close: vi.fn(), disableClose: false }; + + async function createForm() { + TestBed.configureTestingModule({ + imports: [ZoneFormComponent], + providers: [ + provideZonelessChangeDetection(), + { provide: MatDialogRef, useValue: dialog }, + { + provide: MAT_DIALOG_DATA, + useValue: { + item: new PlaceZone({ + id: 'zone-1', + name: 'Floor 1', + parent_id: 'building-1', + }), + }, + }, + ], + }); + const fixture = TestBed.createComponent(ZoneFormComponent); + await fixture.whenStable(); + return fixture; + } + + beforeEach(() => { + vi.resetAllMocks(); + mocks.queryZones.mockResolvedValue({ data: [] }); + mocks.updateZone.mockResolvedValue(new PlaceZone({ id: 'zone-1' })); + }); + + it('keeps the parent when saving before the parent loads', async () => { + mocks.showZone.mockReturnValue(new Promise(() => undefined)); + const fixture = await createForm(); + await fixture.componentInstance.submit(); + expect(mocks.updateZone).toHaveBeenCalledWith( + 'zone-1', + expect.objectContaining({ parent_id: 'building-1' }), + ); + }); + + it('keeps the parent when the parent fails to load', async () => { + mocks.showZone.mockRejectedValue(new Error('Forbidden')); + const fixture = await createForm(); + await fixture.componentInstance.submit(); + expect(mocks.updateZone).toHaveBeenCalledWith( + 'zone-1', + expect.objectContaining({ parent_id: 'building-1' }), + ); + }); +});