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
83 changes: 83 additions & 0 deletions src/lib/providers/vercel/__tests__/translator.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' },
Expand Down
69 changes: 56 additions & 13 deletions src/lib/providers/vercel/translator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -289,19 +308,43 @@ function mapVercelOperatorToUnified(op: string): Operator {
return mapping[op] || 'eq'
}

function mapUnifiedOperatorToVercel(op: string): VercelRuleOperator {
const mapping: Record<string, VercelRuleOperator> = {
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<Record<Operator, { op: VercelRuleOperator; forceNeg: boolean }>> = {
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
}

/**
Expand Down
Loading