mirror of
https://github.com/supabase/supabase.git
synced 2026-10-06 01:45:10 +03:00
I added permanent redirects for the moved Dashboard routes that still send visitors to 404s. Project and organization identifiers carry through, while the old project billing path opens the organization picker for billing. **Note:** The bare `/dashboard/project` path redirects straight to Organizations instead of the `/dashboard/projects` hop named in GROWTH-1295, since `/projects` already redirects there. ## To test Tested on the Studio preview: - [x] Requested the eight old Dashboard paths in GROWTH-1295 while signed out. Each returned 308 with the specified destination. - [ ] Request bare `/dashboard/project` while signed out on the latest preview. Expect a single 308 to `/dashboard/organizations`. - [x] Requested a project backup path with a query string. The destination kept the project ref and query string. - [x] Requested `/dashboard/project/_`. The project picker remained reachable. ## Linear - fixes GROWTH-1295 <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **Bug Fixes** * Added permanent redirects for legacy Studio routes covering account and project pages, backups, email templates, edge-function logs, secrets, and billing settings. * Redirects preserve incoming query parameters and URL fragments; the project selector remains unaffected. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
284 lines
8.6 KiB
TypeScript
284 lines
8.6 KiB
TypeScript
import { describe, expect, it } from 'vitest'
|
|
|
|
import { matchRedirect, preserveQueryAndHash } from './redirects.shared'
|
|
|
|
describe('preserveQueryAndHash', () => {
|
|
it('carries incoming query params onto the destination', () => {
|
|
expect(preserveQueryAndHash('/org', { foo: '1', bar: 'x' })).toBe('/org?foo=1&bar=x')
|
|
})
|
|
|
|
it('returns the destination untouched when there is nothing to carry', () => {
|
|
expect(preserveQueryAndHash('/org', {})).toBe('/org')
|
|
expect(preserveQueryAndHash('/org', new URLSearchParams())).toBe('/org')
|
|
})
|
|
|
|
it('drops params consumed by the matched rule', () => {
|
|
expect(
|
|
preserveQueryAndHash(
|
|
'/new/new-project',
|
|
{ next: 'new-project', a: '1' },
|
|
{
|
|
consumedKeys: ['next'],
|
|
}
|
|
)
|
|
).toBe('/new/new-project?a=1')
|
|
})
|
|
|
|
it("lets the destination's own params win on conflict", () => {
|
|
expect(
|
|
preserveQueryAndHash('/org/_/billing?panel=subscriptionPlan', { panel: 'other', x: '1' })
|
|
).toBe('/org/_/billing?panel=subscriptionPlan&x=1')
|
|
})
|
|
|
|
it('preserves repeated keys and array values', () => {
|
|
expect(preserveQueryAndHash('/dest', new URLSearchParams('f=a&f=b'))).toBe('/dest?f=a&f=b')
|
|
expect(preserveQueryAndHash('/dest', { f: ['a', 'b'] })).toBe('/dest?f=a&f=b')
|
|
})
|
|
|
|
it('carries the incoming hash', () => {
|
|
expect(preserveQueryAndHash('/dest', { a: '1' }, { hash: 'section' })).toBe('/dest?a=1#section')
|
|
expect(preserveQueryAndHash('/dest', {}, { hash: 'section' })).toBe('/dest#section')
|
|
})
|
|
|
|
it("lets the destination's own hash win over the incoming one", () => {
|
|
expect(preserveQueryAndHash('/org/slug/billing#invoices', { a: '1' }, { hash: 'other' })).toBe(
|
|
'/org/slug/billing?a=1#invoices'
|
|
)
|
|
})
|
|
|
|
it('skips undefined values in a record search', () => {
|
|
expect(preserveQueryAndHash('/dest', { a: undefined, b: '1' })).toBe('/dest?b=1')
|
|
})
|
|
})
|
|
|
|
describe('matchRedirect moved Studio routes', () => {
|
|
const ref = 'abcdefghijklmnopqrst'
|
|
|
|
it.each([
|
|
[`/project/${ref}/database/backups`, `/project/${ref}/database/backups/scheduled`],
|
|
[`/project/${ref}/auth/emails`, `/project/${ref}/auth/templates`],
|
|
[`/project/${ref}/logs/edge-functions`, `/project/${ref}/logs/edge-functions-logs`],
|
|
[`/project/${ref}/settings/vault`, `/project/${ref}/integrations/vault/secrets`],
|
|
[`/project/${ref}/settings/billing`, '/org/_/billing'],
|
|
['/account', '/account/me'],
|
|
['/org/my-org/projects', '/org/my-org'],
|
|
['/project', '/organizations'],
|
|
])('redirects %s to %s', (pathname, destination) => {
|
|
expect(matchRedirect({ pathname, search: {}, isPlatform: true })).toEqual({
|
|
destination,
|
|
permanent: true,
|
|
})
|
|
})
|
|
|
|
it('preserves the incoming query and hash', () => {
|
|
expect(
|
|
matchRedirect({
|
|
pathname: `/project/${ref}/database/backups`,
|
|
search: { source: 'bookmark' },
|
|
hash: 'scheduled',
|
|
isPlatform: true,
|
|
})
|
|
).toEqual({
|
|
destination: `/project/${ref}/database/backups/scheduled?source=bookmark#scheduled`,
|
|
permanent: true,
|
|
})
|
|
})
|
|
|
|
it('does not intercept the project selector', () => {
|
|
expect(matchRedirect({ pathname: '/project/_', search: {}, isPlatform: true })).toBeNull()
|
|
})
|
|
})
|
|
|
|
describe('matchRedirect query/hash preservation', () => {
|
|
it('redirects the legacy compute and disk route while preserving query and hash', () => {
|
|
expect(
|
|
matchRedirect({
|
|
pathname: '/project/abc/settings/compute-and-disk',
|
|
search: { upgrade: 'micro' },
|
|
isPlatform: true,
|
|
hash: 'disk',
|
|
})
|
|
).toEqual({
|
|
destination: '/project/abc/settings/infrastructure?upgrade=micro#disk',
|
|
permanent: true,
|
|
})
|
|
})
|
|
|
|
it('redirects legacy replication replica detail to infrastructure', () => {
|
|
expect(
|
|
matchRedirect({
|
|
pathname: '/project/abc/database/replication/replica/replica-1',
|
|
search: {},
|
|
isPlatform: true,
|
|
})
|
|
).toEqual({
|
|
destination: '/project/abc/settings/infrastructure/replica/replica-1',
|
|
permanent: true,
|
|
})
|
|
})
|
|
|
|
it('redirects the legacy replication list route to Pipelines while preserving query and hash', () => {
|
|
expect(
|
|
matchRedirect({
|
|
pathname: '/project/abc/database/replication',
|
|
search: { source: 'bookmark' },
|
|
isPlatform: true,
|
|
hash: 'destinations',
|
|
})
|
|
).toEqual({
|
|
destination: '/project/abc/database/pipelines?source=bookmark#destinations',
|
|
permanent: true,
|
|
})
|
|
})
|
|
|
|
it('redirects legacy pipeline detail URLs to Pipelines', () => {
|
|
expect(
|
|
matchRedirect({
|
|
pathname: '/project/abc/database/replication/pipeline-1',
|
|
search: {},
|
|
isPlatform: true,
|
|
})
|
|
).toEqual({
|
|
destination: '/project/abc/database/pipelines/pipeline-1',
|
|
permanent: true,
|
|
})
|
|
})
|
|
|
|
it('redirects the legacy compute billing panel to the CPU section', () => {
|
|
expect(
|
|
matchRedirect({
|
|
pathname: '/project/abc/settings/billing/subscription',
|
|
search: { panel: 'computeInstance', source: 'banner' },
|
|
isPlatform: true,
|
|
})
|
|
).toEqual({
|
|
destination: '/project/abc/settings/infrastructure?source=banner#cpu',
|
|
permanent: true,
|
|
})
|
|
})
|
|
|
|
it('carries the incoming query and hash through a plain rule', () => {
|
|
expect(
|
|
matchRedirect({
|
|
pathname: '/project/abc/sql/quickstarts',
|
|
search: { template: 'countries', flag: 'true' },
|
|
isPlatform: true,
|
|
hash: 'top',
|
|
})
|
|
).toEqual({
|
|
destination: '/project/abc/sql/examples?template=countries&flag=true#top',
|
|
permanent: true,
|
|
})
|
|
})
|
|
|
|
it('consumes `has` query keys but keeps the rest', () => {
|
|
expect(
|
|
matchRedirect({
|
|
pathname: '/',
|
|
search: { next: 'new-project', projectName: 'foo' },
|
|
isPlatform: true,
|
|
})
|
|
).toEqual({ destination: '/new/new-project?projectName=foo', permanent: false })
|
|
})
|
|
|
|
it("keeps the destination's own params when the incoming query repeats them", () => {
|
|
expect(
|
|
matchRedirect({
|
|
pathname: '/project/abc/settings/billing/subscription',
|
|
search: { panel: 'pitr', source: 'email' },
|
|
isPlatform: true,
|
|
})
|
|
).toEqual({
|
|
destination: '/project/abc/settings/addons?panel=pitr&source=email',
|
|
permanent: true,
|
|
})
|
|
})
|
|
|
|
it('keeps a destination hash (e.g. billing#invoices) over the incoming hash', () => {
|
|
expect(
|
|
matchRedirect({
|
|
pathname: '/org/my-org/invoices',
|
|
search: {},
|
|
isPlatform: true,
|
|
hash: 'ignored',
|
|
})
|
|
).toEqual({ destination: '/org/my-org/billing#invoices', permanent: true })
|
|
})
|
|
|
|
it('leaves plain redirects without query or hash untouched', () => {
|
|
expect(matchRedirect({ pathname: '/', search: {}, isPlatform: true })).toEqual({
|
|
destination: '/org',
|
|
permanent: false,
|
|
})
|
|
expect(matchRedirect({ pathname: '/', search: {}, isPlatform: false })).toEqual({
|
|
destination: '/project/default',
|
|
permanent: false,
|
|
})
|
|
})
|
|
|
|
it('still returns null for non-matching paths', () => {
|
|
expect(
|
|
matchRedirect({ pathname: '/project/abc/editor', search: { a: '1' }, isPlatform: true })
|
|
).toBeNull()
|
|
})
|
|
})
|
|
|
|
describe('matchRedirect maintenance mode', () => {
|
|
it('sends every other path to /maintenance when enabled', () => {
|
|
expect(
|
|
matchRedirect({
|
|
pathname: '/project/abc/editor',
|
|
search: {},
|
|
isPlatform: true,
|
|
maintenanceMode: true,
|
|
})
|
|
).toEqual({ destination: '/maintenance', permanent: false })
|
|
})
|
|
|
|
it('carries query and hash onto /maintenance', () => {
|
|
expect(
|
|
matchRedirect({
|
|
pathname: '/project/abc/editor',
|
|
search: { a: '1' },
|
|
isPlatform: true,
|
|
maintenanceMode: true,
|
|
hash: 'section',
|
|
})
|
|
).toEqual({ destination: '/maintenance?a=1#section', permanent: false })
|
|
})
|
|
|
|
it('leaves /maintenance and /img reachable when enabled', () => {
|
|
expect(
|
|
matchRedirect({
|
|
pathname: '/maintenance',
|
|
search: {},
|
|
isPlatform: true,
|
|
maintenanceMode: true,
|
|
})
|
|
).toBeNull()
|
|
expect(
|
|
matchRedirect({
|
|
pathname: '/img/supabase-logo.svg',
|
|
search: {},
|
|
isPlatform: true,
|
|
maintenanceMode: true,
|
|
})
|
|
).toBeNull()
|
|
})
|
|
|
|
it('bounces /maintenance back to / when disabled', () => {
|
|
expect(matchRedirect({ pathname: '/maintenance', search: {}, isPlatform: true })).toEqual({
|
|
destination: '/',
|
|
permanent: false,
|
|
})
|
|
expect(
|
|
matchRedirect({
|
|
pathname: '/maintenance',
|
|
search: {},
|
|
isPlatform: true,
|
|
maintenanceMode: false,
|
|
})
|
|
).toEqual({ destination: '/', permanent: false })
|
|
})
|
|
})
|