Skip to content

Commit 31700c9

Browse files
authored
feat: server permission checking and v0.2.28 release (#178)
1 parent e05b982 commit 31700c9

8 files changed

Lines changed: 414 additions & 2 deletions

File tree

CHANGELOG.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,12 @@
11
# Changelog
22

3+
## Version 0.2.28
4+
5+
### Added
6+
7+
* Server permission checking system to validate data directory access on startup
8+
* Permission error display with troubleshooting guidance and recheck functionality
9+
310
## Version 0.2.27
411

512
### Fixed

components/Layout.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
1-
import { ResponsiveContainer } from 'recharts'
21
import ClientWrapper from './ClientWrapper'
32
import Header from './Header'
43
import Navigation from './Navigation'
4+
import PermissionError from './PermissionError'
55

66
export default function Layout({ children }: { children: React.ReactNode }) {
77
return (
@@ -14,6 +14,7 @@ export default function Layout({ children }: { children: React.ReactNode }) {
1414
<main className="flex-1 overflow-x-hidden overflow-y-auto bg-gray-100 dark:bg-gray-900 relative">
1515
{/* responsive container (optimized for mobile) */}
1616
<div className="mx-auto px-2 xs:px-4 py-8 max-w-sm xs:max-w-full">
17+
<PermissionError />
1718
{children}
1819
</div>
1920
</main>

components/PermissionError.tsx

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
2+
import { AlertTriangle } from 'lucide-react'
3+
import { checkStartupPermissions } from '@/lib/startup-checks'
4+
import RecheckButton from './RecheckButton'
5+
6+
export default async function PermissionError() {
7+
const permissionResult = await checkStartupPermissions()
8+
9+
// If everything is fine, render nothing
10+
if (permissionResult.success) {
11+
return null
12+
}
13+
14+
// Get error message
15+
const getErrorMessage = () => {
16+
return permissionResult.error?.message || 'Unknown permission error occurred.'
17+
}
18+
19+
return (
20+
<Alert variant="destructive" className="mb-4">
21+
<AlertTriangle className="h-4 w-4" />
22+
<AlertTitle className="font-bold">Permission Error</AlertTitle>
23+
<AlertDescription className="mt-2 flex flex-col">
24+
<div className="space-y-3">
25+
<span className="text-sm">
26+
{getErrorMessage()}{" "}
27+
<a
28+
href="https://docs.habittrove.com/troubleshooting"
29+
target="_blank"
30+
rel="noopener noreferrer"
31+
className="underline hover:text-red-300"
32+
>
33+
Troubleshooting Guide
34+
</a>
35+
</span>
36+
<div>
37+
<RecheckButton />
38+
</div>
39+
</div>
40+
</AlertDescription>
41+
</Alert>
42+
)
43+
}

components/RecheckButton.tsx

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
'use client'
2+
3+
import { Button } from '@/components/ui/button'
4+
import { RefreshCw } from 'lucide-react'
5+
6+
export default function RecheckButton() {
7+
const handleRecheck = () => {
8+
window.location.reload()
9+
}
10+
11+
return (
12+
<Button
13+
onClick={handleRecheck}
14+
variant="outline"
15+
size="sm"
16+
className="bg-red-50 border-red-300 text-red-700 hover:bg-red-100"
17+
>
18+
<RefreshCw className="h-4 w-4 mr-2" />
19+
Recheck
20+
</Button>
21+
)
22+
}

components/ui/alert.tsx

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
import * as React from "react"
2+
import { cva, type VariantProps } from "class-variance-authority"
3+
4+
import { cn } from "@/lib/utils"
5+
6+
const alertVariants = cva(
7+
"relative w-full rounded-lg border p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground",
8+
{
9+
variants: {
10+
variant: {
11+
default: "bg-background text-foreground",
12+
destructive:
13+
"border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive",
14+
warning:
15+
"border-yellow-200 bg-yellow-50 text-yellow-800 dark:border-yellow-800 dark:bg-yellow-950 dark:text-yellow-200 [&>svg]:text-yellow-600 dark:[&>svg]:text-yellow-400",
16+
},
17+
},
18+
defaultVariants: {
19+
variant: "default",
20+
},
21+
}
22+
)
23+
24+
const Alert = React.forwardRef<
25+
HTMLDivElement,
26+
React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof alertVariants>
27+
>(({ className, variant, ...props }, ref) => (
28+
<div
29+
ref={ref}
30+
role="alert"
31+
className={cn(alertVariants({ variant }), className)}
32+
{...props}
33+
/>
34+
))
35+
Alert.displayName = "Alert"
36+
37+
const AlertTitle = React.forwardRef<
38+
HTMLParagraphElement,
39+
React.HTMLAttributes<HTMLHeadingElement>
40+
>(({ className, ...props }, ref) => (
41+
<h5
42+
ref={ref}
43+
className={cn("mb-1 font-medium leading-none tracking-tight", className)}
44+
{...props}
45+
/>
46+
))
47+
AlertTitle.displayName = "AlertTitle"
48+
49+
const AlertDescription = React.forwardRef<
50+
HTMLParagraphElement,
51+
React.HTMLAttributes<HTMLParagraphElement>
52+
>(({ className, ...props }, ref) => (
53+
<div
54+
ref={ref}
55+
className={cn("text-sm [&_p]:leading-relaxed", className)}
56+
{...props}
57+
/>
58+
))
59+
AlertDescription.displayName = "AlertDescription"
60+
61+
export { Alert, AlertTitle, AlertDescription }

lib/startup-checks.test.ts

Lines changed: 205 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,205 @@
1+
import { describe, expect, test, beforeEach, mock } from 'bun:test'
2+
import { checkStartupPermissions } from './startup-checks'
3+
4+
// Mock the fs promises module
5+
const mockStat = mock()
6+
const mockWriteFile = mock()
7+
const mockReadFile = mock()
8+
const mockUnlink = mock()
9+
10+
mock.module('fs', () => ({
11+
promises: {
12+
stat: mockStat,
13+
writeFile: mockWriteFile,
14+
readFile: mockReadFile,
15+
unlink: mockUnlink,
16+
},
17+
}))
18+
19+
describe('checkStartupPermissions', () => {
20+
beforeEach(() => {
21+
// Reset all mocks before each test
22+
mockStat.mockReset()
23+
mockWriteFile.mockReset()
24+
mockReadFile.mockReset()
25+
mockUnlink.mockReset()
26+
})
27+
28+
test('should return success when directory exists and has proper permissions', async () => {
29+
// Mock successful directory stat
30+
mockStat.mockResolvedValue({
31+
isDirectory: () => true,
32+
})
33+
34+
// Mock successful file operations
35+
mockWriteFile.mockResolvedValue(undefined)
36+
mockReadFile.mockResolvedValue('permission-test')
37+
mockUnlink.mockResolvedValue(undefined)
38+
39+
const result = await checkStartupPermissions()
40+
41+
expect(result).toEqual({ success: true })
42+
expect(mockStat).toHaveBeenCalledWith('data')
43+
expect(mockWriteFile).toHaveBeenCalledWith('data/.habittrove-permission-test', 'permission-test')
44+
expect(mockReadFile).toHaveBeenCalledWith('data/.habittrove-permission-test', 'utf8')
45+
expect(mockUnlink).toHaveBeenCalledWith('data/.habittrove-permission-test')
46+
})
47+
48+
test('should return error when directory does not exist', async () => {
49+
mockStat.mockRejectedValue(new Error('ENOENT: no such file or directory'))
50+
51+
const result = await checkStartupPermissions()
52+
53+
expect(result).toEqual({
54+
success: false,
55+
error: {
56+
path: 'data',
57+
message: 'Data directory \'data\' does not exist or is not accessible. Check volume mounts and permissions.',
58+
type: 'writable_data_dir'
59+
}
60+
})
61+
expect(mockStat).toHaveBeenCalledWith('data')
62+
expect(mockWriteFile).not.toHaveBeenCalled()
63+
})
64+
65+
test('should return error when path exists but is not a directory', async () => {
66+
// Mock path exists but is a file, not directory
67+
mockStat.mockResolvedValue({
68+
isDirectory: () => false,
69+
})
70+
71+
const result = await checkStartupPermissions()
72+
73+
expect(result).toEqual({
74+
success: false,
75+
error: {
76+
path: 'data',
77+
message: 'Path \'data\' exists but is not a directory. Please ensure the data directory is properly configured.',
78+
type: 'writable_data_dir'
79+
}
80+
})
81+
expect(mockStat).toHaveBeenCalledWith('data')
82+
expect(mockWriteFile).not.toHaveBeenCalled()
83+
})
84+
85+
test('should return error when write permission fails', async () => {
86+
// Mock successful directory stat
87+
mockStat.mockResolvedValue({
88+
isDirectory: () => true,
89+
})
90+
91+
// Mock write failure
92+
mockWriteFile.mockRejectedValue(new Error('EACCES: permission denied'))
93+
94+
const result = await checkStartupPermissions()
95+
96+
expect(result).toEqual({
97+
success: false,
98+
error: {
99+
path: 'data',
100+
message: 'Insufficient read/write permissions for data directory \'data\'. Check file permissions and ownership.',
101+
type: 'writable_data_dir'
102+
}
103+
})
104+
expect(mockStat).toHaveBeenCalledWith('data')
105+
expect(mockWriteFile).toHaveBeenCalledWith('data/.habittrove-permission-test', 'permission-test')
106+
expect(mockReadFile).not.toHaveBeenCalled()
107+
})
108+
109+
test('should return error when read permission fails', async () => {
110+
// Mock successful directory stat and write
111+
mockStat.mockResolvedValue({
112+
isDirectory: () => true,
113+
})
114+
mockWriteFile.mockResolvedValue(undefined)
115+
116+
// Mock read failure
117+
mockReadFile.mockRejectedValue(new Error('EACCES: permission denied'))
118+
119+
const result = await checkStartupPermissions()
120+
121+
expect(result).toEqual({
122+
success: false,
123+
error: {
124+
path: 'data',
125+
message: 'Insufficient read/write permissions for data directory \'data\'. Check file permissions and ownership.',
126+
type: 'writable_data_dir'
127+
}
128+
})
129+
expect(mockStat).toHaveBeenCalledWith('data')
130+
expect(mockWriteFile).toHaveBeenCalledWith('data/.habittrove-permission-test', 'permission-test')
131+
expect(mockReadFile).toHaveBeenCalledWith('data/.habittrove-permission-test', 'utf8')
132+
})
133+
134+
test('should return error when read content does not match written content', async () => {
135+
// Mock successful directory stat and write
136+
mockStat.mockResolvedValue({
137+
isDirectory: () => true,
138+
})
139+
mockWriteFile.mockResolvedValue(undefined)
140+
141+
// Mock read with different content
142+
mockReadFile.mockResolvedValue('different-content')
143+
144+
const result = await checkStartupPermissions()
145+
146+
expect(result).toEqual({
147+
success: false,
148+
error: {
149+
path: 'data',
150+
message: 'Data integrity check failed in \'data\'. File system may be corrupted or have inconsistent behavior.',
151+
type: 'writable_data_dir'
152+
}
153+
})
154+
expect(mockStat).toHaveBeenCalledWith('data')
155+
expect(mockWriteFile).toHaveBeenCalledWith('data/.habittrove-permission-test', 'permission-test')
156+
expect(mockReadFile).toHaveBeenCalledWith('data/.habittrove-permission-test', 'utf8')
157+
expect(mockUnlink).not.toHaveBeenCalled()
158+
})
159+
160+
test('should return error when cleanup (unlink) fails', async () => {
161+
// Mock successful directory stat, write, and read
162+
mockStat.mockResolvedValue({
163+
isDirectory: () => true,
164+
})
165+
mockWriteFile.mockResolvedValue(undefined)
166+
mockReadFile.mockResolvedValue('permission-test')
167+
168+
// Mock cleanup failure
169+
mockUnlink.mockRejectedValue(new Error('EACCES: permission denied'))
170+
171+
const result = await checkStartupPermissions()
172+
173+
// Should return error since cleanup failed and is part of the try-catch block
174+
expect(result).toEqual({
175+
success: false,
176+
error: {
177+
path: 'data',
178+
message: 'Insufficient read/write permissions for data directory \'data\'. Check file permissions and ownership.',
179+
type: 'writable_data_dir'
180+
}
181+
})
182+
expect(mockStat).toHaveBeenCalledWith('data')
183+
expect(mockWriteFile).toHaveBeenCalledWith('data/.habittrove-permission-test', 'permission-test')
184+
expect(mockReadFile).toHaveBeenCalledWith('data/.habittrove-permission-test', 'utf8')
185+
expect(mockUnlink).toHaveBeenCalledWith('data/.habittrove-permission-test')
186+
})
187+
188+
test('should use correct file paths', async () => {
189+
// Mock successful operations
190+
mockStat.mockResolvedValue({
191+
isDirectory: () => true,
192+
})
193+
mockWriteFile.mockResolvedValue(undefined)
194+
mockReadFile.mockResolvedValue('permission-test')
195+
mockUnlink.mockResolvedValue(undefined)
196+
197+
await checkStartupPermissions()
198+
199+
// Verify the correct paths are used
200+
expect(mockStat).toHaveBeenCalledWith('data')
201+
expect(mockWriteFile).toHaveBeenCalledWith('data/.habittrove-permission-test', 'permission-test')
202+
expect(mockReadFile).toHaveBeenCalledWith('data/.habittrove-permission-test', 'utf8')
203+
expect(mockUnlink).toHaveBeenCalledWith('data/.habittrove-permission-test')
204+
})
205+
})

0 commit comments

Comments
 (0)