From d30fb4773109f87132181596e6d25a2d890a0a65 Mon Sep 17 00:00:00 2001 From: Volker Scheuber Date: Fri, 13 Nov 2026 14:00:48 -0700 Subject: [PATCH] feat: app template list shows the newest version of each template, and describe says much more (#1312) list prints one row per template (the newest version) and --all-versions every version; --long adds the display name, category, what the template provides, its connector, the number of versions and the date. describe takes the template by id (-i) or display name (-n) and the version by -v (default: the newest), and shows the other versions, the connector and its configuration, the capabilities, the object types and the application form; --long adds the properties and the form fields. The key the catalog keeps a version under is no longer printed or accepted. The entries are read defensively and printed without control characters. Co-Authored-By: Claude Sonnet 5.5 --- src/cli/app/app-template-describe.ts | 79 ++- src/cli/app/app-template-list.ts | 20 +- src/help/SampleData.ts | 4 +- src/ops/ApplicationTemplateOps.ts | 487 ++++++++++++-- src/utils/ApplicationTemplateView.ts | 594 ++++++++++++++++++ .../EntityPickFamilies.appTemplate.test.ts | 141 +++++ src/utils/interactive/EntityPickFamilies.ts | 70 ++- .../app-template-describe.test.js.snap | 101 +-- .../app-template-list.test.js.snap | 12 +- .../__snapshots__/app-template.test.js.snap | 11 +- test/unit/ApplicationTemplateCommands.test.js | 255 +++++--- .../ApplicationTemplateEntityPicks.test.js | 48 +- test/unit/ApplicationTemplateOps.test.js | 443 +++++++++++-- test/unit/ApplicationTemplateView.test.js | 374 +++++++++++ test/utils/ApplicationTemplateFixtures.js | 369 +++++++++++ 15 files changed, 2702 insertions(+), 306 deletions(-) create mode 100644 src/utils/ApplicationTemplateView.ts create mode 100644 src/utils/interactive/EntityPickFamilies.appTemplate.test.ts create mode 100644 test/unit/ApplicationTemplateView.test.js create mode 100644 test/utils/ApplicationTemplateFixtures.js diff --git a/src/cli/app/app-template-describe.ts b/src/cli/app/app-template-describe.ts index 0993b3768..8924a6b1c 100644 --- a/src/cli/app/app-template-describe.ts +++ b/src/cli/app/app-template-describe.ts @@ -4,7 +4,6 @@ import * as s from '../../help/SampleData'; import { describeApplicationTemplate } from '../../ops/ApplicationTemplateOps'; import { getTokens } from '../../ops/AuthenticateOps'; import c from '../../utils/ColorTheme'; -import { printMessage } from '../../utils/Console.js'; import { positionalValue } from '../../utils/interactive/PositionalArgs'; import { FrodoCommand } from '../FrodoCommand'; import { channelOption, templatesOption } from './app-template-options'; @@ -25,12 +24,32 @@ export default function setup() { ); program - .description('Describe an application template.') + .description( + 'Describe an application template: what it provides, its versions, connector, capabilities, object types and application form. The newest version unless -v names another.' + ) .addOption( new Option( '-i, --template-id ', - 'Template id: the id of one version of a template, as "frodo app template list" prints it (for example 1_2-saml). Ids are not case-sensitive.' - ).makeOptionMandatory() + 'Template id: the id of the template, as "frodo app template list" prints it (for example azure.ad). Dots and hyphens are the same, and the case does not matter. Cannot be used with -n.' + ).conflicts(['templateName']) + ) + .addOption( + new Option( + '-n, --template-name ', + 'Display name of the template (for example "Microsoft Entra"), as "frodo app template list --long" prints it. The case does not matter. Cannot be used with -i.' + ).conflicts(['templateId']) + ) + .addOption( + new Option( + '-v, --template-version ', + 'The version to describe (for example 3.1). Default: the newest version. The versions of a template are listed in its description.' + ) + ) + .addOption( + new Option( + '-l, --long', + 'Long: also the properties of each object type and the fields of the connector configuration and of the application form.' + ).default(false, 'false') ) .addOption( new Option( @@ -40,16 +59,32 @@ export default function setup() { ) .addOption(templatesOption()) .addOption(channelOption()) + .requireOneOf({ + options: ['templateId', 'templateName'], + purpose: 'which template to describe', + }) // without a tenant there is nothing to connect to: the catalog is public .skipConnectionPromptsWhen( (command) => !positionalValue(command, 'host') && !state.getHost() ) .addHelpText( 'after', - `Without a tenant the command reads the catalog alone and needs no login. With a tenant it also finds the templates the tenant defines itself.\n\n` + + `Without a tenant the command reads the catalog alone and needs no login. With a tenant it also finds the templates the tenant defines itself. A template has several versions; the description of the newest lists the others, and -v describes one of them.\n\n` + `Usage Examples:\n` + - ` Describe the template '${s.appTemplateId}' of the catalog, without a tenant:\n` + + ` Describe the newest version of the template '${s.appTemplateId}' of the catalog, without a tenant:\n` + c.command(` $ frodo app template describe -i ${s.appTemplateId}\n`) + + ` Describe another version of it:\n` + + c.command( + ` $ frodo app template describe -i ${s.appTemplateId} -v ${s.appTemplateVersion}\n` + ) + + ` Name the template by its display name:\n` + + c.command( + ` $ frodo app template describe -n "${s.appTemplateName}"\n` + ) + + ` Add the properties of the object types and the fields of the forms:\n` + + c.command( + ` $ frodo app template describe -i ${s.appTemplateId} --long\n` + ) + ` Print the whole template in JSON format:\n` + c.command( ` $ frodo app template describe -i ${s.appTemplateId} --json\n` @@ -79,26 +114,26 @@ export default function setup() { command ); const withTenant = !!state.getHost(); - // an unspecified -i gets a picker from the framework after the login - // (decision record 0038), so the login comes first + // an unspecified -i/-n gets a picker from the framework after the + // login (decision record 0038), so the login comes first if (withTenant && !(await getTokens(false, true, deploymentTypes))) { process.exitCode = 1; return; } - if (!options.templateId) { - printMessage( - `Error: -i, --template-id is required. "frodo app template list" prints the ids.`, - 'error' - ); - process.exitCode = 1; - return; - } - const outcome = await describeApplicationTemplate(options.templateId, { - templates: options.templates, - channel: options.channel, - json: options.json, - includeOverrides: withTenant, - }); + const outcome = await describeApplicationTemplate( + { + id: options.templateId, + name: options.templateName, + version: options.templateVersion, + }, + { + templates: options.templates, + channel: options.channel, + json: options.json, + long: options.long, + includeOverrides: withTenant, + } + ); if (!outcome) process.exitCode = 1; } // end command logic inside action handler diff --git a/src/cli/app/app-template-list.ts b/src/cli/app/app-template-list.ts index d0f30f1e1..4885abe3f 100644 --- a/src/cli/app/app-template-list.ts +++ b/src/cli/app/app-template-list.ts @@ -26,10 +26,19 @@ export default function setup() { program .description( - 'List application templates: the catalog of the Admin UI, with the templates of the tenant merged over it when a tenant is given.' + 'List application templates, one per template with its newest version: the catalog of the Admin UI, with the templates of the tenant merged over it when a tenant is given.' ) .addOption( - new Option('-l, --long', 'Long with all fields.').default(false, 'false') + new Option( + '-l, --long', + 'Long: a table with the display name, category, what the template provides, its connector, its newest version, the number of versions and the date of the newest.' + ).default(false, 'false') + ) + .addOption( + new Option( + '--all-versions', + 'List every version of every template, not only the newest: the id and the version.' + ).default(false, 'false') ) .addOption(templatesOption()) .addOption(channelOption()) @@ -39,12 +48,14 @@ export default function setup() { ) .addHelpText( 'after', - `Without a tenant the command lists the catalog alone and needs no login. With a tenant (AM base URL, connection profile or alias) it logs in and merges the templates the tenant defines itself, marked as overrides.\n\n` + + `Without a tenant the command lists the catalog alone and needs no login. With a tenant (AM base URL, connection profile or alias) it logs in and merges the templates the tenant defines itself, marked as overrides. "frodo app template describe" takes an id as printed here and describes the newest version of the template, or the one that -v names.\n\n` + `Usage Examples:\n` + ` List the ids of the templates of the catalog, without a tenant:\n` + c.command(` $ frodo app template list\n`) + - ` List them with all fields:\n` + + ` List them with the display name, category, connector and newest version:\n` + c.command(` $ frodo app template list --long\n`) + + ` List every version of every template:\n` + + c.command(` $ frodo app template list --all-versions\n`) + ` List the catalog and the templates of a tenant, using AM base URL, realm, username and password:\n` + c.command( ` $ frodo app template list --long ${s.amBaseUrl} ${s.realm} ${s.username} '${s.password}'\n` @@ -77,6 +88,7 @@ export default function setup() { templates: options.templates, channel: options.channel, long: options.long, + allVersions: options.allVersions, includeOverrides: withTenant, }); if (!outcome) process.exitCode = 1; diff --git a/src/help/SampleData.ts b/src/help/SampleData.ts index 53e1cd5b2..ae092b279 100644 --- a/src/help/SampleData.ts +++ b/src/help/SampleData.ts @@ -59,5 +59,7 @@ export const aiAgentId = 'oracle-assistant'; export const aiAgentFile = 'oracle-assistant.ai.agent.json'; export const allAiAgentsFile = 'allAlphaAIAgents.ai.agent.json'; export const logApiKeyId = 'e4b1d9f2-6a3c-4d57-9b0e-2f8a6c1d7e35'; -export const appTemplateId = '1_2-saml'; +export const appTemplateId = 'azure.ad'; +export const appTemplateName = 'Microsoft Entra'; +export const appTemplateVersion = '3.1'; export const appTemplatesFile = 'appTemplates.json'; diff --git a/src/ops/ApplicationTemplateOps.ts b/src/ops/ApplicationTemplateOps.ts index e13356016..d703b57ab 100644 --- a/src/ops/ApplicationTemplateOps.ts +++ b/src/ops/ApplicationTemplateOps.ts @@ -6,6 +6,27 @@ import { type CatalogChannel, type CatalogSource, } from '../utils/ApplicationTemplateSource'; +import { + capabilitiesOf, + categoryOf, + connectorOf, + connectorSummaryOf, + countsOf, + formSectionsOf, + groupTemplates, + isoDate, + objectTypesOf, + providesOf, + recordOf, + selectTemplate, + textOf, + type FieldRow, + type Json, + type TemplateGroup, + type TemplateRow, + type TemplateSelector, +} from '../utils/ApplicationTemplateView'; +import c from '../utils/ColorTheme'; import { createTable, printError, printMessage } from '../utils/Console'; // The application template catalog has a library module of its own. Every @@ -21,9 +42,6 @@ const { getFilePath, saveTextToFile } = frodo.utils; /** The catalog of the library together with what is known about it. */ type CatalogResult = Awaited>; -/** One template version of the merged list. */ -type TemplateListItem = Awaited>[number]; - /** What to do when the catalog cannot be read because air-gapped mode is on. `printError` already says that air-gapped mode is on and how to turn it off. */ export const AIR_GAPPED_CATALOG_ADVICE = "Pass the application template catalog as a file with --templates . Save the file with 'frodo app template download' on a machine that can reach the internet."; @@ -62,52 +80,153 @@ export function countCatalog(catalog: CatalogResult['catalog']): { return { templates, groups }; } +/** The options of the library for one read: the catalog, and the templates of the tenant if there is one. */ +type LibraryOptions = ReturnType & { + includeOverrides: boolean; +}; + +/** + * The entries of the versions listed, by the key the catalog keeps them under. + * The catalog is read once and the entries are taken from it; an entry the + * tenant defines itself (it replaces the catalog's) is read through the library, + * which also finds it if the catalog has no such entry. + * @param {TemplateRow[]} items the versions to read + * @param {CatalogSource} source which catalog to read + * @param {LibraryOptions} libraryOptions the options for the library + * @returns {Promise>} the entries by `templateId`; a version that could not be read is missing + */ +async function readEntries( + items: TemplateRow[], + source: CatalogSource, + libraryOptions: LibraryOptions +): Promise> { + const entries = new Map(); + const catalog = items.some((item) => !item.isOverride) + ? (await readCatalog(catalogOptionsOf(source))).catalog + : undefined; + for (const item of items) { + let entry = item.isOverride + ? undefined + : recordOf( + catalog?.AppTemplates?.[item.tier]?.[item.group ?? '']?.[ + item.templateId + ] + ); + if (!entry) { + const resolution = await readTemplateInLibrary( + { templateName: item.name, templateVersion: item.templateVersion }, + libraryOptions + ); + entry = recordOf(resolution.template); + } + if (entry) entries.set(item.templateId, entry); + } + return entries; +} + /** - * List the application templates: the ids, or with `long` a table of the - * versions. The templates of the tenant are merged over the catalog when a + * A right-aligned cell of a table. + * @param {string} text the text of the cell + * @returns {{ hAlign: 'right'; content: string }} the cell + */ +function right(text: string): { hAlign: 'right'; content: string } { + return { hAlign: 'right', content: text }; +} + +/** What the list commands print about one version. */ +type ListCells = { + item: TemplateRow; + entry: Json; + versions: number; +}; + +/** + * List the application templates: the ids of the newest version of each + * template, or with `long` a table of them, or with `allVersions` every + * version. The templates of the tenant are merged over the catalog when a * connection to the tenant was made. - * @param {CatalogSource & { long?: boolean; includeOverrides?: boolean }} options which catalog to read, whether to list all fields, and whether the tenant's templates are merged in + * @param {CatalogSource & { long?: boolean; allVersions?: boolean; includeOverrides?: boolean }} options which catalog to read, how much to print, and whether the tenant's templates are merged in * @returns {Promise} true if successful, false otherwise */ export async function listApplicationTemplates( - options: CatalogSource & { long?: boolean; includeOverrides?: boolean } + options: CatalogSource & { + long?: boolean; + allVersions?: boolean; + includeOverrides?: boolean; + } ): Promise { try { - const items = await listTemplates({ + const libraryOptions = { ...catalogOptionsOf(options), includeOverrides: options.includeOverrides === true, - }); - if (options.long) { + }; + const groups = groupTemplates(await listTemplates(libraryOptions)); + const shown = groups.flatMap((group) => + options.allVersions ? group.versions : [group.newest] + ); + const withOverride = shown.some((item) => item.isOverride); + if (!options.long) { + if (!options.allVersions) { + groups.forEach((group) => printMessage(group.name, 'data')); + return true; + } const table = createTable([ 'Id', - 'Name', - 'Group', - { hAlign: 'right', content: 'Version' }, - 'Type', - 'Override', + right('Version'), 'Newest', - 'Updated', - 'Display name', + ...(withOverride ? ['Override'] : []), ]); - items.forEach((item) => { + shown.forEach((item) => { table.push([ - item.templateId, item.name, - item.group ?? '', - { hAlign: 'right', content: item.templateVersion }, - item.templateType ?? '', - item.isOverride ? 'yes' : '', + right(item.templateVersion), item.newestInGroup ? 'yes' : '', - (item.dateUpdated ?? '').slice(0, 10), - item.displayName ?? '', + ...(withOverride ? [item.isOverride ? 'yes' : ''] : []), ]); }); printMessage(table.toString(), 'data'); - } else { - items.forEach((item) => { - printMessage(item.templateId, 'data'); - }); + return true; } + const entries = await readEntries(shown, options, libraryOptions); + const table = createTable([ + 'Id', + 'Display name', + 'Category', + 'Provides', + 'Connector', + options.allVersions ? right('Version') : right('Latest'), + options.allVersions ? 'Newest' : right('Versions'), + 'Updated', + ...(withOverride ? ['Override'] : []), + ]); + const groupOf = new Map( + groups.flatMap((group) => + group.versions.map((item) => [item, group] as const) + ) + ); + shown.forEach((item) => { + const cells: ListCells = { + item, + entry: entries.get(item.templateId) ?? {}, + versions: groupOf.get(item)?.versions.length ?? 1, + }; + table.push([ + item.name, + item.displayName ?? '', + categoryOf(cells.entry), + providesOf(cells.entry), + connectorOf(cells.entry), + right(item.templateVersion), + options.allVersions + ? item.newestInGroup + ? 'yes' + : '' + : right(String(cells.versions)), + isoDate(item.dateUpdated), + ...(withOverride ? [item.isOverride ? 'yes' : ''] : []), + ]); + }); + printMessage(table.toString(), 'data'); return true; } catch (error) { printCatalogFailure(error); @@ -115,42 +234,273 @@ export async function listApplicationTemplates( return false; } +/** A label and a value of the header of `describe`; a line without a value is not printed. */ +type Line = [string, string | undefined]; + +/** + * Print a section heading after an empty line. + * @param {string} title the heading + */ +function printSection(title: string): void { + // an empty message prints nothing, so the empty line is part of the heading + printMessage(`\n${c.heading(title)}`, 'data'); +} + +/** + * Print label and value lines, leaving out the lines that have no value. + * @param {Line[]} lines the lines + * @param {string} indent what each line starts with + */ +function printLines(lines: Line[], indent = ' '): void { + lines + .filter(([, value]) => value) + .forEach(([label, value]) => { + printMessage(`${indent}${label}: ${value}`, 'data'); + }); +} + /** - * Find the version of the list whose id is the one given. Ids are not - * case-sensitive. - * @param {TemplateListItem[]} items the merged list - * @param {string} templateId the id the user gave - * @returns {TemplateListItem | undefined} the version, if there is one + * The fields of a configuration or a form as one line: the labels, with a star + * after the ones that are required. + * @param {FieldRow[]} fields the fields + * @returns {string} the labels */ -function findTemplate( - items: TemplateListItem[], - templateId: string -): TemplateListItem | undefined { - const wanted = templateId.toLowerCase(); - return items.find((item) => item.templateId.toLowerCase() === wanted); +function fieldLabels(fields: FieldRow[]): string { + return fields + .map((field) => `${field.label}${field.required ? '*' : ''}`) + .join(', '); } /** - * Describe an application template: a summary of the version, or with `json` - * the whole template (the form definition and the connector defaults). - * @param {string} templateId the id of the version, for example `3_3-azure-ad` - * @param {CatalogSource & { json?: boolean; includeOverrides?: boolean }} options which catalog to read, whether to print the whole template, and whether the tenant's templates are merged in + * Print the fields of a connector configuration: one line of labels, or with + * `long` a table with the property, the type and whether it is required. + * @param {string} title what the fields are + * @param {FieldRow[]} fields the fields + * @param {boolean} long whether to print a table + */ +function printConfiguration( + title: string, + fields: FieldRow[], + long: boolean +): void { + if (fields.length === 0) return; + if (!long) { + printMessage( + ` ${title} (${fields.length}, * required): ${fieldLabels(fields)}`, + 'data' + ); + return; + } + printMessage(` ${title} (${fields.length}):`, 'data'); + const table = createTable(['Label', 'Property', 'Type', 'Required']); + fields.forEach((field) => { + table.push([ + field.label, + field.property, + field.type, + field.required ? 'yes' : '', + ]); + }); + printMessage(table.toString(), 'data'); +} + +/** + * Print the sections of a template that follow its header: the versions, the + * connector, the capabilities, the object types and the application form. + * Sections the entry has no data for are left out. + * @param {TemplateGroup} group all versions of the template + * @param {TemplateRow} item the version that is described + * @param {Json} entry the entry of that version + * @param {Map} entries the entries of all versions + * @param {boolean} long whether to print the properties of the object types and the fields of the forms + */ +function printTemplateSections( + group: TemplateGroup, + item: TemplateRow, + entry: Json, + entries: Map, + long: boolean +): void { + if (group.versions.length > 1) { + printSection('Versions'); + const rows = group.versions.map((version) => { + const versionEntry = + version === item ? entry : (entries.get(version.templateId) ?? {}); + return { + version, + bundle: textOf(versionEntry['bundleVersion']) ?? '', + counts: countsOf(versionEntry), + }; + }); + // a column that is empty for every version (templates without a connector + // or object types) is left out + const withBundle = rows.some((row) => row.bundle); + const withObjectTypes = rows.some((row) => row.counts.objectTypes > 0); + const table = createTable([ + right('Version'), + 'Updated', + ...(withBundle ? ['Connector bundle'] : []), + ...(withObjectTypes ? [right('Object types'), right('Properties')] : []), + 'Note', + ]); + rows.forEach(({ version, bundle, counts }) => { + table.push([ + right(version.templateVersion), + isoDate(version.dateUpdated), + ...(withBundle ? [bundle] : []), + ...(withObjectTypes + ? [ + right(counts.objectTypes ? String(counts.objectTypes) : ''), + right(counts.properties ? String(counts.properties) : ''), + ] + : []), + [ + version === item ? 'shown' : '', + version.newestInGroup ? 'newest' : '', + version.isOverride ? 'defined by the tenant' : '', + ] + .filter(Boolean) + .join(', '), + ]); + }); + printMessage(table.toString(), 'data'); + } + const connector = connectorSummaryOf(entry); + if (connector) { + printSection('Connector'); + printLines([ + ['Name', connector.name], + ['Class', connector.className], + [ + 'Bundle', + [connector.bundleName, connector.bundleRange].filter(Boolean).join(' '), + ], + ['Template version', connector.templateVersion], + ['Paging', connector.pagingType], + ['Type', connector.connectorType], + ['Needs a remote connector server (RCS)', connector.rcsOnly ? 'yes' : ''], + ['Prerequisites', connector.prerequisites], + ]); + printConfiguration('Basic configuration', connector.basic, long); + printConfiguration('Advanced configuration', connector.advanced, long); + } + const capabilities = capabilitiesOf(entry); + const capabilityLines: Line[] = [ + [ + 'Provisioning', + capabilities.provisioning + ? capabilities.provisioning.join(', ') || 'none' + : '', + ], + ['SSO', capabilities.sso.join(', ')], + ['SSO setup', capabilities.ssoSetup.join(', ')], + ['Incremental reconciliation', capabilities.incrementalRecon], + ['Target', capabilities.target ? 'yes' : ''], + ['Needs an OAuth flow', capabilities.requiresOauthFlow ? 'yes' : ''], + ['Client types', capabilities.clients.join(', ')], + ]; + if (capabilityLines.some(([, value]) => value)) { + printSection('Capabilities'); + printLines(capabilityLines); + } + const objectTypes = objectTypesOf(entry); + if (objectTypes.length > 0) { + printSection('Object types'); + const table = createTable([ + 'Name', + 'Native type', + 'Managed resource', + right('Properties'), + right('Required'), + ]); + objectTypes.forEach((objectType) => { + table.push([ + objectType.name, + objectType.nativeType, + objectType.managedResource, + right(String(objectType.properties.length)), + right(String(objectType.required)), + ]); + }); + printMessage(table.toString(), 'data'); + if (long) { + objectTypes.forEach((objectType) => { + if (objectType.properties.length === 0) return; + printSection( + `Properties of ${objectType.name || objectType.nativeType}` + ); + // the native name is only a column when it differs from the name + const withNative = objectType.properties.some( + (property) => property.nativeName !== property.name + ); + const properties = createTable([ + 'Name', + ...(withNative ? ['Native name'] : []), + 'Type', + 'Flags', + ]); + objectType.properties.forEach((property) => { + properties.push([ + property.name, + ...(withNative ? [property.nativeName] : []), + property.type, + property.flags.join(', '), + ]); + }); + printMessage(properties.toString(), 'data'); + }); + } + } + const sections = formSectionsOf(entry); + if (sections.length > 0) { + printSection('Application form'); + sections.forEach((section) => { + const noun = section.kind === 'fields' ? 'field' : 'default value'; + const count = `${section.names.length} ${noun}${ + section.names.length === 1 ? '' : 's' + }`; + printMessage( + ` ${section.section}: ${count}${ + long && section.names.length > 0 + ? `: ${section.names.join(', ')}` + : '' + }`, + 'data' + ); + }); + } +} + +/** + * Describe an application template: a summary of the version (the newest + * unless a version is named), its other versions, its connector, capabilities, + * object types and application form, or with `json` the whole entry. + * @param {TemplateSelector} selector the template (`-i` id or `-n` display name) and the version (`-v`) + * @param {CatalogSource & { json?: boolean; long?: boolean; includeOverrides?: boolean }} options which catalog to read, how much to print, and whether the tenant's templates are merged in * @returns {Promise} true if successful, false otherwise */ export async function describeApplicationTemplate( - templateId: string, - options: CatalogSource & { json?: boolean; includeOverrides?: boolean } + selector: TemplateSelector, + options: CatalogSource & { + json?: boolean; + long?: boolean; + includeOverrides?: boolean; + } ): Promise { try { const libraryOptions = { ...catalogOptionsOf(options), includeOverrides: options.includeOverrides === true, }; - const item = findTemplate(await listTemplates(libraryOptions), templateId); - if (!item) { - printMessage(`Application template ${templateId} not found.`, 'error'); + const selection = selectTemplate( + groupTemplates(await listTemplates(libraryOptions)), + selector + ); + if ('error' in selection) { + printMessage(selection.error, 'error'); return false; } + const { group, item } = selection; const resolution = await readTemplateInLibrary( { templateName: item.name, templateVersion: item.templateVersion }, libraryOptions @@ -158,7 +508,7 @@ export async function describeApplicationTemplate( const template = resolution.template; if (!template) { printMessage( - `Application template ${item.templateId} could not be read.`, + `Application template ${item.name} ${item.templateVersion} could not be read.`, 'error' ); return false; @@ -167,29 +517,32 @@ export async function describeApplicationTemplate( printMessage(JSON.stringify(template, null, 2), 'data'); return true; } - const description = template['description']; - const supportUrl = template['supportUrl']; - const lines: [string, string | undefined][] = [ - ['Id', item.templateId], - ['Name', item.name], + const entry = recordOf(template) ?? {}; + const description = textOf(entry['description']); + const supportUrl = textOf(entry['supportUrl']); + const header: Line[] = [ + ['Id', group.name], ['Display name', item.displayName], [ 'Group', item.group ? `${item.group} (${item.tier})` : `(none, ${item.tier})`, ], - ['Version', item.templateVersion], - ['Newest version', item.newestInGroup ? 'yes' : 'no'], - ['Type', item.templateType], - ['Updated', item.dateUpdated], - ['Defined by the tenant', item.isOverride ? 'yes' : 'no'], - ['Description', typeof description === 'string' ? description : ''], - ['Support URL', typeof supportUrl === 'string' ? supportUrl : ''], + ['Category', categoryOf(entry)], + ['Provides', providesOf(entry)], + [ + 'Version', + item.newestInGroup + ? `${item.templateVersion} (newest)` + : `${item.templateVersion} (the newest is ${group.newest.templateVersion})`, + ], + ['Updated', isoDate(item.dateUpdated)], + ['Defined by the tenant', item.isOverride ? 'yes' : ''], + ['Description', description], + ['Support URL', supportUrl], ]; - lines - .filter(([, value]) => value) - .forEach(([label, value]) => { - printMessage(`${label}: ${value}`, 'data'); - }); + printLines(header, ''); + const entries = await readEntries(group.versions, options, libraryOptions); + printTemplateSections(group, item, entry, entries, options.long === true); return true; } catch (error) { printCatalogFailure(error); diff --git a/src/utils/ApplicationTemplateView.ts b/src/utils/ApplicationTemplateView.ts new file mode 100644 index 000000000..6dc276ce4 --- /dev/null +++ b/src/utils/ApplicationTemplateView.ts @@ -0,0 +1,594 @@ +/** + * What `frodo app template list` and `describe` print about an application + * template, and how they find the template a user asked for. Everything here is + * a pure function of the catalog entries: no I/O and no library import, so it + * loads in a unit test without the library. + * + * The entries come from the CDN catalog or, for the templates a tenant defines + * itself, from the tenant, and the library checks only a few of their fields + * (`displayName`, `templateType`, `dateUpdated`). Every read below is therefore + * defensive: a field of an unexpected shape is left out, never an error. Text + * from an entry is printed with its control characters replaced, so an entry + * cannot move the cursor or recolor the terminal. + */ + +/** A JSON object whose fields are not known. */ +export type Json = Record; + +/** One template version, as the library lists it (the fields the commands use). */ +export type TemplateRow = { + /** The key the catalog keeps the version under, for example `3_3-azure-ad`. The commands do not print or accept it. */ + templateId: string; + /** The id of the template, for example `azure.ad`. */ + name: string; + group?: string; + tier: 'consumer' | 'enterprise'; + templateVersion: string; + displayName?: string; + templateType?: string; + dateUpdated?: string; + isOverride: boolean; + newestInGroup: boolean; +}; + +/** All versions of one template, oldest to newest as the library lists them. */ +export type TemplateGroup = { + /** The comparison key of the id: dots and hyphens are one, the case is not significant. */ + key: string; + /** The id of the template, for example `azure.ad`. */ + name: string; + versions: TemplateRow[]; + /** The version the commands use when no version is given. */ + newest: TemplateRow; +}; + +/** What the user typed to name a template. */ +export type TemplateSelector = { + /** `-i`: the id of the template. */ + id?: string; + /** `-n`: the display name of the template. */ + name?: string; + /** `-v`: a version other than the newest. */ + version?: string; +}; + +/** The template and the version a selector names, or why it names none. */ +export type TemplateSelection = + { group: TemplateGroup; item: TemplateRow } | { error: string }; + +/** + * The object if the value is one (not null, not an array). + * @param {unknown} value any value + * @returns {Json | undefined} the object, if the value is one + */ +export function recordOf(value: unknown): Json | undefined { + return typeof value === 'object' && value !== null && !Array.isArray(value) + ? (value as Json) + : undefined; +} + +/** + * The text of a value that is printed on one line: a string (control + * characters, including line breaks, become a space) or a finite number. + * @param {unknown} value any value + * @returns {string | undefined} the text, if there is any + */ +export function textOf(value: unknown): string | undefined { + let text: string | undefined; + if (typeof value === 'string') text = value; + else if (typeof value === 'number' && Number.isFinite(value)) { + text = String(value); + } + if (text === undefined) return undefined; + // eslint-disable-next-line no-control-regex + const clean = text.replace(/[\u0000-\u001f\u007f-\u009f]+/g, ' ').trim(); + return clean || undefined; +} + +/** + * The comparison key of a template id: the library's own key (dots and hyphens + * are one) in lower case. + * @param {string} name the id a user typed or a catalog lists + * @returns {string} the key + */ +export function nameKey(name: string): string { + return String(name).trim().replace(/\./g, '-').toLowerCase(); +} + +const pad2 = (n: number) => String(n).padStart(2, '0'); + +/** + * A catalog date as `YYYY-MM-DD`. The catalog writes `MM-DD-YYYY` and `M-D-YYYY` + * (`08-19-2026`, `10-5-2023`); the month comes first. A text that is not such a + * date is returned as it is. Dates only decorate a listing: they are never used + * to order versions (they do not follow the version order). + * @param {unknown} value the `dateUpdated` of an entry + * @returns {string} the date, or '' if there is none + */ +export function isoDate(value: unknown): string { + const raw = textOf(value); + if (!raw) return ''; + const match = /^(\d{1,2})-(\d{1,2})-(\d{4})$/.exec(raw); + if (match) { + const [month, day, year] = [Number(match[1]), Number(match[2]), match[3]]; + const check = new Date(Date.UTC(Number(year), month - 1, day)); + if ( + check.getUTCFullYear() === Number(year) && + check.getUTCMonth() === month - 1 && + check.getUTCDate() === day + ) { + return `${year}-${pad2(month)}-${pad2(day)}`; + } + } + return raw; +} + +/** + * Group the versions of a list by template, in the order of the list. + * @param {TemplateRow[]} items the versions as the library lists them + * @returns {TemplateGroup[]} one group per template + */ +export function groupTemplates(items: TemplateRow[]): TemplateGroup[] { + const groups = new Map(); + for (const item of items) { + const key = nameKey(item.name); + let group = groups.get(key); + if (!group) { + group = { key, name: item.name, versions: [], newest: item }; + groups.set(key, group); + } + group.versions.push(item); + } + for (const group of groups.values()) { + // the library marks the newest by the version number (never by the date); + // without a mark the last version of the list is the newest + group.newest = + group.versions.find((item) => item.newestInGroup) ?? + group.versions[group.versions.length - 1]; + } + return [...groups.values()]; +} + +/** + * Find the template and the version a user named. + * @param {TemplateGroup[]} groups the templates of the list + * @param {TemplateSelector} selector the id (`-i`) or display name (`-n`), and the version (`-v`) + * @returns {TemplateSelection} the version, or an error to print + */ +export function selectTemplate( + groups: TemplateGroup[], + selector: TemplateSelector +): TemplateSelection { + const id = textOf(selector.id); + const name = textOf(selector.name); + const version = textOf(selector.version); + let byId: TemplateGroup | undefined; + let byName: TemplateGroup | undefined; + if (id) { + byId = groups.find((group) => group.key === nameKey(id)); + if (!byId) { + const prefixed = /^\d+_\d+-/.test(id) + ? ' An id carries no version: name the version with -v.' + : ''; + return { + error: `Application template "${id}" not found. "frodo app template list" prints the ids.${prefixed}`, + }; + } + } + if (name) { + const wanted = name.toLowerCase(); + const named = groups.filter( + (group) => + (textOf(group.newest.displayName) ?? '').toLowerCase() === wanted + ); + if (named.length > 1) { + return { + error: `More than one application template has the display name "${name}": ${named + .map((group) => group.name) + .join(', ')}. Name one with -i.`, + }; + } + byName = named[0]; + if (!byName) { + return { + error: `No application template has the display name "${name}". "frodo app template list --long" prints the display names.`, + }; + } + } + if (byId && byName && byId !== byName) { + return { + error: `-i "${id}" and -n "${name}" name different application templates (${byId.name} and ${byName.name}). Give only one.`, + }; + } + const group = byId ?? byName; + if (!group) { + return { error: 'Name the template with -i (id) or -n (display name).' }; + } + if (!version) return { group, item: group.newest }; + const wanted = version.toLowerCase(); + const item = group.versions.find( + (candidate) => candidate.templateVersion.toLowerCase() === wanted + ); + if (!item) { + return { + error: `Application template ${group.name} has no version ${version}. Versions: ${group.versions + .map((candidate) => candidate.templateVersion) + .join(', ')}.`, + }; + } + return { group, item }; +} + +/** + * The category the catalog gives the template (the first of `categories`). + * @param {Json} template the entry + * @returns {string} the category, or '' if there is none + */ +export function categoryOf(template: Json): string { + const categories = template['categories']; + if (Array.isArray(categories)) { + for (const category of categories) { + const text = textOf(category); + if (text) return text; + } + return ''; + } + return textOf(categories) ?? ''; +} + +/** + * Whether the entry has a connector: a provisioning template. + * @param {Json} template the entry + * @returns {boolean} true if it has one + */ +export function hasConnector(template: Json): boolean { + return ( + recordOf(template['connectorTemplate']) !== undefined || + textOf(template['connectorName']) !== undefined + ); +} + +/** A text that is a dotted class name (`org.forgerock.openicf.connectors.epic.ser.SerConnector`). */ +const CLASS_NAME = /^[A-Za-z_$][\w$]*(\.[A-Za-z_$][\w$]*)+$/; + +/** + * The name of the connector for a list: its display name without the word + * "Connector" (`MSGraphAPI Connector` is `MSGraphAPI`), else the class name + * of the connector without its package. A display name that is itself a class + * name (one entry of the catalog has such a name) is shortened the same way. + * @param {Json} template the entry + * @returns {string} the name, or '' if the entry has no connector + */ +export function connectorOf(template: Json): string { + const display = textOf(template['connectorDisplayName']); + if (display && !CLASS_NAME.test(display)) { + return display.replace(/\s+connector$/i, '') || display; + } + const className = display ?? textOf(template['connectorName']); + return className ? (className.split('.').pop() ?? className) : ''; +} + +/** + * The SSO protocols the entry says it supports (`capabilities.sso`, the ones + * that are true). + * @param {Json} template the entry + * @returns {string[]} the protocols, for example `SAML`, `OIDC` + */ +export function ssoProtocolsOf(template: Json): string[] { + const sso = recordOf(recordOf(template['capabilities'])?.['sso']); + return sso + ? Object.entries(sso) + .filter(([, supported]) => supported === true) + .map(([protocol]) => textOf(protocol) ?? '') + .filter(Boolean) + : []; +} + +/** + * What the template provides, in a few words: `target+provisioning` (an AI + * target with a connector), `provisioning` (a connector), `SSO: SAML/OIDC` (the + * protocols of `capabilities.sso`), both of the last two joined by a comma, or + * else the `templateType` (`Bookmark`). + * @param {Json} template the entry + * @returns {string} the words, or '' if the entry says nothing + */ +export function providesOf(template: Json): string { + const parts: string[] = []; + const target = template['target'] === true; + if (hasConnector(template)) { + parts.push(target ? 'target+provisioning' : 'provisioning'); + } else if (target) { + parts.push('target'); + } + const sso = ssoProtocolsOf(template); + if (sso.length > 0) parts.push(`SSO: ${sso.join('/')}`); + return parts.length > 0 + ? parts.join(', ') + : (textOf(template['templateType']) ?? ''); +} + +const OPERATIONS = ['create', 'read', 'update', 'delete']; + +/** + * The operations of provisioning the entry allows (`capabilities.provisioning`, + * the ones that are true), create, read, update, delete first. + * @param {Json} template the entry + * @returns {string[] | undefined} the operations; undefined if the entry says nothing about provisioning + */ +export function provisioningOperationsOf(template: Json): string[] | undefined { + const provisioning = recordOf( + recordOf(template['capabilities'])?.['provisioning'] + ); + if (!provisioning) return undefined; + const allowed = Object.entries(provisioning) + .filter(([, supported]) => supported === true) + .map(([operation]) => (textOf(operation) ?? '').toLowerCase()) + .filter(Boolean); + return [ + ...OPERATIONS.filter((operation) => allowed.includes(operation)), + ...allowed.filter((operation) => !OPERATIONS.includes(operation)), + ]; +} + +/** One configuration field of a connector or a field of the application form. */ +export type FieldRow = { + label: string; + /** The property the field sets (`model`), if the entry says. */ + property: string; + type: string; + required: boolean; +}; + +/** + * The fields in a value that holds them as a list of lists (the way the Admin + * UI lays out a form), as rows. + * @param {unknown} value the list + * @param {number} depth how deep the list is nested (guards a cycle of lists) + * @returns {FieldRow[]} the fields + */ +function fieldsIn(value: unknown, depth = 0): FieldRow[] { + if (Array.isArray(value)) { + return depth > 4 + ? [] + : value.flatMap((entry) => fieldsIn(entry, depth + 1)); + } + const field = recordOf(value); + if (!field) return []; + const property = textOf(field['model']) ?? ''; + const label = + textOf(field['label']) ?? (property || (textOf(field['name']) ?? '')); + if (!label) return []; + return [ + { + label, + property, + type: textOf(field['type']) ?? '', + required: recordOf(field['validation'])?.['required'] === true, + }, + ]; +} + +/** What the connector of a template is and how it is configured. */ +export type ConnectorSummary = { + name: string; + className: string; + bundleName: string; + bundleRange: string; + templateVersion: string; + pagingType: string; + rcsOnly: boolean; + connectorType: string; + basic: FieldRow[]; + advanced: FieldRow[]; + prerequisites: string; +}; + +/** + * The connector of the entry. + * @param {Json} template the entry + * @returns {ConnectorSummary | undefined} the connector; undefined if the entry has none + */ +export function connectorSummaryOf( + template: Json +): ConnectorSummary | undefined { + if (!hasConnector(template)) return undefined; + const connector = recordOf(template['connectorTemplate']) ?? {}; + return { + name: textOf(template['connectorDisplayName']) ?? connectorOf(template), + className: textOf(template['connectorName']) ?? '', + bundleName: textOf(template['bundleName']) ?? '', + bundleRange: textOf(template['bundleVersion']) ?? '', + templateVersion: textOf(connector['version']) ?? '', + pagingType: textOf(connector['pagingType']) ?? '', + rcsOnly: connector['rcsOnly'] === true, + connectorType: textOf(connector['connectorType']) ?? '', + basic: fieldsIn(connector['basic']), + advanced: fieldsIn(connector['advanced']), + prerequisites: textOf(connector['prerequisitesTitle']) ?? '', + }; +} + +/** One property of an object type. */ +export type PropertyRow = { + name: string; + nativeName: string; + type: string; + /** Short words for what is special about the property: `required`, `multi-valued`, `read-only`, ... */ + flags: string[]; +}; + +/** One object type a template provisions (users, groups, ...). */ +export type ObjectTypeRow = { + name: string; + nativeType: string; + managedResource: string; + properties: PropertyRow[]; + required: number; +}; + +const PROPERTY_FLAGS: [string, string][] = [ + ['required', 'required'], + ['multiValued', 'multi-valued'], + ['notReadable', 'not readable'], + ['notCreatable', 'not creatable'], + ['notUpdateable', 'not updateable'], + ['notReturnedByDefault', 'not returned by default'], + ['isUsername', 'username'], + ['isDisplay', 'display'], + ['isMail', 'mail'], + ['nonAccountObject', 'not an account'], +]; + +/** + * The words for what is special about a property. A property that can be + * neither created nor updated is `read-only`. + * @param {Json} property the property of an object type + * @returns {string[]} the words + */ +function flagsOf(property: Json): string[] { + const flags = PROPERTY_FLAGS.filter(([key]) => property[key] === true).map( + ([, words]) => words + ); + if (flags.includes('not creatable') && flags.includes('not updateable')) { + const at = flags.indexOf('not creatable'); + return [ + ...flags.slice(0, at), + 'read-only', + ...flags.slice(at + 1).filter((words) => words !== 'not updateable'), + ]; + } + return flags; +} + +/** + * The object types of the entry and their properties. + * @param {Json} template the entry + * @returns {ObjectTypeRow[]} the object types; none if the entry has none + */ +export function objectTypesOf(template: Json): ObjectTypeRow[] { + const objectTypes = template['objectTypes']; + if (!Array.isArray(objectTypes)) return []; + const rows: ObjectTypeRow[] = []; + for (const entry of objectTypes) { + const objectType = recordOf(entry); + if (!objectType) continue; + const properties: PropertyRow[] = []; + const list = objectType['properties']; + for (const item of Array.isArray(list) ? list : []) { + const property = recordOf(item); + if (!property) continue; + const nativeName = textOf(property['nativeName']) ?? ''; + properties.push({ + name: textOf(property['name']) ?? nativeName, + nativeName, + type: textOf(property['type']) ?? '', + flags: flagsOf(property), + }); + } + rows.push({ + name: textOf(objectType['name']) ?? textOf(objectType['id']) ?? '', + nativeType: textOf(objectType['nativeType']) ?? '', + managedResource: textOf(objectType['managedResource']) ?? '', + properties, + required: properties.filter((property) => + property.flags.includes('required') + ).length, + }); + } + return rows; +} + +/** One section of the application form of a template. */ +export type FormSection = { + section: string; + /** `fields` for a list of form fields, `defaults` for a set of default values. */ + kind: 'fields' | 'defaults'; + /** The labels of the fields, or the names of the default values. */ + names: string[]; +}; + +/** + * The sections of the application form (`schema`) of the entry. + * @param {Json} template the entry + * @returns {FormSection[]} the sections; none if the entry has none + */ +export function formSectionsOf(template: Json): FormSection[] { + const schema = recordOf(template['schema']); + if (!schema) return []; + const sections: FormSection[] = []; + for (const [section, value] of Object.entries(schema)) { + const name = textOf(section); + if (!name) continue; + const defaults = recordOf(recordOf(value)?.['defaultValues']); + const kind = defaults ? 'defaults' : 'fields'; + const names = defaults + ? Object.keys(defaults) + .map((key) => textOf(key) ?? '') + .filter(Boolean) + : fieldsIn(value).map((field) => field.label); + // a section of a shape the commands do not know, or an empty one, says nothing + if (names.length > 0) sections.push({ section: name, kind, names }); + } + return sections; +} + +/** What else the entry says it can do. */ +export type CapabilitiesSummary = { + provisioning?: string[]; + sso: string[]; + /** The keys of the `sso` block (the setup instructions and defaults of an SSO template). */ + ssoSetup: string[]; + incrementalRecon: string; + target: boolean; + requiresOauthFlow: boolean; + clients: string[]; +}; + +/** + * The capabilities of the entry. + * @param {Json} template the entry + * @returns {CapabilitiesSummary} what the entry says + */ +export function capabilitiesOf(template: Json): CapabilitiesSummary { + const recon = recordOf(template['incrementalRecon']); + const reconProperty = textOf(recon?.['propertyName']); + const operator = textOf(recon?.['comparisonOperator']); + const clients = template['clients']; + return { + provisioning: provisioningOperationsOf(template), + sso: ssoProtocolsOf(template), + ssoSetup: Object.keys(recordOf(template['sso']) ?? {}) + .map((key) => textOf(key) ?? '') + .filter(Boolean), + incrementalRecon: reconProperty + ? operator + ? `${reconProperty} (${operator})` + : reconProperty + : '', + target: template['target'] === true, + requiresOauthFlow: template['requiresOauthFlow'] === true, + clients: Array.isArray(clients) + ? clients.map((client) => textOf(client) ?? '').filter(Boolean) + : [], + }; +} + +/** + * The numbers of object types and properties of an entry, for a table of the + * versions. + * @param {Json} template the entry + * @returns {{ objectTypes: number; properties: number }} the counts + */ +export function countsOf(template: Json): { + objectTypes: number; + properties: number; +} { + const objectTypes = objectTypesOf(template); + return { + objectTypes: objectTypes.length, + properties: objectTypes.reduce( + (total, objectType) => total + objectType.properties.length, + 0 + ), + }; +} diff --git a/src/utils/interactive/EntityPickFamilies.appTemplate.test.ts b/src/utils/interactive/EntityPickFamilies.appTemplate.test.ts new file mode 100644 index 000000000..41a9d71ee --- /dev/null +++ b/src/utils/interactive/EntityPickFamilies.appTemplate.test.ts @@ -0,0 +1,141 @@ +import { afterEach, describe, expect, it, jest } from '@jest/globals'; +import { frodo } from '@rockcarver/frodo-lib'; +import { Command, Option } from 'commander'; +import { registerEntityPickFamilies } from './EntityPickFamilies'; +import { familyEntityPicks } from './EntityPickRegistry'; + +// The entity picks of `frodo app template describe` (decision record 0038): +// one row per template, not per version, by id for -i and by display name for +// -n. The version is not picked: -v names it and the newest is the default. + +registerEntityPickFamilies(); + +const listItem = ( + name: string, + version: string, + displayName: string, + more: Record = {} +) => ({ + templateId: `${version.replace(/\./g, '_')}-${name.replace(/\./g, '-')}`, + name, + group: name, + tier: 'enterprise' as const, + templateVersion: version, + displayName, + templateType: 'Enterprise', + dateUpdated: '01-01-2025', + isOverride: false, + newestInGroup: false, + ...more, +}); + +/** Two versions of one template, the older one listed first, and a template with one. */ +const list = () => [ + listItem('azure.ad', '3.3', 'Microsoft Entra'), + listItem('azure.ad', '3.4', 'Microsoft Entra', { newestInGroup: true }), + listItem('saml', '1.3', 'SAML', { + newestInGroup: true, + isOverride: true, + templateType: 'SAML', + }), +]; + +/** The command `frodo app template ` with the given options. */ +function describeCommand(verb: string, ...flags: string[]): Command { + const root = new Command('frodo'); + const app = new Command('app'); + const template = new Command('template'); + const leaf = new Command(verb); + for (const flag of flags) leaf.addOption(new Option(flag)); + root.addCommand(app); + app.addCommand(template); + template.addCommand(leaf); + return leaf; +} + +const flags = [ + '-i, --template-id ', + '-n, --template-name ', + '--channel ', +]; + +afterEach(() => { + jest.restoreAllMocks(); +}); + +describe('the application template pickers', () => { + it('give a command that has -i and -n a pick for each, and a command without them none', () => { + expect( + familyEntityPicks(describeCommand('describe', ...flags)).map( + (pick) => pick.name + ) + ).toEqual(['templateId', 'templateName']); + expect(familyEntityPicks(describeCommand('list'))).toEqual([]); + expect(familyEntityPicks(describeCommand('download'))).toEqual([]); + }); + + it('list one row per template, the newest version of it, in the order of the list', async () => { + const read = jest + .spyOn(frodo.app.template, 'listTemplates') + .mockResolvedValue(list()); + const [byId, byName] = familyEntityPicks( + describeCommand('describe', ...flags) + ); + for (const pick of [byId, byName]) { + const rows = await pick.load(); + expect(rows.map((row) => `${row.name} ${row.templateVersion}`)).toEqual([ + 'azure.ad 3.4', + 'saml 1.3', + ]); + } + // the tenant's templates are merged in: the picker runs after a login + expect(read).toHaveBeenCalledWith({ includeOverrides: true }); + }); + + it('read the catalog the command was given, not only the default', async () => { + const read = jest + .spyOn(frodo.app.template, 'listTemplates') + .mockResolvedValue(list()); + const command = describeCommand('describe', ...flags); + command.setOptionValue('channel', 'development'); + const [pick] = familyEntityPicks(command); + await pick.loadFor?.(command); + expect(read).toHaveBeenCalledWith({ + channel: 'development', + includeOverrides: true, + }); + }); + + it('write the id for -i and the display name for -n, and never the key the catalog keeps a version under', () => { + const [azure, saml] = list().filter((item) => item.newestInGroup); + const [byId, byName] = familyEntityPicks( + describeCommand('describe', ...flags) + ); + expect(byId.label(azure)).toBe('azure.ad'); + expect(byName.label(azure)).toBe('Microsoft Entra'); + expect(byName.label({ ...azure, displayName: undefined })).toBe('azure.ad'); + // what a row says besides its label + expect(byId.description?.(azure)).toBe('Microsoft Entra · Enterprise'); + expect(byId.description?.(saml)).toBe( + 'SAML · SAML · defined by the tenant' + ); + expect(byName.description?.(azure)).toBe('azure.ad'); + for (const row of [azure, saml]) { + for (const pick of [byId, byName]) { + expect(pick.label(row)).not.toMatch(/\d_\d+-/); + expect(pick.value).toBeUndefined(); + } + } + }); + + it('are searched by id and by display name, in either picker', () => { + const [azure] = list().filter((item) => item.newestInGroup); + for (const pick of familyEntityPicks( + describeCommand('describe', ...flags) + )) { + expect(pick.search?.(azure, 'azure')).toBe(true); + expect(pick.search?.(azure, 'ENTRA')).toBe(true); + expect(pick.search?.(azure, 'saml')).toBe(false); + } + }); +}); diff --git a/src/utils/interactive/EntityPickFamilies.ts b/src/utils/interactive/EntityPickFamilies.ts index 56aed8fb0..f6df50e8a 100644 --- a/src/utils/interactive/EntityPickFamilies.ts +++ b/src/utils/interactive/EntityPickFamilies.ts @@ -32,6 +32,7 @@ import { } from '@rockcarver/frodo-lib'; import type { Command } from 'commander'; import { catalogOptionsOf } from '../ApplicationTemplateSource'; +import { groupTemplates } from '../ApplicationTemplateView'; import { sameHost, savedConnectionProfiles, @@ -597,38 +598,65 @@ export function registerEntityPickFamilies(): void { ], }); - // `app template`: the versions of the application templates, by id (for - // example `1_2-saml`). The picker lists the catalog the command reads - // (`--templates` or `--channel`) with the templates of the tenant merged in; - // it runs after a login, so a command that needs no tenant never opens it. + // `app template`: the application templates, one row per template (not per + // version), by id (for example `azure.ad`) for -i and by display name for -n. + // The picker lists the catalog the command reads (`--templates` or + // `--channel`) with the templates of the tenant merged in; it runs after a + // login, so a command that needs no tenant never opens it. The version is + // not picked: -v names it, and the newest is the default. type ApplicationTemplate = Awaited< ReturnType >[number]; + /** The newest version of every template of a list, in the order of the list. */ + const newestTemplates = ( + templates: ApplicationTemplate[] + ): ApplicationTemplate[] => + groupTemplates(templates).map((group) => group.newest); + + const templateDescription = (template: ApplicationTemplate): string => + [ + template.displayName, + template.templateType, + template.isOverride ? 'defined by the tenant' : undefined, + ] + .filter(Boolean) + .join(' · '); + + const loadTemplates = async ( + command?: Command + ): Promise => + newestTemplates( + await frodo.app.template.listTemplates({ + ...(command ? catalogOptionsOf(command.opts()) : {}), + includeOverrides: true, + }) + ); + registerEntityPickFamily({ family: 'app template', picks: [ entityPick({ name: 'templateId', kind: 'application template', - load: async () => - frodo.app.template.listTemplates({ includeOverrides: true }), - loadFor: async (command) => - frodo.app.template.listTemplates({ - ...catalogOptionsOf(command.opts()), - includeOverrides: true, - }), - label: (template) => template.templateId, - description: (template) => - [ - template.displayName, - template.templateType, - template.isOverride ? 'defined by the tenant' : undefined, - ] - .filter(Boolean) - .join(' · '), + load: async () => loadTemplates(), + loadFor: async (command) => loadTemplates(command), + label: (template) => template.name, + description: templateDescription, + search: (template, query) => + `${template.name} ${template.displayName ?? ''}` + .toLowerCase() + .includes(query.toLowerCase()), + }), + entityPick({ + name: 'templateName', + kind: 'application template', + load: async () => loadTemplates(), + loadFor: async (command) => loadTemplates(command), + label: (template) => template.displayName || template.name, + description: (template) => template.name, search: (template, query) => - `${template.templateId} ${template.displayName ?? ''} ${template.name}` + `${template.name} ${template.displayName ?? ''}` .toLowerCase() .includes(query.toLowerCase()), }), diff --git a/test/client_cli/en/__snapshots__/app-template-describe.test.js.snap b/test/client_cli/en/__snapshots__/app-template-describe.test.js.snap index 50d902cd3..71cbf1777 100644 --- a/test/client_cli/en/__snapshots__/app-template-describe.test.js.snap +++ b/test/client_cli/en/__snapshots__/app-template-describe.test.js.snap @@ -3,49 +3,70 @@ exports[`CLI help interface for 'app template describe --help' should be expected english 1`] = ` "Usage: frodo app template describe [options] [host] [realm] [username] [password] -Describe an application template. +Describe an application template: what it provides, its versions, connector, +capabilities, object types and application form. The newest version unless -v +names another. Arguments: - host AM base URL, e.g.: - https://cdk.iam.example.com/am. To use a - connection profile, just specify a unique - substring or alias. - realm Realm. Specify realm as '/' for the root - realm or 'realm' or '/parent/child' - otherwise. (default: "alpha" for Identity - Cloud tenants, "/" otherwise.) - username Username to login with. Must be an admin user - with appropriate rights to manage - authentication journeys/trees. If given - without a password, and it matches the - username already stored in the connection - profile for the target host, frodo uses that - profile's stored password instead of - requiring it on the command line. - password Password. + host AM base URL, e.g.: + https://cdk.iam.example.com/am. To use a + connection profile, just specify a unique + substring or alias. + realm Realm. Specify realm as '/' for the root + realm or 'realm' or '/parent/child' + otherwise. (default: "alpha" for Identity + Cloud tenants, "/" otherwise.) + username Username to login with. Must be an admin + user with appropriate rights to manage + authentication journeys/trees. If given + without a password, and it matches the + username already stored in the connection + profile for the target host, frodo uses + that profile's stored password instead of + requiring it on the command line. + password Password. Options: - --channel Which catalog of the CDN to read: production - or development (the development catalog holds - a few more template versions). Default: - production. Cannot be used with --templates. - (choices: "production", "development") - -i, --template-id Template id: the id of one version of a - template, as "frodo app template list" prints - it (for example 1_2-saml). Ids are not - case-sensitive. (pick from application - template) (prompts if missing) - --json Output the whole template (the form - definition and the connector defaults) in - JSON format. - --templates Read the application template catalog from - this file instead of the CDN: the JSON that - "frodo app template download" saves, or the - gzip file as the CDN serves it. Cannot be - used with --channel. - -h, --help Help - -hh, --help-more Help with all options. - -hhh, --help-all Help with all options, environment variables, - and usage examples. + --channel Which catalog of the CDN to read: + production or development (the + development catalog holds a few more + template versions). Default: production. + Cannot be used with --templates. + (choices: "production", "development") + -i, --template-id Template id: the id of the template, as + "frodo app template list" prints it (for + example azure.ad). Dots and hyphens are + the same, and the case does not matter. + Cannot be used with -n. (pick from + application template) + --json Output the whole template (the form + definition and the connector defaults) in + JSON format. + -l, --long Long: also the properties of each object + type and the fields of the connector + configuration and of the application + form. (default: false) + -n, --template-name Display name of the template (for example + "Microsoft Entra"), as "frodo app + template list --long" prints it. The case + does not matter. Cannot be used with -i. + (pick from application template) + --templates Read the application template catalog + from this file instead of the CDN: the + JSON that "frodo app template download" + saves, or the gzip file as the CDN serves + it. Cannot be used with --channel. + -v, --template-version The version to describe (for example + 3.1). Default: the newest version. The + versions of a template are listed in its + description. + -h, --help Help + -hh, --help-more Help with all options. + -hhh, --help-all Help with all options, environment + variables, and usage examples. + +Rule: + one of -i, --template-id | -n, --template-name is required -- choose which template to describe + " `; diff --git a/test/client_cli/en/__snapshots__/app-template-list.test.js.snap b/test/client_cli/en/__snapshots__/app-template-list.test.js.snap index 0522eb46e..27ea83cee 100644 --- a/test/client_cli/en/__snapshots__/app-template-list.test.js.snap +++ b/test/client_cli/en/__snapshots__/app-template-list.test.js.snap @@ -3,8 +3,9 @@ exports[`CLI help interface for 'app template list --help' should be expected english 1`] = ` "Usage: frodo app template list [options] [host] [realm] [username] [password] -List application templates: the catalog of the Admin UI, with the templates of -the tenant merged over it when a tenant is given. +List application templates, one per template with its newest version: the +catalog of the Admin UI, with the templates of the tenant merged over it when a +tenant is given. Arguments: host AM base URL, e.g.: https://cdk.iam.example.com/am. To use @@ -23,11 +24,16 @@ Arguments: password Password. Options: + --all-versions List every version of every template, not only the + newest: the id and the version. (default: false) --channel Which catalog of the CDN to read: production or development (the development catalog holds a few more template versions). Default: production. Cannot be used with --templates. (choices: "production", "development") - -l, --long Long with all fields. (default: false) + -l, --long Long: a table with the display name, category, what the + template provides, its connector, its newest version, the + number of versions and the date of the newest. (default: + false) --templates Read the application template catalog from this file instead of the CDN: the JSON that "frodo app template download" saves, or the gzip file as the CDN serves it. diff --git a/test/client_cli/en/__snapshots__/app-template.test.js.snap b/test/client_cli/en/__snapshots__/app-template.test.js.snap index ed0bdca61..692334b97 100644 --- a/test/client_cli/en/__snapshots__/app-template.test.js.snap +++ b/test/client_cli/en/__snapshots__/app-template.test.js.snap @@ -17,13 +17,16 @@ Runtime Options: FRODO_NO_PROMPT). Commands: - describe Describe an application template. + describe Describe an application template: what it provides, its + versions, connector, capabilities, object types and + application form. The newest version unless -v names + another. download Download the application template catalog from the CDN and save it to a file, to copy it to a machine that cannot reach the internet. - list List application templates: the catalog of the Admin UI, - with the templates of the tenant merged over it when a - tenant is given. + list List application templates, one per template with its newest + version: the catalog of the Admin UI, with the templates of + the tenant merged over it when a tenant is given. Utilities: help display help for command diff --git a/test/unit/ApplicationTemplateCommands.test.js b/test/unit/ApplicationTemplateCommands.test.js index b8daaad37..a6a6ad364 100644 --- a/test/unit/ApplicationTemplateCommands.test.js +++ b/test/unit/ApplicationTemplateCommands.test.js @@ -5,9 +5,13 @@ * tests guard what only the real command tree shows: * - `list` and `describe` need no connection when no tenant is given, and * `download` never takes one; - * - `--templates` reads the JSON file and the gzip file as the CDN serves it, - * `-i` finds the version and the exit code tells a failure; - * - `--templates` and `--channel` cannot be combined. + * - `--templates` reads the JSON file and the gzip file as the CDN serves it; + * - `list` shows the newest version of each template, `--all-versions` every + * version; `describe` finds a template by `-i` (id) or `-n` (display name), + * describes the newest version unless `-v` names another, and does not know + * the key the catalog keeps a version under; + * - the exit code tells a failure, and `-i` and `-n` cannot be combined, nor + * `--templates` and `--channel`. * Run `npx tsdown` first: the test runs dist/launch.cjs. */ import cp from 'child_process'; @@ -15,43 +19,14 @@ import fs from 'fs'; import os from 'os'; import path from 'path'; import { gzipSync } from 'zlib'; +import { + fixtureCatalog, + fixtureIds, +} from '../utils/ApplicationTemplateFixtures.js'; const LAUNCH = path.join(process.cwd(), 'dist', 'launch.cjs'); -const catalog = { - AppTemplates: { - consumer: { - Saml: { - '1_2-saml': { - id: 'saml', - templateVersion: '1.2', - displayName: 'SAML', - templateType: 'SAML', - description: 'SAML applications enable single sign-on.', - dateUpdated: '09-09-2024', - }, - '1_3-saml': { - id: 'saml', - templateVersion: '1.3', - displayName: 'SAML', - templateType: 'SAML', - dateUpdated: '10-10-2025', - }, - }, - }, - enterprise: { - AzureAD: { - '3_3-azure-ad': { - id: 'azure.ad', - templateVersion: '3.3', - displayName: 'Azure AD', - templateType: 'Enterprise', - dateUpdated: '01-01-2025', - }, - }, - }, - }, -}; +const catalog = fixtureCatalog; let home; @@ -98,7 +73,7 @@ afterAll(() => { }); describe('frodo app template, without a tenant', () => { - test('1: list prints the ids of the catalog file, needs no login and exits 0', async () => { + test('1: list prints the id of each template of the catalog file once, needs no login and exits 0', async () => { const { code, out } = await frodo( 'app', 'template', @@ -107,15 +82,11 @@ describe('frodo app template, without a tenant', () => { 'catalog.json' ); expect(code).toBe(0); - // the library sorts by template name, then by version - expect(out.trim().split('\n')).toEqual([ - '3_3-azure-ad', - '1_2-saml', - '1_3-saml', - ]); + // the library sorts by template name; the newest version is listed, no key + expect(out.trim().split('\n')).toEqual(fixtureIds); }); - test('2: list --long reads the gzip file as the CDN serves it and marks the newest version of a template', async () => { + test('2: list --long reads the gzip file as the CDN serves it and has a row for each template with its newest version and the number of versions', async () => { const { code, out } = await frodo( 'app', 'template', @@ -125,45 +96,141 @@ describe('frodo app template, without a tenant', () => { 'catalog.json.gz' ); expect(code).toBe(0); - const row = (id) => out.split('\n').find((line) => line.startsWith(id)); - expect(row('1_2-saml')).not.toMatch(/yes/); - expect(row('1_3-saml')).toMatch(/yes/); - expect(row('3_3-azure-ad')).toContain('Azure AD'); + const lines = out.trim().split('\n'); + expect(lines[0]).toMatch( + /^Id +│Display name +│Category +│Provides +│Connector +│Latest│Versions│Updated *$/ + ); + expect(lines).toHaveLength(1 + fixtureIds.length); + const row = (id) => lines.find((line) => line.startsWith(id)); + const cells = (line) => line.split('│').map((cell) => cell.trim()); + expect(cells(row('azure.ad'))).toEqual([ + 'azure.ad', + 'Microsoft Entra', + 'Uncategorized', + 'provisioning', + 'MSGraphAPI', + '3.4', + '2', + '2026-08-19', + ]); + expect(cells(row('saml'))[3]).toBe('SSO: SAML'); + expect(cells(row('ai.target'))[3]).toBe('target+provisioning'); + // 2.10 is newer than 2.3, and a date that is no date is printed as it is + expect(cells(row('ldap')).slice(5)).toEqual([ + '2.10', + '2', + 'sometime in 2023', + ]); + }); + + test('3: list --all-versions has an id and a version for each version, with the newest marked', async () => { + const { code, out } = await frodo( + 'app', + 'template', + 'list', + '--all-versions', + '--templates', + 'catalog.json' + ); + expect(code).toBe(0); + const lines = out.trim().split('\n'); + expect(lines[0].split('│').map((cell) => cell.trim())).toEqual([ + 'Id', + 'Version', + 'Newest', + ]); + const rows = lines + .slice(1) + .map((line) => line.split('│').map((cell) => cell.trim())); + expect(rows.filter(([id]) => id === 'azure.ad')).toEqual([ + ['azure.ad', '3.3', ''], + ['azure.ad', '3.4', 'yes'], + ]); + expect(rows.filter(([id]) => id === 'ldap').map((row) => row[1])).toEqual([ + '2.3', + '2.10', + ]); + expect(out).not.toMatch(/\d_\d+-/); }); - test('3: describe finds a version by its id, whatever the case, and --json prints the whole template', async () => { - const summary = await frodo( + test('4: describe takes the newest version of the template -i names, whatever the case, and --json prints the whole entry of the version', async () => { + const newest = await frodo( 'app', 'template', 'describe', '-i', - '1_2-SAML', + 'AZURE-AD', '--templates', 'catalog.json' ); - expect(summary.code).toBe(0); - expect(summary.out).toContain('Id: 1_2-saml'); - expect(summary.out).toContain('Newest version: no'); - const whole = await frodo( + expect(newest.code).toBe(0); + expect(newest.out).toContain('Id: azure.ad'); + expect(newest.out).toContain('Version: 3.4 (newest)'); + expect(newest.out).toContain('Basic configuration (2, * required)'); + const older = await frodo( 'app', 'template', 'describe', '-i', - '1_2-saml', + 'azure.ad', + '-v', + '3.3', '--json', '--templates', 'catalog.json' ); - expect(JSON.parse(whole.out)).toMatchObject({ - id: 'saml', - templateVersion: '1.2', + expect(JSON.parse(older.out)).toMatchObject({ + id: 'azure.ad', + templateVersion: '3.3', }); }); - test('4: a failure exits 1: an unknown id, a missing file, a file that is not a catalog', async () => { + test('5: -v describes another version, -n names the template by its display name, and --long adds the details', async () => { + const version = await frodo( + 'app', + 'template', + 'describe', + '-i', + 'azure.ad', + '-v', + '3.3', + '--templates', + 'catalog.json' + ); + expect(version.code).toBe(0); + expect(version.out).toContain('Version: 3.3 (the newest is 3.4)'); + const named = await frodo( + 'app', + 'template', + 'describe', + '-n', + 'microsoft entra', + '--long', + '--templates', + 'catalog.json' + ); + expect(named.code).toBe(0); + expect(named.out).toContain('Id: azure.ad'); + expect(named.out).toContain('Properties of User'); + expect(named.out).toContain('memberOf'); + expect(named.out).not.toMatch(/\d_\d+-/); + }); + + test('6: a failure exits 1: an unknown id, version or display name, the key of a version, a missing file, a file that is not a catalog', async () => { fs.writeFileSync(path.join(home, 'bad.json'), '{"not":"a catalog"}'); for (const args of [ ['describe', '-i', 'nope', '--templates', 'catalog.json'], + [ + 'describe', + '-i', + 'azure.ad', + '-v', + '9.9', + '--templates', + 'catalog.json', + ], + ['describe', '-n', 'Nope', '--templates', 'catalog.json'], + ['describe', '-i', '3_4-azure-ad', '--templates', 'catalog.json'], ['list', '--templates', 'missing.json'], ['list', '--templates', 'bad.json'], ]) { @@ -171,9 +238,35 @@ describe('frodo app template, without a tenant', () => { expect(code).toBe(1); expect(`${out}${err}`.length).toBeGreaterThan(0); } + const key = await frodo( + 'app', + 'template', + 'describe', + '-i', + '3_4-azure-ad', + '--templates', + 'catalog.json' + ); + expect(`${key.out}${key.err}`).toContain( + 'Application template "3_4-azure-ad" not found' + ); + const version = await frodo( + 'app', + 'template', + 'describe', + '-i', + 'ldap', + '-v', + '2.1', + '--templates', + 'catalog.json' + ); + expect(`${version.out}${version.err}`).toContain( + 'Application template ldap has no version 2.1. Versions: 2.3, 2.10.' + ); }); - test('5: --templates and --channel cannot be combined, and -i is required', async () => { + test('7: --templates and --channel cannot be combined, -i and -n cannot be combined, and one of -i and -n is required', async () => { const both = await frodo( 'app', 'template', @@ -185,18 +278,30 @@ describe('frodo app template, without a tenant', () => { ); expect(both.code).toBe(1); expect(both.err).toContain('cannot be used with option'); - const noId = await frodo( + const twoNames = await frodo( 'app', 'template', 'describe', + '-i', + 'saml', + '-n', + 'SAML', '--templates', 'catalog.json' ); - expect(noId.code).toBe(1); - expect(noId.err).toContain("required option '-i, --template-id"); + expect(twoNames.code).toBe(1); + expect(twoNames.err).toContain('cannot be used with option'); + for (const args of [ + ['--templates', 'catalog.json'], + ['-v', '3.3', '--templates', 'catalog.json'], + ]) { + const none = await frodo('app', 'template', 'describe', ...args); + expect(none.code).toBe(1); + expect(none.err).toContain("one of '-i, --template-id"); + } }); - test('6: download takes no connection: its help has no host, user or password', async () => { + test('8: download takes no connection: its help has no host, user or password', async () => { const { code, out } = await frodo('app', 'template', 'download', '--help'); expect(code).toBe(0); expect(out).toContain('Usage: frodo app template download [options]'); @@ -206,7 +311,7 @@ describe('frodo app template, without a tenant', () => { }); describe('frodo app template, with air-gapped mode on', () => { - test('7: download is refused with the advice to pass a file, and nothing is saved or kept', async () => { + test('9: download is refused with the advice to pass a file, and nothing is saved or kept', async () => { const { code, out, err } = await airGapped('app', 'template', 'download'); expect(code).toBe(1); const text = `${out}${err}`; @@ -220,7 +325,7 @@ describe('frodo app template, with air-gapped mode on', () => { ); }); - test('8: list without a file is refused the same way, and with a file it still works', async () => { + test('10: list without a file is refused the same way, and with a file it still works', async () => { const refused = await airGapped('app', 'template', 'list'); expect(refused.code).toBe(1); expect(`${refused.out}${refused.err}`).toContain('--templates '); @@ -232,31 +337,27 @@ describe('frodo app template, with air-gapped mode on', () => { 'catalog.json' ); expect(served.code).toBe(0); - expect(served.out.trim().split('\n')).toEqual([ - '3_3-azure-ad', - '1_2-saml', - '1_3-saml', - ]); + expect(served.out.trim().split('\n')).toEqual(fixtureIds); }); - test('9: describe from a file works air-gapped, from the CDN it does not', async () => { + test('11: describe from a file works air-gapped, from the CDN it does not', async () => { const served = await airGapped( 'app', 'template', 'describe', '-i', - '1_3-saml', + 'saml', '--templates', 'catalog.json' ); expect(served.code).toBe(0); - expect(served.out).toContain('Newest version: yes'); + expect(served.out).toContain('Version: 1.3 (newest)'); const refused = await airGapped( 'app', 'template', 'describe', '-i', - '1_3-saml' + 'saml' ); expect(refused.code).toBe(1); }); diff --git a/test/unit/ApplicationTemplateEntityPicks.test.js b/test/unit/ApplicationTemplateEntityPicks.test.js index 04e1df42e..2638ad896 100644 --- a/test/unit/ApplicationTemplateEntityPicks.test.js +++ b/test/unit/ApplicationTemplateEntityPicks.test.js @@ -5,15 +5,18 @@ const { getEntityPicks } = await import('../../src/utils/interactive/EntityPickers.ts'); /** - * The entity picker of the real `app template` commands (decision record 0038). + * The entity pickers of the real `app template` commands (decision record 0038). * The group has an entry of its own in the registry beside `app`, whose entries * name other options (`appId`, `appName`), so the tree has to be checked as - * built: `describe` gets exactly one picker for -i, and no other command of - * `app` gets a template picker or loses its application picker. + * built: `describe` gets exactly one picker for -i and one for -n (a row per + * template, not per version), and no other command of `app` gets a template + * picker or loses its application picker. */ -const templateIdPicks = (leaf) => - getEntityPicks(leaf).filter((pick) => pick.name === 'templateId'); +const templatePicks = (leaf) => + getEntityPicks(leaf).filter( + (pick) => pick.name === 'templateId' || pick.name === 'templateName' + ); let leaves = []; @@ -24,15 +27,18 @@ beforeAll(async () => { }); describe('the pickers of the app template commands', () => { - it('gives only `app template describe` a picker, and exactly one', () => { + it('gives only `app template describe` the pickers, one for -i and one for -n', () => { const withPicker = leaves - .filter((leaf) => templateIdPicks(leaf).length > 0) + .filter((leaf) => templatePicks(leaf).length > 0) .map(fullName); expect(withPicker).toEqual(['frodo app template describe']); const describe = leaves.find( (leaf) => fullName(leaf) === 'frodo app template describe' ); - expect(getEntityPicks(describe).length).toBe(1); + expect(getEntityPicks(describe).map((pick) => pick.name)).toEqual([ + 'templateId', + 'templateName', + ]); }); it('keeps the application pickers of the other app commands', () => { @@ -47,33 +53,37 @@ describe('the pickers of the app template commands', () => { expect(picks('frodo app template download')).toEqual([]); }); - it('lists the catalog the command reads, with the templates of the tenant, and shows ids', async () => { + it('lists the catalog the command reads, with the templates of the tenant, and shows ids and display names, never the key of a version', async () => { const describe = leaves.find( (leaf) => fullName(leaf) === 'frodo app template describe' ); - const [pick] = templateIdPicks(describe); - expect(pick.kind).toBe('application template'); - expect(pick.label({ templateId: '1_2-saml', name: 'saml' })).toBe( - '1_2-saml' + const [byId, byName] = templatePicks(describe); + expect(byId.kind).toBe('application template'); + expect(byId.label({ templateId: '3_4-azure-ad', name: 'azure.ad' })).toBe( + 'azure.ad' ); expect( - pick.description({ + byId.description({ displayName: 'SAML', templateType: 'SAML', isOverride: true, }) ).toBe('SAML · SAML · defined by the tenant'); expect( - pick.search( + byId.search( { - templateId: '3_3-azure-ad', - displayName: 'Azure AD', + templateId: '3_4-azure-ad', + displayName: 'Microsoft Entra', name: 'azure.ad', }, - 'azure' + 'entra' ) ).toBe(true); + expect( + byName.label({ name: 'azure.ad', displayName: 'Microsoft Entra' }) + ).toBe('Microsoft Entra'); // the loader for a command reads --templates and --channel from it - expect(typeof pick.loadFor).toBe('function'); + expect(typeof byId.loadFor).toBe('function'); + expect(typeof byName.loadFor).toBe('function'); }); }); diff --git a/test/unit/ApplicationTemplateOps.test.js b/test/unit/ApplicationTemplateOps.test.js index 3e63671ce..dda704a12 100644 --- a/test/unit/ApplicationTemplateOps.test.js +++ b/test/unit/ApplicationTemplateOps.test.js @@ -9,8 +9,13 @@ * `--channel`; an unreadable file is a failure that names the file; * - the templates of the tenant are merged only when the command was told it has * a tenant (`includeOverrides`), never otherwise; - * - `describe` finds a version by its id whatever the case, reads the whole - * template by name and version, and says so when there is none; + * - `list` shows the newest version of each template (by the mark of the + * library, never by a date), or every version, and reads the catalog once; + * - `describe` finds the template by its id (`-i`) or display name (`-n`), + * takes the newest version unless a version is named, reads the whole + * template by name and version, never accepts the key the catalog keeps a + * version under, says so when there is no such template or version, and + * never fails on an entry whose fields have the wrong types; * - `download` asks the source now (`refresh`), saves the text of the cached * catalog unchanged (never a re-encoded copy), and reports what the cache did; * - an air-gapped refusal of the library fails the command and is followed by @@ -23,6 +28,11 @@ import fs from 'fs'; import os from 'os'; import path from 'path'; import { jest } from '@jest/globals'; +import { + fixtureCatalog, + fixtureIds, + listItemsOf, +} from '../utils/ApplicationTemplateFixtures.js'; const printed = []; const messages = (type) => @@ -41,6 +51,8 @@ class FakeFrodoError extends Error { /** The state of the fake library. */ let items = []; let template; +/** The entries of the templates the tenant defines itself, by the id of the version. */ +const overrideEntries = new Map(); let catalogResult; let libraryFailure; const calls = []; @@ -111,7 +123,18 @@ jest.unstable_mockModule('@rockcarver/frodo-lib', () => ({ readTemplate: async (reference, options) => { calls.push(['readTemplate', reference, options]); if (libraryFailure) throw libraryFailure; - return { template: structuredClone(template) }; + const item = items.find( + (candidate) => + candidate.name === reference.templateName && + candidate.templateVersion === reference.templateVersion + ); + const entry = item + ? (overrideEntries.get(item.templateId) ?? + catalogResult.catalog.AppTemplates?.[item.tier]?.[item.group]?.[ + item.templateId + ]) + : undefined; + return { template: structuredClone(entry ?? template) }; }, }, }, @@ -163,6 +186,7 @@ beforeEach(() => { calls.length = 0; saved.length = 0; libraryFailure = undefined; + overrideEntries.clear(); cacheEnabled = false; cachePath = undefined; baseDirectory = fs.mkdtempSync(path.join(os.tmpdir(), 'frodo-templates-')); @@ -241,34 +265,152 @@ describe('the source of the catalog (--templates, --channel)', () => { }); describe('list', () => { - test('4: prints the ids, and merges the tenant only when told there is one', async () => { + beforeEach(() => { + items = listItemsOf(fixtureCatalog); + catalogResult.catalog = structuredClone(fixtureCatalog); + }); + + const row = (id, version) => + messages('data') + .join('\n') + .split('\n') + .find( + (line) => + line.startsWith(`${id} |`) && + (version === undefined || line.includes(` | ${version} | `)) + ); + + test('4: prints the id of each template once, and merges the tenant only when told there is one', async () => { expect(await listApplicationTemplates({})).toBe(true); - expect(messages('data')).toEqual(['1_2-saml', '1_3-saml', '3_3-azure-ad']); + expect(messages('data')).toEqual(fixtureIds); + // the plain list reads the list and nothing else + expect(calls.map(([name]) => name)).toEqual(['listTemplates']); expect(lastCall('listTemplates')[1].includeOverrides).toBe(false); await listApplicationTemplates({ includeOverrides: true }); expect(lastCall('listTemplates')[1].includeOverrides).toBe(true); }); - test('5: --long prints a table with the override and newest marks', async () => { - items[0].isOverride = true; + test('5: the newest version is the one the library marked, not the first and not the one with the latest date', async () => { + // ldap 2.10 is newer than 2.3 by number although its date is no date + await listApplicationTemplates({ long: true }); + expect(row('ldap')).toContain(' | 2.10 | 2 | '); + // the dates of the versions of a template are made to point the other way + items.find((item) => item.templateVersion === '2.3').dateUpdated = + '12-31-2030'; + printed.length = 0; + await listApplicationTemplates({ long: true }); + expect(row('ldap')).toContain(' | 2.10 | 2 | '); + // the library's mark decides: move it and the list follows it + items.find((item) => item.templateVersion === '2.3').newestInGroup = true; + items.find((item) => item.templateVersion === '2.10').newestInGroup = false; + printed.length = 0; + await listApplicationTemplates({ long: true }); + expect(row('ldap')).toContain(' | 2.3 | 2 | '); + }); + + test('6: --long is a table with the display name, category, what the template provides, the connector, the newest version, the number of versions and the date', async () => { + await listApplicationTemplates({ long: true }); + const lines = messages('data').join('\n').split('\n'); + expect(lines[0]).toBe( + 'Id | Display name | Category | Provides | Connector | Latest | Versions | Updated' + ); + expect(lines).toHaveLength(1 + fixtureIds.length); + expect(row('azure.ad')).toBe( + 'azure.ad | Microsoft Entra | Uncategorized | provisioning | MSGraphAPI | 3.4 | 2 | 2026-08-19' + ); + expect(row('saml')).toBe( + 'saml | SAML | saml | SSO: SAML | | 1.3 | 2 | 2026-02-05' + ); + expect(row('bookmark')).toContain(' | Bookmark | | 1.0 | 1 | 2025-12-09'); + expect(row('ai.target')).toContain(' | target+provisioning | AI Target | '); + // a date that is no date and a connector named by a class are printed as they are + expect(row('ldap')).toContain(' | sometime in 2023'); + expect(row('class.name')).toContain(' | SerConnector | '); + // the catalog is read once for all the rows, and no template one by one + expect(calls.map(([name]) => name)).toEqual([ + 'listTemplates', + 'readCatalog', + ]); + }); + + test('7: the templates of the tenant are marked, and are read through the library (the catalog has no entry for them)', async () => { + const saml = items.find( + (item) => item.name === 'saml' && item.templateVersion === '1.3' + ); + saml.isOverride = true; + overrideEntries.set(saml.templateId, { + id: 'saml', + templateVersion: '1.3', + displayName: 'SAML', + categories: ['mine'], + capabilities: { sso: { OIDC: true } }, + }); await listApplicationTemplates({ long: true, includeOverrides: true }); - const table = messages('data').join('\n').split('\n'); - expect(table[0]).toContain('Override'); - const row = (id) => table.find((line) => line.startsWith(id)); - expect(row('1_2-saml')).toMatch(/\| yes \|\s*\|/); // override yes, newest no - expect(row('1_3-saml')).toMatch(/\|\s*\| yes \|/); // not an override, newest yes - expect(row('3_3-azure-ad')).toContain('AzureAD'); + const lines = messages('data').join('\n').split('\n'); + expect(lines[0]).toMatch(/ \| Updated \| Override$/); + expect(row('saml')).toContain(' | mine | SSO: OIDC | '); + expect(row('saml')).toMatch(/ \| yes$/); + expect(row('azure.ad')).toMatch(/ \| $/); + expect( + calls.filter(([name]) => name === 'readTemplate').map(([, ref]) => ref) + ).toEqual([{ templateName: 'saml', templateVersion: '1.3' }]); + }); + + test('8: --all-versions prints the id and the version of every version, with the newest marked', async () => { + await listApplicationTemplates({ allVersions: true }); + const lines = messages('data').join('\n').split('\n'); + expect(lines[0]).toBe('Id | Version | Newest'); + expect(lines).toHaveLength(1 + items.length); + expect(row('azure.ad', '3.3')).toBe('azure.ad | 3.3 | '); + expect(row('azure.ad', '3.4')).toBe('azure.ad | 3.4 | yes'); + expect(row('ldap', '2.3')).toBe('ldap | 2.3 | '); + expect(row('ldap', '2.10')).toBe('ldap | 2.10 | yes'); + // no key of the catalog anywhere + expect(messages('data').join('\n')).not.toMatch(/\d_\d+-/); + }); + + test('9: --long --all-versions has a row for every version, each with its own date, connector bundle and mark', async () => { + await listApplicationTemplates({ long: true, allVersions: true }); + const lines = messages('data').join('\n').split('\n'); + expect(lines[0]).toBe( + 'Id | Display name | Category | Provides | Connector | Version | Newest | Updated' + ); + expect(lines).toHaveLength(1 + items.length); + expect(row('azure.ad', '3.3')).toBe( + 'azure.ad | Microsoft Entra | Uncategorized | provisioning | MSGraphAPI | 3.3 | | 2024-10-03' + ); + expect(row('azure.ad', '3.4')).toContain(' | 3.4 | yes | 2026-08-19'); + expect(row('saml', '1.2')).toContain(' | 1.2 | | 2024-09-09'); }); - test('6: the channel and the file reach the library', async () => { + test('10: --all-versions shows the Override column only when the tenant defines a template', async () => { + await listApplicationTemplates({ allVersions: true }); + expect(messages('data').join('\n').split('\n')[0]).not.toContain( + 'Override' + ); + printed.length = 0; + items[0].isOverride = true; + overrideEntries.set(items[0].templateId, { id: items[0].name }); + await listApplicationTemplates({ + allVersions: true, + includeOverrides: true, + }); + expect(messages('data').join('\n').split('\n')[0]).toBe( + 'Id | Version | Newest | Override' + ); + expect(row(items[0].name)).toBe(`${items[0].name} | 1.0 | yes | yes`); + }); + + test('11: the channel and the file reach the library', async () => { await listApplicationTemplates({ channel: 'development' }); expect(lastCall('listTemplates')[1].channel).toBe('development'); fs.writeFileSync(path.join(baseDirectory, 'c.json'), '{}'); - await listApplicationTemplates({ templates: 'c.json' }); + await listApplicationTemplates({ templates: 'c.json', long: true }); expect(lastCall('listTemplates')[1].catalog).toBeInstanceOf(Uint8Array); + expect(lastCall('readCatalog')[1].catalog).toBeInstanceOf(Uint8Array); }); - test('7: an unreadable file or a failure of the library is an error and a failed command', async () => { + test('12: an unreadable file or a failure of the library is an error and a failed command', async () => { expect(await listApplicationTemplates({ templates: 'nope.json' })).toBe( false ); @@ -279,36 +421,236 @@ describe('list', () => { }); describe('describe', () => { - test('8: finds the version by its id whatever the case, and reads the whole template by name and version', async () => { - expect(await describeApplicationTemplate('1_2-SAML', {})).toBe(true); - expect(lastCall('readTemplate')[1]).toEqual({ - templateName: 'saml', - templateVersion: '1.2', + beforeEach(() => { + items = listItemsOf(fixtureCatalog); + catalogResult.catalog = structuredClone(fixtureCatalog); + }); + + const lines = () => everythingPrinted().split('\n'); + const readCalls = () => + calls + .filter(([name]) => name === 'readTemplate') + .map(([, reference]) => reference); + + test('13: -i takes the newest version, whatever the case and whether the id is written with dots or hyphens, and reads the whole entry by name and version', async () => { + for (const id of ['azure.ad', 'AZURE.AD', 'azure-ad']) { + printed.length = 0; + calls.length = 0; + expect(await describeApplicationTemplate({ id }, {})).toBe(true); + expect(readCalls()[0]).toEqual({ + templateName: 'azure.ad', + templateVersion: '3.4', + }); + expect(lines()).toContain('Id: azure.ad'); + expect(lines()).toContain('Version: 3.4 (newest)'); + } + }); + + test('14: -v takes another version and says which one is the newest', async () => { + expect( + await describeApplicationTemplate({ id: 'azure.ad', version: '3.3' }, {}) + ).toBe(true); + expect(readCalls()[0]).toEqual({ + templateName: 'azure.ad', + templateVersion: '3.3', }); - const text = everythingPrinted(); - expect(text).toContain('Id: 1_2-saml'); - expect(text).toContain('Newest version: no'); - expect(text).toContain('Description: SAML applications enable'); - expect(text).toContain('Support URL: https://docs.example.com/saml'); - expect(text).not.toContain('"fields"'); + expect(lines()).toContain('Version: 3.3 (the newest is 3.4)'); + expect(lines()).toContain('Updated: 2024-10-03'); + // the connector bundle of that version, not of the newest + expect(everythingPrinted()).toContain('[1.5.20.17, 1.6.0.0)'); + expect(everythingPrinted()).not.toContain('Register an app in Entra first'); }); - test('9: --json prints the whole template and nothing else', async () => { - await describeApplicationTemplate('1_2-saml', { json: true }); - expect(JSON.parse(messages('data').join(''))).toEqual(template); + test('15: -n takes the display name, whatever the case', async () => { + expect( + await describeApplicationTemplate({ name: 'MICROSOFT entra' }, {}) + ).toBe(true); + expect(lines()).toContain('Id: azure.ad'); + expect(lines()).toContain('Display name: Microsoft Entra'); + expect(readCalls()[0].templateName).toBe('azure.ad'); }); - test('10: an id that is not there is an error and a failed command, and the template is not read', async () => { - expect(await describeApplicationTemplate('nope', {})).toBe(false); - expect(messages('error')[0]).toContain('nope not found'); - expect(lastCall('readTemplate')).toBeUndefined(); + test('16: the key the catalog keeps a version under is not an id: the command fails, says where the ids are and reads nothing', async () => { + for (const id of ['3_4-azure-ad', '1_2-SAML']) { + printed.length = 0; + expect(await describeApplicationTemplate({ id }, {})).toBe(false); + expect(messages('error')[0]).toContain(`"${id}" not found`); + expect(messages('error')[0]).toContain('"frodo app template list"'); + expect(messages('error')[0]).toContain('name the version with -v'); + } + expect(readCalls()).toEqual([]); }); - test('11: the tenant is merged only when told there is one, and the same options reach both reads', async () => { - await describeApplicationTemplate('1_2-saml', { - includeOverrides: true, - channel: 'development', + test('17: an id, a display name or a version that is not there, two names for different templates, and no name at all are errors, and nothing is read', async () => { + const errorOf = async (selector) => { + printed.length = 0; + expect(await describeApplicationTemplate(selector, {})).toBe(false); + return messages('error')[0]; + }; + expect(await errorOf({ id: 'nope' })).toContain('"nope" not found'); + expect(await errorOf({ name: 'Nope' })).toContain( + 'No application template has the display name "Nope"' + ); + expect(await errorOf({ id: 'ldap', version: '9.9' })).toBe( + 'Application template ldap has no version 9.9. Versions: 2.3, 2.10.' + ); + expect(await errorOf({ id: 'saml', name: 'Microsoft Entra' })).toContain( + 'name different application templates (saml and azure.ad)' + ); + expect(await errorOf({})).toContain('-i (id) or -n (display name)'); + expect(readCalls()).toEqual([]); + }); + + test('18: the summary has the header, the versions, the connector, the capabilities, the object types and the form', async () => { + await describeApplicationTemplate({ id: 'azure.ad' }, {}); + const out = everythingPrinted(); + expect(lines().slice(0, 9)).toEqual([ + 'Id: azure.ad', + 'Display name: Microsoft Entra', + 'Group: AzureAD (enterprise)', + 'Category: Uncategorized', + 'Provides: provisioning', + 'Version: 3.4 (newest)', + 'Updated: 2026-08-19', + 'Description: Reads and provisions Entra.', + 'Support URL: https://docs.example.com/entra', + ]); + // the versions: the connector bundle each needs, the counts, the marks + expect(out).toContain( + 'Version | Updated | Connector bundle | Object types | Properties | Note' + ); + expect(out).toContain('3.3 | 2024-10-03 | [1.5.20.17, 1.6.0.0) | 1 | 2 | '); + expect(out).toContain( + '3.4 | 2026-08-19 | [1.5.20.35, 1.6.0.0) | 2 | 4 | shown, newest' + ); + expect(out).toContain(' Name: MSGraphAPI Connector'); + expect(out).toContain( + ' Class: org.example.msgraphapi.MSGraphAPIConnector' + ); + expect(out).toContain( + ' Bundle: org.example.msgraphapi-connector [1.5.20.35, 1.6.0.0)' + ); + expect(out).toContain(' Paging: cookie'); + expect(out).toContain(' Needs a remote connector server (RCS): yes'); + expect(out).toContain(' Prerequisites: Register an app in Entra first'); + expect(out).toContain( + ' Basic configuration (2, * required): Tenant*, Client Secret' + ); + expect(out).toContain( + ' Advanced configuration (1, * required): Exclude Unmodified' + ); + expect(out).toContain(' Provisioning: create, read, update, delete'); + expect(out).toContain(' Incremental reconciliation: whenChanged (ge)'); + expect(out).toContain('User | __ACCOUNT__ | user | 3 | 1'); + expect(out).toContain('Group | __GROUP__ | assignment | 1 | 1'); + expect(out).toContain(' applicationDetails: 2 fields'); + expect(out).toContain(' provisioning: 1 default value'); + // the long fields are for --long, the empty section is not shown + expect(out).not.toContain('Client Secret | clientSecret'); + expect(out).not.toContain('Properties of'); + expect(out).not.toContain('nothing'); + // the key the catalog keeps the version under is nowhere + expect(out).not.toMatch(/\d_\d+-azure/); + }); + + test('19: --long adds the table of the connector configuration, the properties of each object type and the labels of the form fields', async () => { + await describeApplicationTemplate({ id: 'azure.ad' }, { long: true }); + const out = everythingPrinted(); + expect(out).toContain('Label | Property | Type | Required'); + expect(out).toContain('Tenant | tenant | string | yes'); + expect(out).toContain('Client Secret | clientSecret | password | '); + expect(out).toContain('Properties of User'); + expect(out).toContain('Properties of Group'); + expect(out).toContain('Name | Type | Flags'); + expect(out).toContain('userPrincipalName | string | required'); + expect(out).toContain('memberOf | string | multi-valued, read-only'); + expect(out).toContain(' applicationDetails: 2 fields: Name, Description'); + expect(out).toContain(' provisioning: 1 default value: performHardDelete'); + }); + + test('20: a template without a connector or object types leaves those sections and columns out, and an SSO template says its protocols and setup', async () => { + await describeApplicationTemplate({ id: 'saml' }, {}); + const out = everythingPrinted(); + expect(lines()).toContain('Provides: SSO: SAML'); + expect(lines()).toContain('Version: 1.3 (newest)'); + expect(out).toContain('Version | Updated | Note'); + expect(out).not.toContain('Connector bundle'); + expect(out).not.toContain('Connector\n'); + expect(out).not.toContain('Object types'); + expect(out).toContain(' SSO: SAML'); + expect(out).toContain(' SSO setup: instructions, schema, defaults'); + expect(out).toContain(' Client types: iOS, Android, React'); + printed.length = 0; + await describeApplicationTemplate({ id: 'bookmark' }, {}); + expect(lines()).toContain('Provides: Bookmark'); + // one version: no table of versions; nothing to say about capabilities + expect(everythingPrinted()).not.toContain('Versions'); + expect(everythingPrinted()).not.toContain('Capabilities'); + expect(lines()).toContain(' applicationDetails: 1 field'); + }); + + test('21: an AI target says so, and that it needs an OAuth flow', async () => { + await describeApplicationTemplate({ id: 'ai.target' }, {}); + expect(lines()).toContain('Provides: target+provisioning'); + expect(lines()).toContain(' Provisioning: read'); + expect(lines()).toContain(' Target: yes'); + expect(lines()).toContain(' Needs an OAuth flow: yes'); + }); + + test('22: --json prints the whole entry of the version and nothing else', async () => { + await describeApplicationTemplate( + { id: 'azure.ad', version: '3.3' }, + { json: true } + ); + expect(JSON.parse(messages('data').join(''))).toEqual( + fixtureCatalog.AppTemplates.enterprise.AzureAD['3_3-azure-ad'] + ); + expect(printed).toHaveLength(1); + }); + + test('23: a template of the tenant is marked, and its version is read through the library', async () => { + const saml = items.find( + (item) => item.name === 'saml' && item.templateVersion === '1.3' + ); + saml.isOverride = true; + overrideEntries.set(saml.templateId, { + id: 'saml', + templateVersion: '1.3', + displayName: 'SAML', + description: 'The tenant says so.', }); + expect( + await describeApplicationTemplate( + { id: 'saml' }, + { includeOverrides: true } + ) + ).toBe(true); + expect(lines()).toContain('Defined by the tenant: yes'); + expect(lines()).toContain('Description: The tenant says so.'); + expect(everythingPrinted()).toContain( + 'shown, newest, defined by the tenant' + ); + }); + + test('24: an entry whose fields have the wrong types prints what it can, and a control character does not reach the terminal', async () => { + expect( + await describeApplicationTemplate({ id: 'odd' }, { long: true }) + ).toBe(true); + expect(lines()).toContain('Id: odd'); + expect(lines()).toContain('Updated: 13-45-2026'); + expect(lines()).toContain('Description: 12'); + expect(everythingPrinted()).not.toContain('Connector'); + printed.length = 0; + await describeApplicationTemplate({ id: 'escape' }, {}); + expect(everythingPrinted()).not.toMatch(/[\u0000-\u0009\u000b-\u001f]/); + expect(lines()).toContain('Description: before [31mred [0m after'); + }); + + test('25: the tenant is merged only when told there is one, and the same options reach the list and the reads', async () => { + await describeApplicationTemplate( + { id: 'saml' }, + { includeOverrides: true, channel: 'development' } + ); expect(lastCall('listTemplates')[1]).toMatchObject({ includeOverrides: true, channel: 'development', @@ -317,11 +659,16 @@ describe('describe', () => { includeOverrides: true, channel: 'development', }); + expect(lastCall('readCatalog')[1]).toMatchObject({ + channel: 'development', + }); + await describeApplicationTemplate({ id: 'saml' }, {}); + expect(lastCall('listTemplates')[1].includeOverrides).toBe(false); }); }); describe('download', () => { - test('12: asks the source now, saves the text of the cached catalog unchanged, and says what the cache did', async () => { + test('26: asks the source now, saves the text of the cached catalog unchanged, and says what the cache did', async () => { const file = path.join(baseDirectory, 'out.json'); expect( await downloadApplicationTemplateCatalog('out.json', 'development') @@ -343,20 +690,20 @@ describe('download', () => { expect(text).toContain('--templates '); }); - test('13: without a file it saves appTemplates.json in the working directory and says so', async () => { + test('27: without a file it saves appTemplates.json in the working directory and says so', async () => { expect(await downloadApplicationTemplateCatalog()).toBe(true); expect(saved[0].file).toBe(path.join(baseDirectory, DEFAULT_CATALOG_FILE)); expect(messages('info')[0]).toContain(DEFAULT_CATALOG_FILE); }); - test('14: without a kept file it writes the catalog the library returned', async () => { + test('28: without a kept file it writes the catalog the library returned', async () => { delete catalogResult.metadata.cachePath; const file = path.join(baseDirectory, 'plain.json'); await downloadApplicationTemplateCatalog('plain.json'); expect(JSON.parse(fs.readFileSync(file, 'utf8'))).toEqual(sampleCatalog); }); - test('15: a stale copy after an error is a warning, and air-gapped mode is said in words', async () => { + test('29: a stale copy after an error is a warning, and air-gapped mode is said in words', async () => { catalogResult.metadata.cache = 'stale-after-error'; catalogResult.metadata.cacheError = 'getaddrinfo ENOTFOUND'; await downloadApplicationTemplateCatalog('a.json'); @@ -366,7 +713,7 @@ describe('download', () => { ); }); - test('16: an air-gapped refusal fails the command and is followed by the advice of this command line', async () => { + test('30: an air-gapped refusal fails the command and is followed by the advice of this command line', async () => { libraryFailure = new FakeFrodoError( 'Air-gapped mode is on: the application template catalog cannot be read from the CDN.', 'AIR_GAPPED' @@ -379,7 +726,7 @@ describe('download', () => { expect(saved).toEqual([]); }); - test('17: the same advice follows a refusal while listing', async () => { + test('31: the same advice follows a refusal while listing', async () => { libraryFailure = new FakeFrodoError('Air-gapped mode is on.', 'AIR_GAPPED'); expect(await listApplicationTemplates({})).toBe(false); expect(messages('info').join('\n')).toContain('--templates '); @@ -387,7 +734,7 @@ describe('download', () => { }); describe('the catalog counts', () => { - test('18: versions and groups of both tiers are counted', () => { + test('32: versions and groups of both tiers are counted', () => { expect(countCatalog(sampleCatalog)).toEqual({ templates: 3, groups: 2 }); expect( countCatalog({ AppTemplates: { consumer: {}, enterprise: {} } }) @@ -399,7 +746,7 @@ describe('the catalog counts', () => { }); describe('the cache of the catalog', () => { - test('19: the CLI switches it on at start, in the default folder of the library', () => { + test('33: the CLI switches it on at start, in the default folder of the library', () => { delete process.env.FRODO_CONFIG_PATH; expect(cacheEnabled).toBe(false); enableTemplateCatalogCache(); @@ -407,7 +754,7 @@ describe('the cache of the catalog', () => { expect(cachePath).toBeUndefined(); }); - test('20: the folder moves with FRODO_CONFIG_PATH like the other files of frodo', () => { + test('34: the folder moves with FRODO_CONFIG_PATH like the other files of frodo', () => { process.env.FRODO_CONFIG_PATH = '/srv/frodo-config'; try { enableTemplateCatalogCache(); @@ -418,7 +765,7 @@ describe('the cache of the catalog', () => { expect(cachePath).toBe(path.join('/srv/frodo-config', 'AppTemplates')); }); - test('21: src/app.ts calls the switch at start, before it builds the commands, and nothing offers a way to turn it off', () => { + test('35: src/app.ts calls the switch at start, before it builds the commands, and nothing offers a way to turn it off', () => { const app = fs.readFileSync( path.join(process.cwd(), 'src', 'app.ts'), 'utf8' diff --git a/test/unit/ApplicationTemplateView.test.js b/test/unit/ApplicationTemplateView.test.js new file mode 100644 index 000000000..4f85b27f5 --- /dev/null +++ b/test/unit/ApplicationTemplateView.test.js @@ -0,0 +1,374 @@ +/** + * Unit tests for what `frodo app template list` and `describe` derive from a + * catalog entry and how they find the template a user named + * (src/utils/ApplicationTemplateView.ts). The module is pure, so nothing is + * mocked. What these tests guard: + * - the version is never decided by a date, and a date that is not a date is + * printed as it is; + * - an id is found whatever its case and whether it is written with dots or + * hyphens, a display name whatever its case, and the key the catalog keeps a + * version under (`3_4-azure-ad`) is not an id; + * - every field of an entry may have the wrong type, and nothing throws; + * - a text from an entry cannot carry a control character to the terminal. + */ +import { describe, expect, test } from '@jest/globals'; +import { + fixtureCatalog, + listItemsOf, +} from '../utils/ApplicationTemplateFixtures.js'; + +const view = await import('../../src/utils/ApplicationTemplateView.ts'); + +const entryOf = (key) => { + for (const tier of Object.values(fixtureCatalog.AppTemplates)) { + for (const group of Object.values(tier)) { + if (group[key]) return group[key]; + } + } + throw new Error(`no entry ${key} in the fixture`); +}; + +const groups = view.groupTemplates(listItemsOf(fixtureCatalog)); + +describe('text and dates', () => { + test('1: a control character in a text becomes a space, and a text that is only such characters is no text', () => { + expect(view.textOf('before\u001b[31mred\nafter\u0007')).toBe( + 'before [31mred after' + ); + expect(view.textOf('\u001b\u0007')).toBeUndefined(); + expect(view.textOf(' padded ')).toBe('padded'); + expect(view.textOf(12)).toBe('12'); + expect(view.textOf(NaN)).toBeUndefined(); + expect(view.textOf(['a'])).toBeUndefined(); + expect(view.textOf(null)).toBeUndefined(); + }); + + test('2: the catalog writes the month first, with and without a leading zero, and the date is printed as an ISO date', () => { + expect(view.isoDate('08-19-2026')).toBe('2026-08-19'); + expect(view.isoDate('10-5-2023')).toBe('2023-10-05'); + expect(view.isoDate('2-5-2026')).toBe('2026-02-05'); + }); + + test('3: a text that is not a date, or a date that does not exist, is printed as it is, and nothing is a crash', () => { + expect(view.isoDate('13-45-2026')).toBe('13-45-2026'); + expect(view.isoDate('02-30-2026')).toBe('02-30-2026'); + expect(view.isoDate('sometime in 2023')).toBe('sometime in 2023'); + expect(view.isoDate(undefined)).toBe(''); + expect(view.isoDate(20260819)).toBe('20260819'); + expect(view.isoDate({})).toBe(''); + }); +}); + +describe('the templates of a list', () => { + test('4: versions are grouped by template in the order of the list, and the newest is the one the library marked, not the one with the latest date', () => { + expect(groups.map((group) => group.name)).toEqual([ + 'ai.target', + 'azure.ad', + 'bookmark', + 'class.name', + 'escape', + 'ldap', + 'odd', + 'saml', + ]); + const ldap = groups.find((group) => group.name === 'ldap'); + expect(ldap.versions.map((item) => item.templateVersion)).toEqual([ + '2.3', + '2.10', + ]); + // 2.10 is newer than 2.3 by number although its date is not a date + expect(ldap.newest.templateVersion).toBe('2.10'); + const saml = groups.find((group) => group.name === 'saml'); + expect(saml.newest.templateVersion).toBe('1.3'); + }); + + test('5: without a mark the last version of the list is the newest', () => { + const items = listItemsOf(fixtureCatalog).map((item) => ({ + ...item, + newestInGroup: false, + })); + const regrouped = view.groupTemplates(items); + expect( + regrouped.find((group) => group.name === 'saml').newest.templateVersion + ).toBe('1.3'); + }); + + test('6: the key of the comparison is the id with dots and hyphens alike, in lower case', () => { + expect(view.nameKey('Azure.AD')).toBe('azure-ad'); + expect(view.nameKey(' azure-ad ')).toBe('azure-ad'); + }); +}); + +describe('finding the template a user named', () => { + const select = (selector) => view.selectTemplate(groups, selector); + + test('7: -i takes the newest version, whatever the case, and dots or hyphens', () => { + for (const id of ['azure.ad', 'AZURE.AD', 'azure-ad', ' Azure-Ad ']) { + const selection = select({ id }); + expect(selection.item.name).toBe('azure.ad'); + expect(selection.item.templateVersion).toBe('3.4'); + } + }); + + test('8: -v takes another version, and 2.10 is not 2.1', () => { + expect( + select({ id: 'azure.ad', version: '3.3' }).item.templateVersion + ).toBe('3.3'); + expect(select({ id: 'ldap', version: '2.10' }).item.templateVersion).toBe( + '2.10' + ); + expect(select({ id: 'ldap', version: '2.1' }).error).toContain( + 'has no version 2.1. Versions: 2.3, 2.10.' + ); + }); + + test('9: -n takes the display name, whatever the case', () => { + const selection = select({ name: 'microsoft ENTRA' }); + expect(selection.item.name).toBe('azure.ad'); + expect(selection.item.templateVersion).toBe('3.4'); + expect(select({ name: 'AI Target (Demo)', version: '1.0' }).item.name).toBe( + 'ai.target' + ); + }); + + test('10: the key the catalog keeps a version under is not an id, and the error says where the ids are and that a version is named with -v', () => { + for (const key of ['3_4-azure-ad', '1_2-saml', '2_10-ldap']) { + const { error } = select({ id: key }); + expect(error).toContain(`"${key}" not found`); + expect(error).toContain('"frodo app template list" prints the ids'); + expect(error).toContain('name the version with -v'); + } + // an id that is simply wrong gets no remark about versions + expect(select({ id: 'nope' }).error).not.toContain('-v'); + // a name is not an id either + expect(select({ id: 'Microsoft Entra' }).error).toContain('not found'); + }); + + test('11: an unknown display name says where the names are', () => { + expect(select({ name: 'Nope' }).error).toBe( + 'No application template has the display name "Nope". "frodo app template list --long" prints the display names.' + ); + }); + + test('12: -i and -n that name different templates are an error, and the same template is not', () => { + expect(select({ id: 'saml', name: 'Microsoft Entra' }).error).toContain( + 'name different application templates (saml and azure.ad)' + ); + expect(select({ id: 'azure.ad', name: 'Microsoft Entra' }).item.name).toBe( + 'azure.ad' + ); + }); + + test('13: nothing named is an error, and a display name that two templates share asks for the id', () => { + expect(select({}).error).toContain('-i (id) or -n (display name)'); + expect(select({ id: ' ' }).error).toContain('-i (id) or -n'); + const twins = view.groupTemplates([ + ...listItemsOf(fixtureCatalog), + { + ...listItemsOf(fixtureCatalog)[0], + name: 'twin.one', + displayName: 'LDAP', + newestInGroup: true, + isOverride: true, + }, + ]); + const { error } = view.selectTemplate(twins, { name: 'ldap' }); + expect(error).toContain('More than one application template'); + expect(error).toContain('ldap, twin.one'); + }); +}); + +describe('what an entry provides', () => { + test('14: a connector is provisioning, a target with a connector is both, SSO lists its protocols, and a bare entry says its type', () => { + expect(view.providesOf(entryOf('3_4-azure-ad'))).toBe('provisioning'); + expect(view.providesOf(entryOf('1_0-ai-target'))).toBe( + 'target+provisioning' + ); + expect(view.providesOf(entryOf('1_3-saml'))).toBe('SSO: SAML'); + expect(view.providesOf(entryOf('1_0-bookmark'))).toBe('Bookmark'); + // SSO that is false is not offered; both together are joined + expect( + view.providesOf({ + connectorName: 'x.Y', + capabilities: { sso: { SAML: true, OIDC: true, 'WS-Fed': false } }, + }) + ).toBe('provisioning, SSO: SAML/OIDC'); + expect(view.providesOf({ target: true })).toBe('target'); + expect(view.providesOf({})).toBe(''); + }); + + test('15: the connector is named without the word Connector, or by the last part of a class name', () => { + expect(view.connectorOf(entryOf('3_4-azure-ad'))).toBe('MSGraphAPI'); + expect(view.connectorOf(entryOf('1_0-class-name'))).toBe('SerConnector'); + expect( + view.connectorOf({ connectorName: 'org.example.epic.EpicConnector' }) + ).toBe('EpicConnector'); + expect(view.connectorOf({ connectorDisplayName: 'Connector' })).toBe( + 'Connector' + ); + expect(view.connectorOf(entryOf('1_3-saml'))).toBe(''); + }); + + test('16: the category is the first the catalog gives, as it gives it', () => { + expect(view.categoryOf(entryOf('3_4-azure-ad'))).toBe('Uncategorized'); + expect(view.categoryOf(entryOf('1_3-saml'))).toBe('saml'); + expect(view.categoryOf(entryOf('1_0-odd'))).toBe('oops'); + expect(view.categoryOf({ categories: [null, 3, 'second'] })).toBe('3'); + expect(view.categoryOf({ categories: [] })).toBe(''); + expect(view.categoryOf({})).toBe(''); + }); +}); + +describe('the parts of an entry', () => { + test('17: the connector has its bundle, paging and configuration fields, with the required ones marked', () => { + const connector = view.connectorSummaryOf(entryOf('3_4-azure-ad')); + expect(connector).toMatchObject({ + name: 'MSGraphAPI Connector', + className: 'org.example.msgraphapi.MSGraphAPIConnector', + bundleName: 'org.example.msgraphapi-connector', + bundleRange: '[1.5.20.35, 1.6.0.0)', + templateVersion: '1.5.20.35', + pagingType: 'cookie', + rcsOnly: true, + prerequisites: 'Register an app in Entra first', + }); + expect( + connector.basic.map((field) => [field.label, field.required]) + ).toEqual([ + ['Tenant', true], + ['Client Secret', false], + ]); + expect(connector.basic[1]).toMatchObject({ + property: 'clientSecret', + type: 'password', + }); + expect(connector.advanced).toHaveLength(1); + expect(view.connectorSummaryOf(entryOf('1_3-saml'))).toBeUndefined(); + }); + + test('18: object types count their properties and the required ones, and a property that can be neither created nor updated is read-only', () => { + const [user, group] = view.objectTypesOf(entryOf('3_4-azure-ad')); + expect(user).toMatchObject({ + name: 'User', + nativeType: '__ACCOUNT__', + managedResource: 'user', + required: 1, + }); + expect(user.properties).toHaveLength(3); + expect(user.properties[0].flags).toEqual(['required']); + expect(user.properties[2].flags).toEqual(['multi-valued', 'read-only']); + expect(group.required).toBe(1); + expect(view.countsOf(entryOf('3_4-azure-ad'))).toEqual({ + objectTypes: 2, + properties: 4, + }); + expect(view.countsOf(entryOf('1_3-saml'))).toEqual({ + objectTypes: 0, + properties: 0, + }); + }); + + test('19: a property that is only not creatable stays as it is said', () => { + const [objectType] = view.objectTypesOf({ + objectTypes: [ + { name: 'T', properties: [{ nativeName: 'p', notCreatable: true }] }, + ], + }); + expect(objectType.properties[0].flags).toEqual(['not creatable']); + }); + + test('20: the form has its sections with their fields, the defaults of provisioning, and no empty section', () => { + expect(view.formSectionsOf(entryOf('3_4-azure-ad'))).toEqual([ + { + section: 'applicationDetails', + kind: 'fields', + names: ['Name', 'Description'], + }, + { + section: 'provisioning', + kind: 'defaults', + names: ['performHardDelete'], + }, + ]); + // a field with no label is named by the property it sets + expect( + view.formSectionsOf({ + schema: { a: [[{ model: 'x.y' }, { type: 'string' }]] }, + })[0].names + ).toEqual(['x.y']); + }); + + test('21: capabilities say what is allowed, in the order create, read, update, delete', () => { + expect(view.capabilitiesOf(entryOf('3_4-azure-ad')).provisioning).toEqual([ + 'create', + 'read', + 'update', + 'delete', + ]); + expect(view.capabilitiesOf(entryOf('3_4-azure-ad'))).toMatchObject({ + incrementalRecon: 'whenChanged (ge)', + target: false, + sso: [], + }); + expect(view.capabilitiesOf(entryOf('1_3-saml'))).toMatchObject({ + sso: ['SAML'], + ssoSetup: ['instructions', 'schema', 'defaults'], + clients: ['iOS', 'Android', 'React'], + }); + expect(view.capabilitiesOf(entryOf('1_0-ai-target'))).toMatchObject({ + target: true, + requiresOauthFlow: true, + provisioning: ['read'], + }); + expect( + view.provisioningOperationsOf({ + capabilities: { provisioning: { Delete: true, Archive: true } }, + }) + ).toEqual(['delete', 'archive']); + expect( + view.provisioningOperationsOf({ + capabilities: { provisioning: { Create: false } }, + }) + ).toEqual([]); + expect( + view.provisioningOperationsOf(entryOf('1_0-bookmark')) + ).toBeUndefined(); + }); + + test('22: an entry whose fields have the wrong types gives nothing and throws nothing', () => { + const odd = entryOf('1_0-odd'); + expect(view.providesOf(odd)).toBe('Enterprise'); + expect(view.connectorSummaryOf(odd)).toBeUndefined(); + expect(view.objectTypesOf(odd)).toEqual([]); + expect(view.formSectionsOf(odd)).toEqual([]); + expect(view.capabilitiesOf(odd)).toEqual({ + provisioning: undefined, + sso: [], + ssoSetup: [], + incrementalRecon: '', + target: false, + requiresOauthFlow: false, + clients: [], + }); + for (const nothing of [undefined, null, 5, 'text', [], {}]) { + const entry = view.recordOf(nothing) ?? {}; + expect(() => { + view.providesOf(entry); + view.connectorSummaryOf(entry); + view.objectTypesOf(entry); + view.formSectionsOf(entry); + view.capabilitiesOf(entry); + }).not.toThrow(); + } + // properties and object types of the wrong type are skipped, not fatal + expect( + view.objectTypesOf({ + objectTypes: [ + 'x', + null, + { name: 'T', properties: ['p', { nativeName: 'q' }] }, + ], + })[0].properties + ).toHaveLength(1); + }); +}); diff --git a/test/utils/ApplicationTemplateFixtures.js b/test/utils/ApplicationTemplateFixtures.js new file mode 100644 index 000000000..6463b0380 --- /dev/null +++ b/test/utils/ApplicationTemplateFixtures.js @@ -0,0 +1,369 @@ +/** + * A small application template catalog for the tests of `frodo app template`. + * It has the shapes of the live catalog that the commands must handle, and the + * odd ones that the library does not check: + * - a template with several versions and a connector (`azure.ad`, 3.3 and 3.4, + * the older one needing an older connector bundle than the newer), + * - an SSO template with versions (`saml`) and a template with neither a + * connector nor `capabilities` (`bookmark`), + * - a template whose versions are not in the order of their dates (`ldap`), + * with a date that is not a date, + * - an AI target (`ai.target`), whose connector can only read, + * - a template whose fields have the wrong types (`odd`), + * - a class name where a connector display name should be (`class.name`), + * - control characters in a text (`escape`). + * The entries are made up; they are not copied from the live catalog. + */ + +const field = (label, model, required = false, type = 'string') => ({ + label, + model, + type, + ...(required ? { validation: { required: true } } : {}), +}); + +const property = (nativeName, more = {}) => ({ + nativeName, + name: nativeName, + type: 'string', + ...more, +}); + +export const fixtureCatalog = { + AppTemplates: { + consumer: { + Saml: { + '1_2-saml': { + id: 'saml', + templateVersion: '1.2', + displayName: 'SAML', + templateType: 'SAML', + dateUpdated: '09-09-2024', + categories: ['saml'], + description: 'SAML applications enable single sign-on.', + supportUrl: 'https://docs.example.com/saml', + capabilities: { sso: { SAML: true, OIDC: false } }, + sso: { instructions: [], schema: [], defaults: {} }, + clients: ['iOS', 'Android'], + schema: { + applicationDetails: [[field('Name', 'name', true)]], + }, + }, + '1_3-saml': { + id: 'saml', + templateVersion: '1.3', + displayName: 'SAML', + templateType: 'SAML', + dateUpdated: '2-5-2026', + categories: ['saml'], + description: 'SAML applications enable single sign-on (newer).', + supportUrl: 'https://docs.example.com/saml', + capabilities: { sso: { SAML: true, OIDC: false } }, + sso: { instructions: [], schema: [], defaults: {} }, + clients: ['iOS', 'Android', 'React'], + schema: { + applicationDetails: [ + [ + field('Name', 'name', true), + field('Description', 'description'), + ], + ], + }, + }, + }, + Bookmark: { + '1_0-bookmark': { + id: 'bookmark', + templateVersion: '1.0', + displayName: 'Bookmark', + templateType: 'Bookmark', + dateUpdated: '12-09-2025', + categories: ['bookmark'], + description: 'A bookmark is a link.', + supportUrl: 'https://docs.example.com/bookmark', + schema: { applicationDetails: [[field('Name', 'name', true)]] }, + }, + }, + }, + enterprise: { + AzureAD: { + '3_3-azure-ad': { + id: 'azure.ad', + templateVersion: '3.3', + displayName: 'Microsoft Entra', + templateType: 'Enterprise', + dateUpdated: '10-03-2024', + categories: ['Uncategorized'], + description: 'Reads and provisions Entra.', + supportUrl: 'https://docs.example.com/entra', + connectorDisplayName: 'MSGraphAPI Connector', + connectorName: 'org.example.msgraphapi.MSGraphAPIConnector', + bundleName: 'org.example.msgraphapi-connector', + bundleVersion: '[1.5.20.17, 1.6.0.0)', + capabilities: { + provisioning: { + Create: true, + Read: true, + Update: true, + Delete: true, + }, + }, + connectorTemplate: { + version: '1.5.20.17', + pagingType: 'cookie', + basic: [[field('Tenant', 'tenant', true)]], + advanced: [[field('Exclude Unmodified', 'excludeUnmodified')]], + }, + objectTypes: [ + { + id: '__ACCOUNT__', + nativeType: '__ACCOUNT__', + name: 'User', + managedResource: 'user', + properties: [ + property('userPrincipalName', { required: true }), + property('givenName'), + ], + }, + ], + schema: { + applicationDetails: [[field('Name', 'name', true)]], + provisioning: { defaultValues: { performHardDelete: true } }, + }, + }, + '3_4-azure-ad': { + id: 'azure.ad', + templateVersion: '3.4', + displayName: 'Microsoft Entra', + templateType: 'Enterprise', + dateUpdated: '08-19-2026', + categories: ['Uncategorized'], + description: 'Reads and provisions Entra.', + supportUrl: 'https://docs.example.com/entra', + connectorDisplayName: 'MSGraphAPI Connector', + connectorName: 'org.example.msgraphapi.MSGraphAPIConnector', + bundleName: 'org.example.msgraphapi-connector', + bundleVersion: '[1.5.20.35, 1.6.0.0)', + capabilities: { + provisioning: { + Create: true, + Read: true, + Update: true, + Delete: true, + }, + }, + incrementalRecon: { + propertyName: 'whenChanged', + comparisonOperator: 'ge', + }, + connectorTemplate: { + version: '1.5.20.35', + pagingType: 'cookie', + rcsOnly: true, + prerequisitesTitle: 'Register an app in Entra first', + basic: [ + [field('Tenant', 'tenant', true)], + [field('Client Secret', 'clientSecret', false, 'password')], + ], + advanced: [[field('Exclude Unmodified', 'excludeUnmodified')]], + }, + objectTypes: [ + { + id: '__ACCOUNT__', + nativeType: '__ACCOUNT__', + name: 'User', + managedResource: 'user', + properties: [ + property('userPrincipalName', { required: true }), + property('givenName'), + property('memberOf', { + multiValued: true, + notCreatable: true, + notUpdateable: true, + }), + ], + }, + { + id: '__GROUP__', + nativeType: '__GROUP__', + name: 'Group', + managedResource: 'assignment', + properties: [property('displayName', { required: true })], + }, + ], + schema: { + applicationDetails: [ + [ + field('Name', 'name', true), + field('Description', 'description'), + ], + ], + provisioning: { defaultValues: { performHardDelete: true } }, + // the shape of a section that holds nothing + nothing: {}, + }, + }, + }, + Ldap: { + '2_3-ldap': { + id: 'ldap', + templateVersion: '2.3', + displayName: 'LDAP', + templateType: 'Enterprise', + dateUpdated: '06-03-2024', + categories: ['IT infrastructure'], + connectorDisplayName: 'LDAP Connector', + connectorName: 'org.example.ldap.LdapConnector', + bundleVersion: '[1.5.20.11, 1.6.0.0)', + capabilities: { provisioning: { Create: true, Read: true } }, + connectorTemplate: { version: '1.5.20.11', basic: [], advanced: [] }, + }, + // the newest version has the older date, and a date that is no date + '2_10-ldap': { + id: 'ldap', + templateVersion: '2.10', + displayName: 'LDAP', + templateType: 'Enterprise', + dateUpdated: 'sometime in 2023', + categories: ['IT infrastructure'], + connectorDisplayName: 'LDAP Connector', + connectorName: 'org.example.ldap.LdapConnector', + bundleVersion: '[1.5.20.11, 1.6.0.0)', + capabilities: { provisioning: { Create: true, Read: true } }, + connectorTemplate: { version: '1.5.20.11', basic: [], advanced: [] }, + }, + }, + AiTarget: { + '1_0-ai-target': { + id: 'ai.target', + templateVersion: '1.0', + displayName: 'AI Target (Demo)', + templateType: 'Enterprise', + dateUpdated: '05-15-2026', + categories: ['AI Services'], + target: true, + requiresOauthFlow: true, + connectorDisplayName: 'AI Target Connector', + connectorName: 'org.example.ai.AiTargetConnector', + bundleVersion: '[1.5.20.33, 1.6.0.0)', + capabilities: { provisioning: { Read: true } }, + connectorTemplate: { version: '1.5.20.33', basic: [], advanced: [] }, + }, + }, + ClassName: { + '1_0-class-name': { + id: 'class.name', + templateVersion: '1.0', + displayName: 'Class Name', + templateType: 'Enterprise', + dateUpdated: '01-02-2025', + categories: ['ERP'], + connectorDisplayName: 'org.example.epic.ser.SerConnector', + connectorName: 'org.example.epic.ser.SerConnector', + connectorTemplate: { version: '1', basic: [], advanced: [] }, + }, + }, + Odd: { + '1_0-odd': { + id: 'odd', + templateVersion: '1.0', + displayName: 'Odd Fields', + templateType: 'Enterprise', + dateUpdated: '13-45-2026', + categories: 'oops', + description: 12, + supportUrl: ['not', 'text'], + capabilities: [], + connectorTemplate: 'oops', + objectTypes: {}, + schema: 5, + clients: 'iOS', + incrementalRecon: 'whenChanged', + }, + }, + Escape: { + '1_0-escape': { + id: 'escape', + templateVersion: '1.0', + displayName: 'Escape', + templateType: 'Enterprise', + dateUpdated: '01-01-2025', + categories: ['ERP'], + description: 'before\u001b[31mred\u001b[0m\nafter\u0007', + }, + }, + }, + }, +}; + +/** The ids the commands print for the fixture catalog, in the order of the list: one per template. */ +export const fixtureIds = [ + 'ai.target', + 'azure.ad', + 'bookmark', + 'class.name', + 'escape', + 'ldap', + 'odd', + 'saml', +]; + +/** + * The versions of a catalog as the library lists them (the same fields, in the + * order of the name of the template, then of its version): for the tests that + * replace the library. + * @param {object} catalog a catalog like `fixtureCatalog` + * @returns {object[]} the versions + */ +export function listItemsOf(catalog) { + const nameKey = (name) => name.replace(/\./g, '-'); + const compare = (a, b) => { + const left = a.split('.').map(Number); + const right = b.split('.').map(Number); + for (let i = 0; i < Math.max(left.length, right.length); i++) { + const difference = (left[i] ?? 0) - (right[i] ?? 0); + if (difference) return difference; + } + return 0; + }; + const items = []; + for (const tier of ['consumer', 'enterprise']) { + for (const [group, templates] of Object.entries( + catalog.AppTemplates[tier] + )) { + for (const [templateId, template] of Object.entries(templates)) { + items.push({ + templateId, + name: template.id, + group, + tier, + templateVersion: template.templateVersion, + displayName: template.displayName, + templateType: template.templateType, + dateUpdated: template.dateUpdated, + isOverride: false, + newestInGroup: false, + }); + } + } + } + const newest = new Map(); + for (const item of items) { + const key = nameKey(item.name); + if ( + !newest.has(key) || + compare(item.templateVersion, newest.get(key)) > 0 + ) { + newest.set(key, item.templateVersion); + } + } + for (const item of items) { + item.newestInGroup = + item.templateVersion === newest.get(nameKey(item.name)); + } + return items.sort( + (a, b) => + nameKey(a.name).localeCompare(nameKey(b.name)) || + compare(a.templateVersion, b.templateVersion) + ); +}