/* tslint:disable:function-constructor no-any */ import { FileType, ProjectPluginStructure } from '@teleporthq/teleport-types' import { NextInstantSearchProjectPlugin } from '../src/instant-search/project-plugin' import { NextItemComparisonProjectPlugin } from '../src/item-comparison/project-plugin' import { withoutCredentialColumns } from '../src/plugins/public-exposure' import { generateRawQueryApiRoute } from '../src/global-state/data-source-utils' /** * The plugins' public routes (the instant search, the item comparison) index * and serve only what a page every visitor may open shows: a table only * signed-in or subscriber pages bind is never indexed, nor a page behind a * sign-in, nor a credential-named column. And an app-wide global state over a * protected table answers the project's staff. */ const APP_CONTENT = `const MyApp = ({ Component, pageProps }) => export default MyApp ` const details = (navLink: string, tableName: string) => ({ value: navLink.split('/')[1], pageOptions: { navLink, dynamicRouteAttribute: 'slug', detailsPageInfo: { dataSourceId: 'ds', dataSourceName: 'Teleport', dataSourceType: 'teleport', tableName, differentiatorColumn: 'slug', featureIdentifier: '', }, }, }) const structureWith = (plugins: Record): ProjectPluginStructure => { const files = new Map() files.set('_app', { path: ['pages'], files: [{ name: '_app', fileType: FileType.JS, content: APP_CONTENT }], }) return { uidl: { name: 'p', globals: { settings: { title: 'P', language: 'en' }, assets: [] }, dataSources: { ds: { id: 'ds', name: 'Teleport', type: 'teleport', config: {} } }, ecommerceSettings: { paymentProviders: [] }, blogSettings: {}, bookingSettings: {}, plugins, authentication: { enabled: true, roles: ['admin', 'user'], pageProtection: { // The posts are a subscriber's, the booking items a member's. blog: { requiresAuth: true, allowedRoles: [], pageName: 'blog', route: '/blog', requiresSubscription: true, subscriptionProductIds: [], }, book: { requiresAuth: true, allowedRoles: [], pageName: 'book', route: '/book' }, members: { requiresAuth: true, allowedRoles: [], pageName: 'members', route: '/members' }, }, folderProtection: {}, authPages: {}, }, root: { name: 'App', stateDefinitions: { route: { type: 'string', values: [ { value: 'home', pageOptions: { navLink: '/' } }, details('/products/[slug]', 'teleport_products'), details('/blog/[slug]', 'teleport_blog_posts'), details('/book/[slug]', 'teleport_booking_items'), ], }, }, }, }, files, dependencies: {}, devDependencies: {}, template: { files: [], subFolders: [] }, } as unknown as ProjectPluginStructure } describe('the plugins index and serve only what a public page shows', () => { it('the instant search reads no table a protected or subscriber page binds, and no protected page', async () => { const structure = structureWith({ instantSearch: { settings: { sections: ['pages', 'products', 'posts', 'bookingItems'] }, data: { pages: [ { title: 'About', route: '/', text: 'Our story' }, { title: 'Members', route: '/members', text: 'The secret handshake' }, ], }, }, }) await new NextInstantSearchProjectPlugin().runAfter(structure) const route = structure.files.get('instant-search-route').files[0].content as string expect(route).toContain('key: "products"') expect(route).not.toContain('key: "posts"') expect(route).not.toContain('key: "bookingItems"') const index = structure.files.get('instant-search-index').files[0].content as string expect(index).toContain('Our story') expect(index).not.toContain('secret handshake') }) it('the item comparison compares no table a protected page binds', async () => { const structure = structureWith({ itemComparison: { settings: {} } }) await new NextItemComparisonProjectPlugin().runAfter(structure) const route = structure.files.get('item-comparison-route').files[0].content as string expect(route).toContain('"products": {') expect(route).not.toContain('"bookingItems": {') }) it('never indexes the table of a row-owned (self-guarded) details page (I3)', async () => { const structure = structureWith({ instantSearch: { settings: { sections: ['products'] }, data: { pages: [] } }, }) ;(structure.uidl as any).authentication.pageProtection.products = { requiresAuth: true, allowedRoles: [], pageName: 'product', route: '/products', routePattern: '/products/[slug]', rowOwnerColumn: 'user_id', rowOwnerDifferentiator: 'slug', } await new NextInstantSearchProjectPlugin().runAfter(structure) const route = structure.files.get('instant-search-route').files[0].content as string expect(route).not.toContain('key: "products"') }) it('never names a credential column', () => { expect( withoutCredentialColumns(['name', 'api_key', 'secret_ingredient', 'access_token']) ).toEqual(['name', 'secret_ingredient']) }) }) describe('an app-wide global state over a protected table', () => { const boot = (staffRoles: string[]) => { const code = generateRawQueryApiRoute( { id: 'ds', name: 'db', type: 'teleport', config: {} } as any, "SELECT COUNT(*) AS pending FROM teleport_orders WHERE status = 'pending'", staffRoles ) .replace( /^import \{ Client, types as pgTypes \} from 'pg'$/m, "const { Client, types: pgTypes } = require('pg')" ) .replace(/^export default async function handler/m, 'module.exports = async function handler') let session: any = null class FakeClient { public async connect(): Promise { return } public async end(): Promise { return } public async query(): Promise<{ rows: any[] }> { return { rows: [{ pending: 3 }] } } } const fakeRequire = (name: string): any => { if (name === 'pg') { return { Client: FakeClient, types: { setTypeParser: (): void => undefined } } } if (name === 'next-auth/jwt') { return { getToken: async () => session } } if (name === 'crypto') { return require('crypto') } throw new Error('unexpected require: ' + name) } const moduleObj: { exports: any } = { exports: {} } new Function('require', 'module', 'exports', 'process', 'console', code)( fakeRequire, moduleObj, moduleObj.exports, { env: { NEXTAUTH_SECRET: 'server-secret' } }, { log: (): void => undefined, warn: (): void => undefined, error: (): void => undefined } ) return async (who: any) => { session = who const reply: any = {} const res: any = { status(c: number) { reply.status = c return res }, json(p: any) { reply.body = p return res }, } // A signed-in local request carries the plain session cookie. const headers = who ? { cookie: 'next-auth.session-token=signed' } : {} await moduleObj.exports({ method: 'GET', query: {}, headers }, res) return reply } } it("answers the project's staff, and no one else", async () => { const route = boot(['admin', 'manager']) expect((await route({ sub: 'a1', role: 'admin' })).status).toBe(200) expect((await route({ sub: 'm1', role: 'manager' })).status).toBe(200) expect((await route({ sub: 'u1', role: 'member' })).status).toBe(403) expect((await route(null)).status).toBe(401) }) })