Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 17 additions & 4 deletions src/validator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,19 @@ function fieldAllowsCustomValue(field: FormanSchemaField): boolean {
return field.mappable === true || field.editable === true;
}

/**
* Whether a value missing from an RPC-resolved option list should warn instead of fail.
*
* Reference types are included because the caller cannot be relied on to hold every placeholder a
* store URL interpolates: `GetScenarios?teamId={{teamId}}&scenarioId={{scenarioId}}` resolves
* against whatever context the caller has, so a validator lacking `scenarioId` asks a narrower
* question than the picker that offered the value and legitimately fails to see it (MAIA-1311).
* Rejecting there is unrecoverable for the user; a warning still surfaces as a setup error.
*/
function unresolvedOptionIsTolerable(field: FormanSchemaField, root: DomainRoot): boolean {
return root.allowDynamicValues || fieldAllowsCustomValue(field) || isReferenceType(field.type);
}

/**
* Context for schema validation operations
*/
Expand Down Expand Up @@ -995,7 +1008,7 @@ async function handlePathType(value: unknown, field: FormanSchemaField, context:
if (!selectedOption) {
if (
optionsFromRPC &&
(context.roots[context.domain]!.allowDynamicValues || fieldAllowsCustomValue(field))
unresolvedOptionIsTolerable(field, context.roots[context.domain]!)
) {
warnings.push({
domain: context.domain,
Expand Down Expand Up @@ -1124,7 +1137,7 @@ async function handleSelectType(
optionsOrGroups as FormanSchemaSelectOptionsStore,
);
if (!found) {
(optionsFromRPC && (context.roots[context.domain]!.allowDynamicValues || fieldAllowsCustomValue(field))
(optionsFromRPC && unresolvedOptionIsTolerable(field, context.roots[context.domain]!)
? warnings
: errors
).push({
Expand All @@ -1138,7 +1151,7 @@ async function handleSelectType(

if (
optionsFromRPC &&
(context.roots[context.domain]!.allowDynamicValues || fieldAllowsCustomValue(field)) &&
unresolvedOptionIsTolerable(field, context.roots[context.domain]!) &&
hasUnresolvedValue
) {
context.roots[context.domain]!.fieldStates.push({
Expand Down Expand Up @@ -1189,7 +1202,7 @@ async function handleSelectType(
if (!item) {
if (
optionsFromRPC &&
(context.roots[context.domain]!.allowDynamicValues || fieldAllowsCustomValue(field))
unresolvedOptionIsTolerable(field, context.roots[context.domain]!)
) {
warnings.push({
domain: context.domain,
Expand Down
22 changes: 19 additions & 3 deletions test/scenario.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,9 +62,11 @@ describe('scenario type', () => {
const valid = await validateForman({ scenario: 'SCN_34174' }, prodSchema, { resolveRemote });
expect(valid.valid).toBe(true);

const invalid = await validateForman({ scenario: 'SCN_00000' }, prodSchema, { resolveRemote });
expect(invalid.valid).toBe(false);
expect(invalid.errors[0]!.message).toBe("Value 'SCN_00000' not found in options.");
// The validator resolves this store without the `scenarioId` the picker supplies, so a real
// scenario can be absent from the list it sees. Warn rather than block (MAIA-1311).
const unseen = await validateForman({ scenario: 'SCN_00000' }, prodSchema, { resolveRemote });
expect(unseen.valid).toBe(true);
expect(unseen.warnings[0]!.message).toBe("Value 'SCN_00000' not found in options.");

// Conversion carries rpc:// URL and custom keys
const jsonSchema = toJSONSchema({ type: 'collection', spec: prodSchema });
Expand All @@ -78,4 +80,18 @@ describe('scenario type', () => {
label: 'label',
});
});
it('should still hard-error for a non-reference select resolved from the same RPC', async () => {
const schema: FormanSchemaField[] = [
{
name: 'mode',
type: 'select',
options: { store: 'rpc://scenario-service/2.15.6/GetModes?teamId={{teamId}}' },
},
];
const resolveRemote = async () => [{ value: 'fast', label: 'Fast' }];

const invalid = await validateForman({ mode: 'nope' }, schema, { resolveRemote });
expect(invalid.valid).toBe(false);
expect(invalid.errors[0]!.message).toBe("Value 'nope' not found in options.");
});
});