diff --git a/src/lib/providers/vercel/__tests__/translator.test.ts b/src/lib/providers/vercel/__tests__/translator.test.ts index a69e214..91ede49 100644 --- a/src/lib/providers/vercel/__tests__/translator.test.ts +++ b/src/lib/providers/vercel/__tests__/translator.test.ts @@ -274,6 +274,89 @@ describe('vercel/translator', () => { } }) + // Regression tests for #261: `ne`/`not_contains`/`not_in` previously fell + // through mapUnifiedOperatorToVercel's `mapping[op] || 'eq'` fallback + // with no warning — silently inverting a rule's intent (e.g. "method is + // not POST" became "method is POST"). Vercel has no dedicated negative + // operators for these, only a positive operator + `neg` flag (same + // mechanism `not_exists` already uses via `nex`), so the fix maps each + // to its positive Vercel operator with `neg: true` forced on. + it('maps ne/not_contains/not_in to their positive Vercel operator with neg forced true', () => { + const ops = [ + { unified: 'ne', vercel: 'eq' }, + { unified: 'not_contains', vercel: 'sub' }, + { unified: 'not_in', vercel: 'inc' }, + ] as const + + for (const { unified, vercel } of ops) { + const rule = makeUnifiedRule({ + conditions: [{ field: 'path', operator: unified, value: 'test' }], + }) + const { result } = unifiedToVercel(rule) + const condition = result.conditionGroup[0]!.conditions[0]! + expect(condition.op).toBe(vercel) + expect(condition.neg).toBe(true) + } + }) + + it('composes a negated ne condition (double negation) back to a plain, non-negated eq', () => { + // `ne` already means "not equal"; `negated: true` on top of that means + // "NOT (not equal)" = "equal" — the two negations must cancel (XOR), + // not stack into a nonsensical `neg: true` re-affirmation. + const rule = makeUnifiedRule({ + conditions: [{ field: 'path', operator: 'ne', value: 'test', negated: true }], + }) + const { result } = unifiedToVercel(rule) + const condition = result.conditionGroup[0]!.conditions[0]! + expect(condition.op).toBe('eq') + expect(condition.neg).toBe(false) + }) + + it('still honors negated on an already-working operator exactly as before (eq + negated -> neg: true)', () => { + const rule = makeUnifiedRule({ + conditions: [{ field: 'path', operator: 'eq', value: 'test', negated: true }], + }) + const { result } = unifiedToVercel(rule) + const condition = result.conditionGroup[0]!.conditions[0]! + expect(condition.op).toBe('eq') + expect(condition.neg).toBe(true) + }) + + // Regression tests for #261: gt/ge/lt/le have no Vercel equivalent at + // all (Vercel's operator vocabulary has no numeric-comparison concept), + // so — unlike ne/not_contains/not_in above — there's no positive-operator + // fallback to map to. The condition must be dropped with a critical + // warning, not silently mis-mapped to eq. + it('drops a numeric-comparison condition (gt/ge/lt/le) and warns instead of defaulting to eq', () => { + const numericOps = ['gt', 'ge', 'lt', 'le'] as const + + for (const op of numericOps) { + // `asn` (-> Vercel's geo_as_number) has a real Vercel field mapping, + // unlike Cloudflare-only fields like `port` — this must fail on the + // *operator*, not get dropped earlier for having no Vercel field at + // all (which would happen for `port` and not exercise this path). + const rule = makeUnifiedRule({ + conditions: [ + { field: 'asn', operator: op, value: 13335 }, + { field: 'path', operator: 'eq', value: '/api' }, + ], + }) + const { result, warnings } = unifiedToVercel(rule) + + expect(result.conditionGroup[0]!.conditions).toHaveLength(1) + expect(result.conditionGroup[0]!.conditions[0]!.type).toBe('path') + expect(warnings.some((w) => w.severity === 'critical' && w.message.includes(op))).toBe(true) + } + }) + + it('throws when the only condition on the rule uses an unsupported operator', () => { + const rule = makeUnifiedRule({ + conditions: [{ field: 'path', operator: 'gt', value: 1024 }], + }) + + expect(() => unifiedToVercel(rule)).toThrow(/no conditions Vercel can represent/) + }) + it('translates unified field types back to Vercel types', () => { const mappings = [ { unified: 'host', vercel: 'host' }, diff --git a/src/lib/providers/vercel/translator.ts b/src/lib/providers/vercel/translator.ts index 347b1a7..5a6c58e 100644 --- a/src/lib/providers/vercel/translator.ts +++ b/src/lib/providers/vercel/translator.ts @@ -258,9 +258,28 @@ function buildVercelConditionGroups( ) continue } + const mappedOperator = mapUnifiedOperatorToVercel(condition.operator) + if (!mappedOperator) { + warnings.push( + TranslationWarningSystem.createUnsupportedFeatureWarning( + `operator '${condition.operator}'`, + 'unified config', + 'Vercel', + ruleId, + condition.field, + ), + ) + continue + } mapped.push({ - op: mapUnifiedOperatorToVercel(condition.operator), - neg: condition.negated, + op: mappedOperator.op, + // XOR the operator's own required negation (e.g. `ne` -> `eq` + + // forced neg) with the condition's independent `negated` flag — + // `forceNeg: false` for every operator that doesn't need one makes + // this reduce to plain `condition.negated`, so this is a strict + // superset of the previous `neg: condition.negated` behavior, not a + // change for any operator already working correctly. + neg: mappedOperator.forceNeg !== Boolean(condition.negated), type, key: condition.key, value: condition.value, @@ -289,19 +308,43 @@ function mapVercelOperatorToUnified(op: string): Operator { return mapping[op] || 'eq' } -function mapUnifiedOperatorToVercel(op: string): VercelRuleOperator { - const mapping: Record = { - eq: 'eq', - starts_with: 'pre', - ends_with: 'suf', - in: 'inc', - contains: 'sub', - matches: 're', - exists: 'ex', - not_exists: 'nex', +/** + * Maps a unified operator to its Vercel equivalent, or `null` if Vercel has + * no representation for it at all. Previously this defaulted any unmapped + * operator to plain `eq` with no warning — silently changing what a rule + * matches, sometimes inverting it outright (`ne` "is not X" became `eq` "is + * X"). See #261. + * + * Returns `{ op, forceNeg }` rather than just `op`: Vercel has no dedicated + * "not equal"/"does not contain"/"is not any of" operators, only positive + * ones plus a `neg` flag (`VercelRuleOperator` in types/vercel.ts documents + * the negative form each positive operator has via `neg`, except `ex`/`nex`, + * which are already their own negated pair). `ne`/`not_contains`/`not_in` + * are fully representable this way — `forceNeg: true` — but the caller must + * compose that with the condition's own independent `negated` flag (XOR), + * not just overwrite it with one or the other. + * + * `gt`/`ge`/`lt`/`le` have no entry and return `null`: Vercel's operator + * vocabulary (`eq, pre, suf, inc, sub, re, ex, nex`) has no numeric + * comparison concept at all, so there's nothing to fall back to — the caller + * must drop the condition and warn rather than silently mis-map it to `eq`. + */ +function mapUnifiedOperatorToVercel(op: Operator): { op: VercelRuleOperator; forceNeg: boolean } | null { + const mapping: Partial> = { + eq: { op: 'eq', forceNeg: false }, + ne: { op: 'eq', forceNeg: true }, + starts_with: { op: 'pre', forceNeg: false }, + ends_with: { op: 'suf', forceNeg: false }, + in: { op: 'inc', forceNeg: false }, + not_in: { op: 'inc', forceNeg: true }, + contains: { op: 'sub', forceNeg: false }, + not_contains: { op: 'sub', forceNeg: true }, + matches: { op: 're', forceNeg: false }, + exists: { op: 'ex', forceNeg: false }, + not_exists: { op: 'nex', forceNeg: false }, } - return mapping[op] || 'eq' + return mapping[op] ?? null } /**