ProjectNeedsSecuring.test.tsx271 lines · main
1import { fireEvent, screen } from '@testing-library/react'
2import { mockAnimationsApi } from 'jsdom-testing-mocks'
3import type { MouseEventHandler, ReactNode } from 'react'
4import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
5
6import { ProjectNeedsSecuring } from './ProjectNeedsSecuring'
7import { render } from '@/tests/helpers'
8
9const {
10 mockUseFlag,
11 mockUseProjectLintsQuery,
12 mockUseSelectedProjectQuery,
13 mockUseTablesQuery,
14 mockUseProjectPostgrestConfigQuery,
15 mockUseTablePrivilegesQuery,
16 mockUseLocalStorageQuery,
17 mockUseRouter,
18 mockRouterPush,
19} = vi.hoisted(() => ({
20 mockUseFlag: vi.fn(),
21 mockUseProjectLintsQuery: vi.fn(),
22 mockUseSelectedProjectQuery: vi.fn(),
23 mockUseTablesQuery: vi.fn(),
24 mockUseProjectPostgrestConfigQuery: vi.fn(),
25 mockUseTablePrivilegesQuery: vi.fn(),
26 mockUseLocalStorageQuery: vi.fn(),
27 mockUseRouter: vi.fn(),
28 mockRouterPush: vi.fn(),
29}))
30
31vi.mock('common', async () => {
32 const actual = await vi.importActual<typeof import('common')>('common')
33
34 return {
35 ...actual,
36 useFlag: mockUseFlag,
37 useParams: () => ({ ref: 'project-ref' }),
38 }
39})
40
41vi.mock('next/router', () => ({
42 useRouter: () => mockUseRouter(),
43}))
44
45vi.mock('next/link', () => ({
46 default: ({
47 href,
48 children,
49 onClick,
50 ...props
51 }: {
52 href: string
53 children: ReactNode
54 onClick?: MouseEventHandler<HTMLAnchorElement>
55 [key: string]: unknown
56 }) => (
57 <a href={href} onClick={onClick} {...props}>
58 {children}
59 </a>
60 ),
61}))
62
63vi.mock('@/data/lint/lint-query', () => ({
64 useProjectLintsQuery: mockUseProjectLintsQuery,
65}))
66
67vi.mock('@/hooks/misc/useSelectedProject', () => ({
68 useSelectedProjectQuery: mockUseSelectedProjectQuery,
69}))
70
71vi.mock('@/data/tables/tables-query', () => ({
72 useTablesQuery: mockUseTablesQuery,
73}))
74
75vi.mock('@/data/config/project-postgrest-config-query', () => ({
76 parseDbSchemaString: vi.fn((value: string) => value.split(',').map((schema) => schema.trim())),
77 useProjectPostgrestConfigQuery: mockUseProjectPostgrestConfigQuery,
78}))
79
80vi.mock('@/data/privileges/table-privileges-query', () => ({
81 useTablePrivilegesQuery: mockUseTablePrivilegesQuery,
82}))
83
84vi.mock('@/hooks/misc/useLocalStorage', () => ({
85 useLocalStorageQuery: mockUseLocalStorageQuery,
86}))
87
88vi.mock('sonner', () => ({
89 toast: {
90 error: vi.fn(),
91 },
92}))
93
94const issueLint = {
95 cache_key: 'lint-1',
96 name: 'rls_disabled_in_public',
97 detail: 'RLS is disabled on public.invoices',
98 description: 'RLS disabled',
99 level: 'ERROR',
100 categories: ['SECURITY'],
101 metadata: {
102 schema: 'public',
103 name: 'invoices',
104 },
105}
106
107const tables = [
108 {
109 id: 1,
110 name: 'invoices',
111 schema: 'public',
112 rls_enabled: false,
113 },
114 {
115 id: 2,
116 name: 'profiles',
117 schema: 'public',
118 rls_enabled: false,
119 },
120 {
121 id: 3,
122 name: 'customers',
123 schema: 'public',
124 rls_enabled: true,
125 },
126]
127
128const tablePrivileges = [
129 {
130 schema: 'public',
131 name: 'invoices',
132 privileges: [
133 {
134 grantee: 'anon',
135 privilege_type: 'SELECT',
136 },
137 ],
138 },
139]
140
141describe('ProjectNeedsSecuring', () => {
142 beforeEach(() => {
143 mockAnimationsApi()
144 mockUseFlag.mockReturnValue(true)
145 mockUseRouter.mockReturnValue({ pathname: '/project/[ref]', push: mockRouterPush })
146 mockUseSelectedProjectQuery.mockReturnValue({
147 data: { connectionString: 'postgresql://example' },
148 })
149 mockUseProjectLintsQuery.mockReturnValue({
150 data: [issueLint],
151 isPending: false,
152 isError: false,
153 })
154 mockUseTablesQuery.mockReturnValue({
155 data: tables,
156 isPending: false,
157 isError: false,
158 })
159 mockUseProjectPostgrestConfigQuery.mockReturnValue({
160 data: 'public',
161 isPending: false,
162 isError: false,
163 })
164 mockUseTablePrivilegesQuery.mockReturnValue({
165 data: tablePrivileges,
166 isPending: false,
167 isError: false,
168 })
169 mockUseLocalStorageQuery.mockReturnValue([null, vi.fn(), { isLoading: false }])
170 })
171
172 afterEach(() => {
173 vi.clearAllMocks()
174 window.localStorage.clear()
175 })
176
177 it('renders the security gate when an exposed table has RLS disabled and the project has not been dismissed', () => {
178 render(
179 <ProjectNeedsSecuring>
180 <div data-testid="project-children">Project content</div>
181 </ProjectNeedsSecuring>
182 )
183
184 expect(screen.getByText('Your project needs securing')).toBeInTheDocument()
185 expect(screen.getByText('Review and fix')).toBeInTheDocument()
186 expect(screen.getByRole('link', { name: 'Open Data API settings' })).toHaveAttribute(
187 'href',
188 '/project/project-ref/integrations/data_api/settings'
189 )
190 expect(screen.queryByRole('columnheader', { name: 'Action' })).not.toBeInTheDocument()
191 expect(screen.queryByRole('link', { name: 'View policies' })).not.toBeInTheDocument()
192 expect(screen.queryByText('profiles')).not.toBeInTheDocument()
193 expect(screen.queryByText('customers')).not.toBeInTheDocument()
194 expect(screen.getByText('Skip to home')).toBeInTheDocument()
195 expect(screen.queryByTestId('project-children')).not.toBeInTheDocument()
196 })
197
198 it('navigates to the table policies page when a table row is clicked', () => {
199 render(
200 <ProjectNeedsSecuring>
201 <div data-testid="project-children">Project content</div>
202 </ProjectNeedsSecuring>
203 )
204
205 fireEvent.click(screen.getByText('invoices'))
206
207 expect(mockRouterPush).toHaveBeenCalledWith(
208 '/project/project-ref/auth/policies?schema=public&search=invoices'
209 )
210 })
211
212 it('renders the project content when the security gate has been dismissed', () => {
213 mockUseLocalStorageQuery.mockReturnValue([
214 '2026-04-21T00:00:00.000Z',
215 vi.fn(),
216 { isLoading: false },
217 ])
218
219 render(
220 <ProjectNeedsSecuring>
221 <div data-testid="project-children">Project content</div>
222 </ProjectNeedsSecuring>
223 )
224
225 expect(screen.queryByText('Your project needs securing')).not.toBeInTheDocument()
226 expect(screen.getByTestId('project-children')).toBeInTheDocument()
227 })
228
229 it('renders the project content when there are no RLS issues', () => {
230 mockUseProjectLintsQuery.mockReturnValue({
231 data: [],
232 isPending: false,
233 isError: false,
234 })
235
236 render(
237 <ProjectNeedsSecuring>
238 <div data-testid="project-children">Project content</div>
239 </ProjectNeedsSecuring>
240 )
241
242 expect(screen.queryByText('Your project needs securing')).not.toBeInTheDocument()
243 expect(screen.getByTestId('project-children')).toBeInTheDocument()
244 })
245
246 it('renders the project content on non-home project routes', () => {
247 mockUseRouter.mockReturnValue({ pathname: '/project/[ref]/database/tables' })
248
249 render(
250 <ProjectNeedsSecuring>
251 <div data-testid="project-children">Project content</div>
252 </ProjectNeedsSecuring>
253 )
254
255 expect(screen.queryByText('Your project needs securing')).not.toBeInTheDocument()
256 expect(screen.getByTestId('project-children')).toBeInTheDocument()
257 })
258
259 it('renders the project content when the feature flag is disabled', () => {
260 mockUseFlag.mockReturnValue(false)
261
262 render(
263 <ProjectNeedsSecuring>
264 <div data-testid="project-children">Project content</div>
265 </ProjectNeedsSecuring>
266 )
267
268 expect(screen.queryByText('Your project needs securing')).not.toBeInTheDocument()
269 expect(screen.getByTestId('project-children')).toBeInTheDocument()
270 })
271})