Skip to content

Commit c19d04a

Browse files
committed
fs update
1 parent e26f6d5 commit c19d04a

File tree

12 files changed

+821
-172
lines changed

12 files changed

+821
-172
lines changed

frontend/src/services/websocket-service.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ export class WebSocketService {
1010
private ws: WebSocket | null = null
1111
private listeners = new Set<WebSocketEventHandler>()
1212
private reconnectTimer: ReturnType<typeof setTimeout> | null = null
13+
private reconnectAttempt = 0
1314

1415
public connectionState = new ObservableValue<'connecting' | 'connected' | 'disconnected'>('disconnected')
1516

@@ -24,6 +25,7 @@ export class WebSocketService {
2425
this.ws = new WebSocket(url)
2526

2627
this.ws.onopen = () => {
28+
this.reconnectAttempt = 0
2729
this.connectionState.setValue('connected')
2830
}
2931

@@ -52,6 +54,7 @@ export class WebSocketService {
5254
clearTimeout(this.reconnectTimer)
5355
this.reconnectTimer = null
5456
}
57+
this.reconnectAttempt = 0
5558
this.ws?.close()
5659
this.ws = null
5760
this.connectionState.setValue('disconnected')
@@ -64,9 +67,11 @@ export class WebSocketService {
6467

6568
private scheduleReconnect() {
6669
if (this.reconnectTimer) return
70+
const delay = Math.min(1000 * 2 ** this.reconnectAttempt, 30_000)
71+
this.reconnectAttempt++
6772
this.reconnectTimer = setTimeout(() => {
6873
this.reconnectTimer = null
6974
this.connect()
70-
}, 3000)
75+
}, delay)
7176
}
7277
}
Lines changed: 213 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,213 @@
1+
import { addStore, InMemoryStore, useSystemIdentityContext } from '@furystack/core'
2+
import { Injector } from '@furystack/inject'
3+
import { useLogging, VerboseConsoleLogger } from '@furystack/logging'
4+
import { getRepository } from '@furystack/repository'
5+
import { GitHubRepository, Service, Stack } from 'common'
6+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
7+
8+
import { ServiceLifecycleAction } from './service-lifecycle-action.js'
9+
import { ProcessManager } from '../../../services/process-manager.js'
10+
import { GitService } from '../../../services/git-service.js'
11+
12+
const createMockActionContext = <TBody = unknown, TUrl = Record<string, string>>(options: {
13+
injector: Injector
14+
body?: TBody
15+
urlParams?: TUrl
16+
}) => ({
17+
injector: options.injector,
18+
getBody: () => Promise.resolve(options.body as TBody),
19+
getUrlParams: () => (options.urlParams ?? {}) as TUrl,
20+
getQuery: () => ({}) as never,
21+
request: {} as never,
22+
response: {} as never,
23+
})
24+
25+
describe('ServiceLifecycleAction', () => {
26+
let injector: Injector
27+
let mockPm: {
28+
startService: ReturnType<typeof vi.fn>
29+
stopService: ReturnType<typeof vi.fn>
30+
restartService: ReturnType<typeof vi.fn>
31+
installService: ReturnType<typeof vi.fn>
32+
buildService: ReturnType<typeof vi.fn>
33+
}
34+
35+
beforeEach(async () => {
36+
injector = new Injector()
37+
useLogging(injector, VerboseConsoleLogger)
38+
39+
addStore(injector, new InMemoryStore({ model: Service, primaryKey: 'id' }))
40+
addStore(injector, new InMemoryStore({ model: Stack, primaryKey: 'name' }))
41+
addStore(injector, new InMemoryStore({ model: GitHubRepository, primaryKey: 'id' }))
42+
43+
getRepository(injector).createDataSet(Service, 'id', {})
44+
getRepository(injector).createDataSet(Stack, 'name', {})
45+
getRepository(injector).createDataSet(GitHubRepository, 'id', {})
46+
47+
mockPm = {
48+
startService: vi.fn().mockResolvedValue(undefined),
49+
stopService: vi.fn().mockResolvedValue(undefined),
50+
restartService: vi.fn().mockResolvedValue(undefined),
51+
installService: vi.fn().mockResolvedValue(undefined),
52+
buildService: vi.fn().mockResolvedValue(undefined),
53+
}
54+
injector.setExplicitInstance(mockPm as unknown as ProcessManager, ProcessManager)
55+
56+
const mockGit = {
57+
clone: vi.fn().mockResolvedValue(undefined),
58+
pull: vi.fn().mockResolvedValue({ updated: true }),
59+
}
60+
injector.setExplicitInstance(mockGit as unknown as GitService, GitService)
61+
})
62+
63+
afterEach(async () => {
64+
await injector[Symbol.asyncDispose]()
65+
})
66+
67+
describe('start/stop/restart/install/build delegation', () => {
68+
it('should delegate "start" to ProcessManager.startService', async () => {
69+
const action = ServiceLifecycleAction('start')
70+
const ctx = createMockActionContext({ injector, urlParams: { id: 'svc-1' } })
71+
const result = await action(ctx)
72+
const body = result.chunk as { success: boolean; serviceId: string }
73+
74+
expect(body.success).toBe(true)
75+
expect(body.serviceId).toBe('svc-1')
76+
expect(mockPm.startService).toHaveBeenCalledWith('svc-1')
77+
})
78+
79+
it('should delegate "stop" to ProcessManager.stopService', async () => {
80+
const action = ServiceLifecycleAction('stop')
81+
await action(createMockActionContext({ injector, urlParams: { id: 'svc-2' } }))
82+
expect(mockPm.stopService).toHaveBeenCalledWith('svc-2')
83+
})
84+
85+
it('should delegate "restart" to ProcessManager.restartService', async () => {
86+
const action = ServiceLifecycleAction('restart')
87+
await action(createMockActionContext({ injector, urlParams: { id: 'svc-3' } }))
88+
expect(mockPm.restartService).toHaveBeenCalledWith('svc-3')
89+
})
90+
91+
it('should delegate "install" to ProcessManager.installService', async () => {
92+
const action = ServiceLifecycleAction('install')
93+
await action(createMockActionContext({ injector, urlParams: { id: 'svc-4' } }))
94+
expect(mockPm.installService).toHaveBeenCalledWith('svc-4')
95+
})
96+
97+
it('should delegate "build" to ProcessManager.buildService', async () => {
98+
const action = ServiceLifecycleAction('build')
99+
await action(createMockActionContext({ injector, urlParams: { id: 'svc-5' } }))
100+
expect(mockPm.buildService).toHaveBeenCalledWith('svc-5')
101+
})
102+
103+
it('should wrap ProcessManager errors into RequestError', async () => {
104+
mockPm.startService.mockRejectedValue(new Error('Service not found: svc-bad'))
105+
const action = ServiceLifecycleAction('start')
106+
await expect(action(createMockActionContext({ injector, urlParams: { id: 'svc-bad' } }))).rejects.toThrow(
107+
'Service not found: svc-bad',
108+
)
109+
})
110+
})
111+
112+
describe('pull action', () => {
113+
it('should throw 404 when service does not exist', async () => {
114+
const action = ServiceLifecycleAction('pull')
115+
await expect(action(createMockActionContext({ injector, urlParams: { id: 'nonexistent' } }))).rejects.toThrow(
116+
'Service not found',
117+
)
118+
})
119+
120+
it('should throw 400 when no repository is linked', async () => {
121+
const elevated = useSystemIdentityContext({ injector })
122+
const ts = new Date().toISOString()
123+
await getRepository(elevated)
124+
.getDataSetFor(Stack, 'name')
125+
.add(elevated, {
126+
name: 'test-stack',
127+
displayName: 'Test',
128+
description: '',
129+
mainDirectory: '/tmp/test',
130+
createdAt: ts,
131+
updatedAt: ts,
132+
})
133+
await getRepository(elevated)
134+
.getDataSetFor(Service, 'id')
135+
.add(elevated, {
136+
id: 'no-repo-svc',
137+
stackName: 'test-stack',
138+
displayName: 'No Repo',
139+
description: '',
140+
runCommand: 'echo hi',
141+
installStatus: 'not-installed',
142+
buildStatus: 'not-built',
143+
runStatus: 'stopped',
144+
autoFetchEnabled: false,
145+
autoFetchIntervalMinutes: 60,
146+
autoRestartOnFetch: false,
147+
dependencyIds: [],
148+
prerequisiteServiceIds: [],
149+
createdAt: ts,
150+
updatedAt: ts,
151+
})
152+
await elevated[Symbol.asyncDispose]()
153+
154+
const action = ServiceLifecycleAction('pull')
155+
await expect(action(createMockActionContext({ injector, urlParams: { id: 'no-repo-svc' } }))).rejects.toThrow(
156+
'No repository linked',
157+
)
158+
})
159+
160+
it('should reject path traversal attempts', async () => {
161+
const elevated = useSystemIdentityContext({ injector })
162+
const ts = new Date().toISOString()
163+
await getRepository(elevated)
164+
.getDataSetFor(Stack, 'name')
165+
.add(elevated, {
166+
name: 'traversal-stack',
167+
displayName: 'Traversal',
168+
description: '',
169+
mainDirectory: '/tmp/safe',
170+
createdAt: ts,
171+
updatedAt: ts,
172+
})
173+
await getRepository(elevated)
174+
.getDataSetFor(GitHubRepository, 'id')
175+
.add(elevated, {
176+
id: 'repo-1',
177+
stackName: 'traversal-stack',
178+
url: 'https://github.com/test/repo',
179+
displayName: 'Repo',
180+
description: '',
181+
createdAt: ts,
182+
updatedAt: ts,
183+
})
184+
await getRepository(elevated)
185+
.getDataSetFor(Service, 'id')
186+
.add(elevated, {
187+
id: 'traversal-svc',
188+
stackName: 'traversal-stack',
189+
displayName: 'Traversal',
190+
description: '',
191+
workingDirectory: '../../etc',
192+
repositoryId: 'repo-1',
193+
runCommand: 'echo hi',
194+
installStatus: 'not-installed',
195+
buildStatus: 'not-built',
196+
runStatus: 'stopped',
197+
autoFetchEnabled: false,
198+
autoFetchIntervalMinutes: 60,
199+
autoRestartOnFetch: false,
200+
dependencyIds: [],
201+
prerequisiteServiceIds: [],
202+
createdAt: ts,
203+
updatedAt: ts,
204+
})
205+
await elevated[Symbol.asyncDispose]()
206+
207+
const action = ServiceLifecycleAction('pull')
208+
await expect(
209+
action(createMockActionContext({ injector, urlParams: { id: 'traversal-svc' } })),
210+
).rejects.toThrow('outside the stack directory')
211+
})
212+
})
213+
})

0 commit comments

Comments
 (0)