test(docs-infra): update `firebase-test-utils` to support regex-based redirects (#41842)

This commit updates the utilities in `firebase-test-utils/` to also
support testing Firebase redirects that are configured using regular
expressions (via the `regex` property).
See the [Firebase docs][1] for more details.

[1]: https://firebase.google.com/docs/hosting/full-config#redirects

PR Close #41842
This commit is contained in:
George Kalpakas 2021-04-27 22:50:12 +03:00 committed by Jessica Janiuk
parent ae1ce5f9c7
commit d1f5a9e44b
6 changed files with 345 additions and 179 deletions

View File

@ -3,18 +3,28 @@ import { FirebaseRedirect } from './FirebaseRedirect';
describe('FirebaseRedirect', () => {
describe('replace', () => {
it('should return undefined if the redirect does not match the url', () => {
const redirect = new FirebaseRedirect({source: '/a/b/c', destination: '/x/y/z'});
expect(redirect.replace('/1/2/3')).toBe(undefined);
const globRedirect = new FirebaseRedirect({source: '/a/b/c', destination: '/x/y/z'});
expect(globRedirect.replace('/1/2/3')).toBe(undefined);
const regexRedirect = new FirebaseRedirect({regex: '^/a/b/c$', destination: '/x/y/z'});
expect(regexRedirect.replace('/1/2/3')).toBe(undefined);
});
it('should return the destination if there is a match', () => {
const redirect = new FirebaseRedirect({source: '/a/b/c', destination: '/x/y/z'});
expect(redirect.replace('/a/b/c')).toBe('/x/y/z');
const globRedirect = new FirebaseRedirect({source: '/a/b/c', destination: '/x/y/z'});
expect(globRedirect.replace('/a/b/c')).toBe('/x/y/z');
const regexRedirect = new FirebaseRedirect({regex: '^/a/b/c$', destination: '/x/y/z'});
expect(regexRedirect.replace('/a/b/c')).toBe('/x/y/z');
});
it('should inject name params into the destination', () => {
const redirect = new FirebaseRedirect({source: '/api/:package/:api-*', destination: '<:package><:api>'});
expect(redirect.replace('/api/common/NgClass-directive')).toEqual('<common><NgClass>');
const globRedirect = new FirebaseRedirect({source: '/api/:package/:api-*', destination: '<:package><:api>'});
expect(globRedirect.replace('/api/common/NgClass-directive')).toBe('<common><NgClass>');
const regexRedirect = new FirebaseRedirect(
{regex: '^/api/(?P<package>[^/]+)/(?P<api>[^/]+)-.*$', destination: '<:package><:api>'});
expect(regexRedirect.replace('/api/common/NgClass-directive')).toBe('<common><NgClass>');
});
it('should inject rest params into the destination', () => {

View File

@ -6,8 +6,10 @@ export class FirebaseRedirect {
source: FirebaseRedirectSource;
destination: string;
constructor({source, destination}: FirebaseRedirectConfig) {
this.source = FirebaseRedirectSource.fromGlobPattern(source);
constructor({source, regex, destination}: FirebaseRedirectConfig) {
this.source = (typeof source === 'string') ?
FirebaseRedirectSource.fromGlobPattern(source) :
FirebaseRedirectSource.fromRegexPattern(regex!);
this.destination = destination;
}

View File

@ -2,6 +2,7 @@ import { FirebaseRedirectSource } from './FirebaseRedirectSource';
describe('FirebaseRedirectSource', () => {
describe('test', () => {
describe('(using glob)', () => {
it('should match * parts', () => {
testGlob('asdf/*.jpg',
['asdf/.jpg', 'asdf/asdf.jpg', 'asdf/asdf_asdf.jpg'],
@ -50,12 +51,41 @@ describe('FirebaseRedirectSource', () => {
.toThrowError('Error in FirebaseRedirectSource: "/&(a|b)/c" - unknown expansion type: "&" in "&(a|b)"');
});
// Globs that contain params tested via the match tests below
// Globs that contain params are tested via the match tests below
});
describe('(using regex)', () => {
it('should match simple regexes', () => {
testRegex('^asdf/[^/]*\\.jpg$',
['asdf/.jpg', 'asdf/asdf.jpg', 'asdf/asdf_asdf.jpg'],
['asdf/asdf/asdf.jpg', 'xxxasdf/asdf.jpgxxx', 'asdf/asdf_jpg']);
});
it('should match regexes with capture groups', () => {
testRegex('asdf/([^/]+)\\.jpg$',
['asdf/asdf.jpg', 'asdf/asdf_asdf.jpg', 'asdf/asdf/asdf.jpg'],
['asdf/.jpg', 'xxxasdf/asdf.jpgxxx', 'asdf/asdf_jpg']);
});
it('should match regexes with named capture groups', () => {
testRegex('^asdf/(?P<name>[^/]*)\\.jpg$',
['asdf/.jpg', 'asdf/asdf.jpg', 'asdf/asdf_asdf.jpg'],
['asdf/asdf/asdf.jpg', 'xxxasdf/asdf.jpgxxx', 'asdf/asdf_jpg']);
});
it('should error on non-supported named capture group syntax', () => {
expect(() => FirebaseRedirectSource.fromRegexPattern('/(?<foo>.*)')).toThrowError(
'Error in FirebaseRedirectSource: "/(?<foo>.*)" - The regular expression pattern ' +
'contains a named capture group of the format `(?<name>...)`, which is not ' +
'compatible with the RE2 library. Use `(?P<name>...)` instead.');
});
});
});
describe('match', () => {
describe('(using glob)', () => {
it('should match patterns with no parameters', () => {
testMatch('/abc/def/*', {
testGlobMatch('/abc/def/*', {
}, {
'/abc/def/': {},
'/abc/def/ghi': {},
@ -66,7 +96,7 @@ describe('FirebaseRedirectSource', () => {
});
it('should capture a simple named param', () => {
testMatch('/:abc', {
testGlobMatch('/:abc', {
named: ['abc']
}, {
'/a': {abc: 'a'},
@ -77,7 +107,7 @@ describe('FirebaseRedirectSource', () => {
'/a/a/b': undefined,
'/a/a/b/': undefined,
});
testMatch('/a/:b', {
testGlobMatch('/a/:b', {
named: ['b']
}, {
'/a/b': {b: 'b'},
@ -92,7 +122,7 @@ describe('FirebaseRedirectSource', () => {
});
it('should capture a named param followed by non-word chars', () => {
testMatch('/a/:x-', {
testGlobMatch('/a/:x-', {
named: ['x']
}, {
'/a/b-': {x: 'b'},
@ -108,7 +138,7 @@ describe('FirebaseRedirectSource', () => {
});
it('should capture multiple named params', () => {
testMatch('/a/:b/:c', {
testGlobMatch('/a/:b/:c', {
named: ['b', 'c']
}, {
'/a/b/c': {b: 'b', c: 'c'},
@ -118,7 +148,7 @@ describe('FirebaseRedirectSource', () => {
'/a/b/': undefined,
'/a/b/c/': undefined,
});
testMatch('/:a/b/:c', {
testGlobMatch('/:a/b/:c', {
named: ['a', 'c']
}, {
'/a/b/c': {a: 'a', c: 'c'},
@ -131,7 +161,7 @@ describe('FirebaseRedirectSource', () => {
});
it('should capture a simple rest param', () => {
testMatch('/:abc*', {
testGlobMatch('/:abc*', {
rest: ['abc']
}, {
'/a': {abc: 'a'},
@ -143,7 +173,7 @@ describe('FirebaseRedirectSource', () => {
'/a/b/c': {abc: 'a/b/c'},
'/a/b/c/': {abc: 'a/b/c/'},
});
testMatch('/a/:b*', {
testGlobMatch('/a/:b*', {
rest: ['b']
}, {
'/a/b': {b: 'b'},
@ -158,7 +188,7 @@ describe('FirebaseRedirectSource', () => {
});
it('should capture a rest param mixed with a named param', () => {
testMatch('/:abc/:rest*', {
testGlobMatch('/:abc/:rest*', {
named: ['abc'],
rest: ['rest']
}, {
@ -173,17 +203,119 @@ describe('FirebaseRedirectSource', () => {
});
});
});
describe('(using regex)', () => {
it('should match patterns with no parameters', () => {
testRegexMatch('^/abc/def/[^/]*$', {
}, {
'/abc/def/': {},
'/abc/def/ghi': {},
'/': undefined,
'/abc': undefined,
'/abc/def/ghi/jk;': undefined,
});
});
it('should capture a simple named group', () => {
testRegexMatch('^/(?P<abc>[^/]+)$', {
named: ['abc'],
}, {
'/a': {abc: 'a'},
'/abc': {abc: 'abc'},
'/': undefined,
'/a/': undefined,
'/a/b/': undefined,
'/a/a/b': undefined,
'/a/a/b/': undefined,
});
testRegexMatch('^/a/(?P<b>[^/]+)$', {
named: ['b'],
}, {
'/a/b': {b: 'b'},
'/a/bcd': {b: 'bcd'},
'/a/': undefined,
'/a/b/': undefined,
'/a': undefined,
'/a//': undefined,
'/a/a/b': undefined,
'/a/a/b/': undefined,
});
});
it('should capture a named group followed by non-word chars', () => {
testRegexMatch('^/a/(?P<x>[^/]+)-$', {
named: ['x']
}, {
'/a/b-': {x: 'b'},
'/a/bcd-': {x: 'bcd'},
'/a/--': {x: '-'},
'/a': undefined,
'/a/-': undefined,
'/a/-/': undefined,
'/a/': undefined,
'/a/b/-': undefined,
'/a/b-c': undefined,
});
});
it('should capture multiple named capture groups', () => {
testRegexMatch('^/a/(?P<b>[^/]+)/(?P<c>[^/]+)$', {
named: ['b', 'c']
}, {
'/a/b/c': {b: 'b', c: 'c'},
'/a/bcd/efg': {b: 'bcd', c: 'efg'},
'/a/b/c-': {b: 'b', c: 'c-'},
'/a/': undefined,
'/a/b/': undefined,
'/a/b/c/': undefined,
});
testRegexMatch('^/(?P<a>[^/]+)/b/(?P<c>[^/]+)$', {
named: ['a', 'c']
}, {
'/a/b/c': {a: 'a', c: 'c'},
'/abc/b/efg': {a: 'abc', c: 'efg'},
'/a/b/c-': {a: 'a', c: 'c-'},
'/a/': undefined,
'/a/b/': undefined,
'/a/b/c/': undefined,
});
});
});
});
});
function testGlob(pattern: string, matches: string[], nonMatches: string[]) {
const redirectSource = FirebaseRedirectSource.fromGlobPattern(pattern);
matches.forEach(url => expect(redirectSource.test(url)).toBe(true, url));
nonMatches.forEach(url => expect(redirectSource.test(url)).toBe(false, url));
return testSource(FirebaseRedirectSource.fromGlobPattern(pattern), matches, nonMatches);
}
function testMatch(pattern: string, captures: { named?: string[], rest?: string[] }, matches: { [url: string]: object|undefined }) {
const redirectSource = FirebaseRedirectSource.fromGlobPattern(pattern);
expect(redirectSource.namedGroups).toEqual(captures.named || []);
expect(redirectSource.restNamedGroups).toEqual(captures.rest || []);
Object.keys(matches).forEach(url => expect(redirectSource.match(url)).toEqual(matches[url]));
function testRegex(pattern: string, matches: string[], nonMatches: string[]) {
return testSource(FirebaseRedirectSource.fromRegexPattern(pattern), matches, nonMatches);
}
function testSource(source: FirebaseRedirectSource, matches: string[], nonMatches: string[]) {
matches.forEach(url => expect(source.test(url)).toBe(true, url));
nonMatches.forEach(url => expect(source.test(url)).toBe(false, url));
}
function testGlobMatch(
pattern: string,
captures: { named?: string[], rest?: string[] },
matches: { [url: string]: object|undefined }) {
return testSourceMatch(FirebaseRedirectSource.fromGlobPattern(pattern), captures, matches);
}
function testRegexMatch(
pattern: string,
captures: { named?: string[], rest?: string[] },
matches: { [url: string]: object|undefined }) {
return testSourceMatch(FirebaseRedirectSource.fromRegexPattern(pattern), captures, matches);
}
function testSourceMatch(
source: FirebaseRedirectSource,
captures: { named?: string[], rest?: string[] },
matches: { [url: string]: object|undefined }) {
expect(source.namedGroups).toEqual(captures.named || []);
expect(source.restNamedGroups).toEqual(captures.rest || []);
Object.keys(matches).forEach(url => expect(source.match(url)).toEqual(matches[url]));
}

View File

@ -53,6 +53,29 @@ export class FirebaseRedirectSource {
}
}
static fromRegexPattern(regex: string): FirebaseRedirectSource {
try {
// NOTE:
// Firebase redirect regexes use the [RE2 library](https://github.com/google/re2/wiki/Syntax),
// which requires named capture groups to begin with `?P`. See
// https://firebase.google.com/docs/hosting/full-config#capture-url-segments-for-redirects.
if (/\(\?<[^>]+>/.test(regex)) {
// Throw if the regex contains a non-RE2 named capture group.
throw new Error(
'The regular expression pattern contains a named capture group of the format ' +
'`(?<name>...)`, which is not compatible with the RE2 library. Use `(?P<name>...)` ' +
'instead.');
}
// Replace `(?P<...>` with just `(?<...>` to convert to `XRegExp`-compatible syntax for named
// capture groups.
return new FirebaseRedirectSource(regex.replace(/(\(\?)P(<[^>]+>)/g, '$1$2'));
} catch (err) {
throw new Error(`Error in FirebaseRedirectSource: "${regex}" - ${err.message}`);
}
}
test(url: string): boolean {
return XRegExp.test(url, this.regex);
}

View File

@ -4,7 +4,7 @@ describe('FirebaseRedirector', () => {
it('should replace with the first matching redirect', () => {
const redirector = new FirebaseRedirector([
{ source: '/a/b/c', destination: '/X/Y/Z' },
{ source: '/a/:foo/c', destination: '/X/:foo/Z' },
{ regex: '^/a/(?P<foo>[^/]+)/c$', destination: '/X/:foo/Z' },
{ source: '/**/:foo/c', destination: '/A/:foo/zzz' },
]);
expect(redirector.redirect('/a/b/c')).toEqual('/X/Y/Z');
@ -15,9 +15,9 @@ describe('FirebaseRedirector', () => {
it('should return the original url if no redirect matches', () => {
const redirector = new FirebaseRedirector([
{ source: 'x', destination: 'X' },
{ regex: '^x$', destination: 'X' },
{ source: 'y', destination: 'Y' },
{ source: 'z', destination: 'Z' },
{ regex: '^z$', destination: 'Z' },
]);
expect(redirector.redirect('a')).toEqual('a');
});
@ -25,7 +25,7 @@ describe('FirebaseRedirector', () => {
it('should recursively redirect', () => {
const redirector = new FirebaseRedirector([
{ source: 'a', destination: 'b' },
{ source: 'b', destination: 'c' },
{ regex: '^b$', destination: 'c' },
{ source: 'c', destination: 'd' },
]);
expect(redirector.redirect('a')).toEqual('d');
@ -33,9 +33,9 @@ describe('FirebaseRedirector', () => {
it('should throw if stuck in an infinite loop', () => {
const redirector = new FirebaseRedirector([
{ source: 'a', destination: 'b' },
{ regex: '^a$', destination: 'b' },
{ source: 'b', destination: 'c' },
{ source: 'c', destination: 'a' },
{ regex: '^c$', destination: 'a' },
]);
expect(() => redirector.redirect('a')).toThrowError('infinite redirect loop');
});

View File

@ -1,9 +1,8 @@
import { FirebaseRedirect } from './FirebaseRedirect';
export interface FirebaseRedirectConfig {
source: string;
destination: string;
}
export type FirebaseRedirectConfig =
{ source: string, regex?: undefined, destination: string } |
{ source?: undefined, regex: string, destination: string };
export class FirebaseRedirector {
private redirects: FirebaseRedirect[];