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.
@@ -78,12 +78,19 @@ class ApiManager {
78
78
  }
79
79
  saveTheme(oldName, theme) {
80
80
  const http = this.http;
81
- return Promise.resolve() //
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;
@@ -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 res = await (0, cross_fetch_1.default)(url, createPostRequestInit(variables, headers, bodyType));
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "caprover-api",
3
- "version": "0.0.21",
3
+ "version": "0.0.23",
4
4
  "description": "API client for CapRover",
5
5
  "types": "dist/index.d.ts",
6
6
  "main": "dist/index.js",
@@ -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
- return Promise.resolve() //
150
- .then(
151
- http.fetch(http.POST, '/user/system/themes/update', {
152
- oldName,
153
- name: theme.name,
154
- content: theme.content,
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<{}> {
@@ -64,10 +64,17 @@ class CrossFetchEngine {
64
64
  headers: Record<string, string>,
65
65
  bodyType: RequestBodyType
66
66
  ): Promise<any> {
67
- const res = await fetch(
68
- url,
69
- createPostRequestInit(variables, headers, bodyType)
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()
@@ -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
+ })
@@ -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
+ })