caprover-api 0.0.21 → 0.0.23
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/api/ApiManager.js +10 -3
- package/dist/api/HttpClient.js +10 -1
- package/package.json +1 -1
- package/src/api/ApiManager.ts +22 -9
- package/src/api/HttpClient.ts +11 -4
- package/test/ApiManager.test.js +45 -0
- package/test/HttpClient.test.js +87 -0
- package/test/NativeFormData.test.js +58 -0
package/dist/api/ApiManager.js
CHANGED
|
@@ -78,12 +78,19 @@ class ApiManager {
|
|
|
78
78
|
}
|
|
79
79
|
saveTheme(oldName, theme) {
|
|
80
80
|
const http = this.http;
|
|
81
|
-
|
|
82
|
-
.then(http.fetch(http.POST, '/user/system/themes/update', {
|
|
81
|
+
const payload = {
|
|
83
82
|
oldName,
|
|
84
83
|
name: theme.name,
|
|
85
84
|
content: theme.content,
|
|
86
|
-
}
|
|
85
|
+
};
|
|
86
|
+
if (theme.extra !== undefined) {
|
|
87
|
+
payload.extra = theme.extra;
|
|
88
|
+
}
|
|
89
|
+
if (theme.headEmbed !== undefined) {
|
|
90
|
+
payload.headEmbed = theme.headEmbed;
|
|
91
|
+
}
|
|
92
|
+
return Promise.resolve() //
|
|
93
|
+
.then(http.fetch(http.POST, '/user/system/themes/update', payload));
|
|
87
94
|
}
|
|
88
95
|
deleteTheme(themeName) {
|
|
89
96
|
const http = this.http;
|
package/dist/api/HttpClient.js
CHANGED
|
@@ -47,7 +47,16 @@ let NAMESPACE = 'x-namespace';
|
|
|
47
47
|
let CAPTAIN = 'captain';
|
|
48
48
|
class CrossFetchEngine {
|
|
49
49
|
static async post(url, variables, headers, bodyType) {
|
|
50
|
-
const
|
|
50
|
+
const request = createPostRequestInit(variables, headers, bodyType);
|
|
51
|
+
// node-fetch v2 (used by cross-fetch) stringifies native FormData.
|
|
52
|
+
// Keep native FormData paired with the runtime's native fetch encoder.
|
|
53
|
+
const isNativeFormData = bodyType === 'form-data' &&
|
|
54
|
+
typeof globalThis.FormData !== 'undefined' &&
|
|
55
|
+
variables instanceof globalThis.FormData &&
|
|
56
|
+
typeof globalThis.fetch === 'function';
|
|
57
|
+
const res = isNativeFormData
|
|
58
|
+
? await globalThis.fetch(url, request)
|
|
59
|
+
: await (0, cross_fetch_1.default)(url, request);
|
|
51
60
|
if (!res.ok) {
|
|
52
61
|
const errBody = await res.text();
|
|
53
62
|
throw new Error(`HTTP ${res.status}: ${errBody}`);
|
package/package.json
CHANGED
package/src/api/ApiManager.ts
CHANGED
|
@@ -145,15 +145,28 @@ export default class ApiManager {
|
|
|
145
145
|
|
|
146
146
|
saveTheme(oldName: string, theme: CapRoverTheme): Promise<{}> {
|
|
147
147
|
const http = this.http
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
148
|
+
const payload: {
|
|
149
|
+
oldName: string
|
|
150
|
+
name: string
|
|
151
|
+
content: string
|
|
152
|
+
extra?: string
|
|
153
|
+
headEmbed?: string
|
|
154
|
+
} = {
|
|
155
|
+
oldName,
|
|
156
|
+
name: theme.name,
|
|
157
|
+
content: theme.content,
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
if (theme.extra !== undefined) {
|
|
161
|
+
payload.extra = theme.extra
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
if (theme.headEmbed !== undefined) {
|
|
165
|
+
payload.headEmbed = theme.headEmbed
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
return Promise.resolve() //
|
|
169
|
+
.then(http.fetch(http.POST, '/user/system/themes/update', payload))
|
|
157
170
|
}
|
|
158
171
|
|
|
159
172
|
deleteTheme(themeName: string): Promise<{}> {
|
package/src/api/HttpClient.ts
CHANGED
|
@@ -64,10 +64,17 @@ class CrossFetchEngine {
|
|
|
64
64
|
headers: Record<string, string>,
|
|
65
65
|
bodyType: RequestBodyType
|
|
66
66
|
): Promise<any> {
|
|
67
|
-
const
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
67
|
+
const request = createPostRequestInit(variables, headers, bodyType)
|
|
68
|
+
// node-fetch v2 (used by cross-fetch) stringifies native FormData.
|
|
69
|
+
// Keep native FormData paired with the runtime's native fetch encoder.
|
|
70
|
+
const isNativeFormData =
|
|
71
|
+
bodyType === 'form-data' &&
|
|
72
|
+
typeof globalThis.FormData !== 'undefined' &&
|
|
73
|
+
variables instanceof globalThis.FormData &&
|
|
74
|
+
typeof globalThis.fetch === 'function'
|
|
75
|
+
const res = isNativeFormData
|
|
76
|
+
? await globalThis.fetch(url, request)
|
|
77
|
+
: await fetch(url, request)
|
|
71
78
|
|
|
72
79
|
if (!res.ok) {
|
|
73
80
|
const errBody = await res.text()
|
package/test/ApiManager.test.js
CHANGED
|
@@ -101,3 +101,48 @@ test('executeGenericApiCommand accepts PATCH', async () => {
|
|
|
101
101
|
|
|
102
102
|
assert.equal(requests[0].method, 'PATCH')
|
|
103
103
|
})
|
|
104
|
+
|
|
105
|
+
test('saveTheme sends supplied writable theme fields', async () => {
|
|
106
|
+
const { api, requests } = createApiWithRequestRecorder()
|
|
107
|
+
|
|
108
|
+
await api.saveTheme('old-theme', {
|
|
109
|
+
name: 'new-theme',
|
|
110
|
+
content: 'theme-content',
|
|
111
|
+
extra: '{"siderTheme":"dark"}',
|
|
112
|
+
headEmbed: '<meta name="theme-marker" content="test">',
|
|
113
|
+
builtIn: true,
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
assert.deepEqual(requests, [
|
|
117
|
+
{
|
|
118
|
+
method: 'POST',
|
|
119
|
+
endpoint: '/user/system/themes/update',
|
|
120
|
+
data: {
|
|
121
|
+
oldName: 'old-theme',
|
|
122
|
+
name: 'new-theme',
|
|
123
|
+
content: 'theme-content',
|
|
124
|
+
extra: '{"siderTheme":"dark"}',
|
|
125
|
+
headEmbed: '<meta name="theme-marker" content="test">',
|
|
126
|
+
},
|
|
127
|
+
bodyType: 'json',
|
|
128
|
+
},
|
|
129
|
+
])
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
test('saveTheme omits unspecified optional theme fields', async () => {
|
|
133
|
+
const { api, requests } = createApiWithRequestRecorder()
|
|
134
|
+
|
|
135
|
+
await api.saveTheme('old-theme', {
|
|
136
|
+
name: 'new-theme',
|
|
137
|
+
content: 'theme-content',
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
assert.deepEqual(requests[0].data, {
|
|
141
|
+
oldName: 'old-theme',
|
|
142
|
+
name: 'new-theme',
|
|
143
|
+
content: 'theme-content',
|
|
144
|
+
})
|
|
145
|
+
assert.equal('extra' in requests[0].data, false)
|
|
146
|
+
assert.equal('headEmbed' in requests[0].data, false)
|
|
147
|
+
assert.equal(JSON.stringify(requests[0].data).includes('undefined'), false)
|
|
148
|
+
})
|
package/test/HttpClient.test.js
CHANGED
|
@@ -74,3 +74,90 @@ test('authentication retry preserves the form-data body type', async () => {
|
|
|
74
74
|
assert.equal(loginRequests, 1)
|
|
75
75
|
assert.deepEqual(bodyTypes, ['form-data', 'form-data'])
|
|
76
76
|
})
|
|
77
|
+
|
|
78
|
+
for (const cachedToken of ['', 'stale-token']) {
|
|
79
|
+
test(`automatic login retries once with cached token ${JSON.stringify(cachedToken)}`, async () => {
|
|
80
|
+
let token = cachedToken
|
|
81
|
+
let logins = 0
|
|
82
|
+
const seen = []
|
|
83
|
+
const client = new HttpClient(
|
|
84
|
+
'https://captain.example.com',
|
|
85
|
+
async () => token,
|
|
86
|
+
async () => {
|
|
87
|
+
logins++
|
|
88
|
+
token = 'fresh-token'
|
|
89
|
+
}
|
|
90
|
+
)
|
|
91
|
+
client.fetchInternal = async () => {
|
|
92
|
+
const headers = await client.createHeaders()
|
|
93
|
+
seen.push(headers['x-captain-auth'])
|
|
94
|
+
return seen.length === 1
|
|
95
|
+
? { status: ErrorFactory.STATUS_AUTH_TOKEN_INVALID }
|
|
96
|
+
: { status: ErrorFactory.OKAY, data: { success: true } }
|
|
97
|
+
}
|
|
98
|
+
assert.deepEqual(await client.fetch(client.GET, '/user/apps', {})(), {
|
|
99
|
+
success: true,
|
|
100
|
+
})
|
|
101
|
+
assert.equal(logins, 1)
|
|
102
|
+
assert.deepEqual(seen, [cachedToken || undefined, 'fresh-token'])
|
|
103
|
+
})
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
test('repeated authorization failure is propagated after one retry', async () => {
|
|
107
|
+
let requests = 0
|
|
108
|
+
let logins = 0
|
|
109
|
+
const client = new HttpClient(
|
|
110
|
+
'https://captain.example.com',
|
|
111
|
+
async () => '',
|
|
112
|
+
async () => {
|
|
113
|
+
logins++
|
|
114
|
+
}
|
|
115
|
+
)
|
|
116
|
+
client.fetchInternal = async () => {
|
|
117
|
+
requests++
|
|
118
|
+
return {
|
|
119
|
+
status: ErrorFactory.STATUS_AUTH_TOKEN_INVALID,
|
|
120
|
+
description: 'still invalid',
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
await assert.rejects(
|
|
124
|
+
client.fetch(client.GET, '/user/apps', {})(),
|
|
125
|
+
(error) => {
|
|
126
|
+
assert.equal(
|
|
127
|
+
error.captainStatus,
|
|
128
|
+
ErrorFactory.STATUS_AUTH_TOKEN_INVALID
|
|
129
|
+
)
|
|
130
|
+
assert.equal(error.captainMessage, 'still invalid')
|
|
131
|
+
return true
|
|
132
|
+
}
|
|
133
|
+
)
|
|
134
|
+
assert.equal(requests, 2)
|
|
135
|
+
assert.equal(logins, 1)
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
test('ordinary server errors preserve status and message without retrying', async () => {
|
|
139
|
+
let requests = 0
|
|
140
|
+
const client = new HttpClient(
|
|
141
|
+
'https://captain.example.com',
|
|
142
|
+
async () => 'valid',
|
|
143
|
+
async () => {
|
|
144
|
+
assert.fail('unexpected login')
|
|
145
|
+
}
|
|
146
|
+
)
|
|
147
|
+
client.fetchInternal = async () => {
|
|
148
|
+
requests++
|
|
149
|
+
return {
|
|
150
|
+
status: ErrorFactory.ILLEGAL_PARAMETER,
|
|
151
|
+
description: 'invalid input',
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
await assert.rejects(
|
|
155
|
+
client.fetch(client.POST, '/user/apps', {})(),
|
|
156
|
+
(error) => {
|
|
157
|
+
assert.equal(error.captainStatus, ErrorFactory.ILLEGAL_PARAMETER)
|
|
158
|
+
assert.equal(error.captainMessage, 'invalid input')
|
|
159
|
+
return true
|
|
160
|
+
}
|
|
161
|
+
)
|
|
162
|
+
assert.equal(requests, 1)
|
|
163
|
+
})
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
const assert = require('node:assert/strict')
|
|
2
|
+
const { createServer } = require('node:http')
|
|
3
|
+
const { once } = require('node:events')
|
|
4
|
+
const test = require('node:test')
|
|
5
|
+
const {
|
|
6
|
+
default: ApiManager,
|
|
7
|
+
SimpleAuthenticationProvider,
|
|
8
|
+
} = require('../dist/api/ApiManager')
|
|
9
|
+
|
|
10
|
+
test('uploadAppData sends a real multipart file through the Node transport', async () => {
|
|
11
|
+
const requests = []
|
|
12
|
+
const server = createServer(async (req, res) => {
|
|
13
|
+
const chunks = []
|
|
14
|
+
for await (const chunk of req) chunks.push(chunk)
|
|
15
|
+
requests.push({
|
|
16
|
+
url: req.url,
|
|
17
|
+
headers: req.headers,
|
|
18
|
+
body: Buffer.concat(chunks),
|
|
19
|
+
})
|
|
20
|
+
res.setHeader('Content-Type', 'application/json')
|
|
21
|
+
res.end(JSON.stringify({ status: 100, data: {} }))
|
|
22
|
+
})
|
|
23
|
+
server.listen(0, '127.0.0.1')
|
|
24
|
+
await once(server, 'listening')
|
|
25
|
+
const provider = new SimpleAuthenticationProvider(async () => ({
|
|
26
|
+
password: 'unused',
|
|
27
|
+
}))
|
|
28
|
+
provider.onAuthTokenUpdated('test-token')
|
|
29
|
+
const api = new ApiManager(
|
|
30
|
+
`http://127.0.0.1:${server.address().port}`,
|
|
31
|
+
provider
|
|
32
|
+
)
|
|
33
|
+
try {
|
|
34
|
+
const payload = new Uint8Array([0, 255, 128, 10, 42])
|
|
35
|
+
await api.uploadAppData(
|
|
36
|
+
'fixture',
|
|
37
|
+
new File([payload], 'fixture.tar', { type: 'application/x-tar' }),
|
|
38
|
+
false
|
|
39
|
+
)
|
|
40
|
+
assert.equal(requests.length, 1)
|
|
41
|
+
const request = requests[0]
|
|
42
|
+
assert.equal(request.url, '/api/v2/user/apps/appData/fixture')
|
|
43
|
+
assert.equal(request.headers['x-captain-auth'], 'test-token')
|
|
44
|
+
assert.match(
|
|
45
|
+
request.headers['content-type'],
|
|
46
|
+
/^multipart\/form-data; boundary=/
|
|
47
|
+
)
|
|
48
|
+
assert.match(
|
|
49
|
+
request.body.toString(),
|
|
50
|
+
/name="sourceFile"; filename="fixture.tar"/
|
|
51
|
+
)
|
|
52
|
+
assert.ok(request.body.includes(Buffer.from(payload)))
|
|
53
|
+
} finally {
|
|
54
|
+
api.destroy()
|
|
55
|
+
server.closeAllConnections()
|
|
56
|
+
await new Promise((resolve) => server.close(resolve))
|
|
57
|
+
}
|
|
58
|
+
})
|