quickbase-js 0.1.0-beta.7 → 0.1.0-beta.9
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/CHANGELOG.md +55 -0
- package/README.md +46 -130
- package/dist/esm/quickbase.d.ts +1292 -125
- package/dist/esm/quickbase.js +666 -190
- package/dist/esm/quickbase.js.map +1 -1
- package/dist/esm/quickbase.min.js +1 -1
- package/dist/esm/quickbase.min.js.map +1 -1
- package/dist/umd/quickbase.umd.js +674 -189
- package/dist/umd/quickbase.umd.js.map +1 -1
- package/dist/umd/quickbase.umd.min.js +1 -1
- package/dist/umd/quickbase.umd.min.js.map +1 -1
- package/open-api/fix-spec-main.ts +69 -68
- package/open-api/generate-override-report.ts +214 -0
- package/open-api/generate-unified-interface.ts +162 -103
- package/open-api/output/override-report.html +11 -0
- package/open-api/output/override-report.json +14395 -58
- package/open-api/output/quickbase-fixed.json +583 -2
- package/open-api/schema/tags/fields.ts +331 -16
- package/open-api/schema/tags/index.ts +2 -4
- package/open-api/types/spec.ts +2 -1
- package/open-api/utils/generateJsDoc.ts +96 -0
- package/package.json +33 -33
- package/src/RateLimitError.ts +12 -0
- package/src/ThrottleBucket.ts +41 -0
- package/src/authorizationStrategy.ts +246 -0
- package/src/generated-unified/QuickbaseClient.ts +809 -119
- package/src/invokeMethod.ts +242 -0
- package/src/quickbaseClient.ts +117 -248
- package/src/rateLimiter.ts +25 -0
- package/src/tokenCache.ts +10 -26
- package/tests/setup.ts +2 -0
- package/tests/vitest/qb/apps/getApp.test.ts +6 -2
- package/tests/vitest/qb/apps/getAppEvents.test.ts +67 -0
- package/tests/vitest/qb/apps/updateApp.test.ts +58 -0
- package/tests/vitest/qb/auth/mockQuickbaseServer.ts +60 -0
- package/tests/vitest/qb/auth/retryUserToken401.test.ts +73 -0
- package/tests/vitest/qb/auth/ssoTokenIntegrationWithServer.test.ts +65 -0
- package/tests/vitest/qb/fields/createField.test.ts +1 -2
- package/tests/vitest/qb/fields/deleteFields.test.ts +10 -16
- package/tests/vitest/qb/fields/getFieldUsage.test.ts +58 -150
- package/tests/vitest/qb/fields/updateField.test.ts +93 -0
- package/tests/vitest/qb/formulas/runFormula.test.ts +96 -0
- package/tests/vitest/qb/index.ts +1 -0
- package/tests/vitest/qb/tables/createRelationship.test.ts +102 -0
- package/tests/vitest/qb/tables/deleteRelationship.test.ts +143 -0
- package/tests/vitest/qb/tables/updateRelationship.test.ts +250 -0
- package/tests/vitest/unit/ThrottleBucket.test.ts +95 -0
- package/tests/vitest/unit/apps/copyApp.test.ts +133 -40
- package/tests/vitest/unit/apps/getAppEvents.test.ts +193 -0
- package/tests/vitest/unit/apps/updateApp.test.ts +412 -0
- package/tests/vitest/unit/auth/fetchTempToken401.test.ts +7 -26
- package/tests/vitest/unit/auth/getTempToken.test.ts +42 -18
- package/tests/vitest/unit/auth/retryTempToken401.test.ts +95 -0
- package/tests/vitest/unit/auth/retryTempTokenMaxRetries.test.ts +235 -0
- package/tests/vitest/unit/auth/retryUserToken401.test.ts +69 -0
- package/tests/vitest/unit/auth/ssoTokenRefresh.test.ts +325 -0
- package/tests/vitest/unit/fields/createField.test.ts +180 -120
- package/tests/vitest/unit/fields/getFieldUsage.test.ts +47 -14
- package/tests/vitest/unit/fields/updateField.test.ts +285 -0
- package/tests/vitest/unit/formulas/runFormula.test.ts +205 -0
- package/tests/vitest/unit/index.ts +2 -1
- package/tests/vitest/unit/inferHttpMethod.test.ts +131 -0
- package/tests/vitest/unit/rateLimit.test.ts +191 -0
- package/tests/vitest/unit/tables/createRelationship.test.ts +212 -0
- package/tests/vitest/unit/tables/deleteRelationship.test.ts +97 -0
- package/tests/vitest/unit/tables/updateRelationship.test.ts +307 -0
- package/tests/vitest/unit/tokenLifespan.test.ts +204 -0
- package/tools/log-gen.ts +20 -7
- package/tools/log-gen.yaml +3446 -1848
- package/tsconfig.json +5 -2
- package/vitest.config.ts +2 -2
- package/notes.txt +0 -158
- package/tests/vitest/unit/auth/retryOn401.test.ts +0 -116
- package/tests/vitest/unit/auth/two401s.test.ts +0 -86
- package/tools/generate-types.ts +0 -29
- package/tools/openapi-schema-validator.ts +0 -38
- package/tools/spec-analyzer.ts +0 -163
- package/tools/split-spec.ts +0 -148
- package/tools/test-import.ts +0 -3
- package/tools/validate-spec.ts +0 -40
- /package/tests/playwright/qb/auth/{tokenPrefetch.test.ts → tempTokenPrefetch.test.ts} +0 -0
- /package/tests/playwright/qb/auth/{tokenRenewal.test.ts → tempTokenRenewal.test.ts} +0 -0
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, vi } from "vitest";
|
|
2
|
+
import { createClient, mockFetch } from "@tests/setup.ts";
|
|
3
|
+
|
|
4
|
+
describe("QuickbaseClient - Temp Token Retry Limit", () => {
|
|
5
|
+
let client: ReturnType<typeof createClient>;
|
|
6
|
+
|
|
7
|
+
beforeEach(() => {
|
|
8
|
+
mockFetch.mockClear();
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
it("exhausts retries after maxRetries (3) and throws an error", async () => {
|
|
12
|
+
client = createClient(mockFetch, {
|
|
13
|
+
useTempTokens: true,
|
|
14
|
+
debug: true,
|
|
15
|
+
maxRetries: 3, // Explicitly set maxRetries to 3
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
const mockDbid = "mockDbid123";
|
|
19
|
+
let callCount = 0;
|
|
20
|
+
|
|
21
|
+
mockFetch.mockImplementation((url) => {
|
|
22
|
+
callCount++;
|
|
23
|
+
// Initial temp token fetch
|
|
24
|
+
if (url.includes("auth/temporary") && callCount === 1) {
|
|
25
|
+
return Promise.resolve({
|
|
26
|
+
ok: true,
|
|
27
|
+
status: 200,
|
|
28
|
+
json: () => Promise.resolve({ temporaryAuthorization: "token_1" }),
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
// First attempt: 401
|
|
32
|
+
if (url.includes("fields") && callCount === 2) {
|
|
33
|
+
return Promise.resolve({
|
|
34
|
+
ok: false,
|
|
35
|
+
status: 401,
|
|
36
|
+
json: () => Promise.resolve({ message: "Unauthorized 1" }),
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
// Second temp token fetch
|
|
40
|
+
if (url.includes("auth/temporary") && callCount === 3) {
|
|
41
|
+
return Promise.resolve({
|
|
42
|
+
ok: true,
|
|
43
|
+
status: 200,
|
|
44
|
+
json: () => Promise.resolve({ temporaryAuthorization: "token_2" }),
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
// Second attempt: 401
|
|
48
|
+
if (url.includes("fields") && callCount === 4) {
|
|
49
|
+
return Promise.resolve({
|
|
50
|
+
ok: false,
|
|
51
|
+
status: 401,
|
|
52
|
+
json: () => Promise.resolve({ message: "Unauthorized 2" }),
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
// Third temp token fetch
|
|
56
|
+
if (url.includes("auth/temporary") && callCount === 5) {
|
|
57
|
+
return Promise.resolve({
|
|
58
|
+
ok: true,
|
|
59
|
+
status: 200,
|
|
60
|
+
json: () => Promise.resolve({ temporaryAuthorization: "token_3" }),
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
// Third attempt: 401
|
|
64
|
+
if (url.includes("fields") && callCount === 6) {
|
|
65
|
+
return Promise.resolve({
|
|
66
|
+
ok: false,
|
|
67
|
+
status: 401,
|
|
68
|
+
json: () => Promise.resolve({ message: "Unauthorized 3" }),
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
// Fourth temp token fetch
|
|
72
|
+
if (url.includes("auth/temporary") && callCount === 7) {
|
|
73
|
+
return Promise.resolve({
|
|
74
|
+
ok: true,
|
|
75
|
+
status: 200,
|
|
76
|
+
json: () => Promise.resolve({ temporaryAuthorization: "token_4" }),
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
// Fourth attempt: 401
|
|
80
|
+
if (url.includes("fields") && callCount === 8) {
|
|
81
|
+
return Promise.resolve({
|
|
82
|
+
ok: false,
|
|
83
|
+
status: 401,
|
|
84
|
+
json: () => Promise.resolve({ message: "Unauthorized 4" }),
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
return Promise.reject(new Error(`Unexpected fetch call: ${url}`));
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
const consoleSpy = vi.spyOn(console, "log");
|
|
91
|
+
|
|
92
|
+
await expect(client.getFields({ tableId: mockDbid })).rejects.toThrow(
|
|
93
|
+
"API Error: Unauthorized 4 (Status: 401)"
|
|
94
|
+
);
|
|
95
|
+
|
|
96
|
+
expect(mockFetch).toHaveBeenCalledTimes(8); // 4 token fetches + 4 API attempts
|
|
97
|
+
|
|
98
|
+
const tokenLogs = consoleSpy.mock.calls.filter((call) =>
|
|
99
|
+
call[0].includes("Fetched and cached new token")
|
|
100
|
+
);
|
|
101
|
+
expect(tokenLogs).toHaveLength(4);
|
|
102
|
+
expect(tokenLogs).toContainEqual([
|
|
103
|
+
"Fetched and cached new token for dbid: mockDbid123",
|
|
104
|
+
"token_1",
|
|
105
|
+
]);
|
|
106
|
+
expect(tokenLogs).toContainEqual([
|
|
107
|
+
"Fetched and cached new token for dbid: mockDbid123",
|
|
108
|
+
"token_2",
|
|
109
|
+
]);
|
|
110
|
+
expect(tokenLogs).toContainEqual([
|
|
111
|
+
"Fetched and cached new token for dbid: mockDbid123",
|
|
112
|
+
"token_3",
|
|
113
|
+
]);
|
|
114
|
+
expect(tokenLogs).toContainEqual([
|
|
115
|
+
"Fetched and cached new token for dbid: mockDbid123",
|
|
116
|
+
"token_4",
|
|
117
|
+
]);
|
|
118
|
+
|
|
119
|
+
expect(consoleSpy).toHaveBeenCalledWith(
|
|
120
|
+
"Authorization error for getFields (temp token), refreshing token:"
|
|
121
|
+
);
|
|
122
|
+
expect(consoleSpy).toHaveBeenCalledWith(
|
|
123
|
+
"[getFields] Retrying with token: token_2..."
|
|
124
|
+
);
|
|
125
|
+
expect(consoleSpy).toHaveBeenCalledWith(
|
|
126
|
+
"[getFields] Retrying with token: token_3..."
|
|
127
|
+
);
|
|
128
|
+
expect(consoleSpy).toHaveBeenCalledWith(
|
|
129
|
+
"[getFields] Retrying with token: token_4..."
|
|
130
|
+
);
|
|
131
|
+
|
|
132
|
+
consoleSpy.mockRestore();
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it("exhausts retries after maxRetries (2) and throws an error", async () => {
|
|
136
|
+
client = createClient(mockFetch, {
|
|
137
|
+
useTempTokens: true,
|
|
138
|
+
debug: true,
|
|
139
|
+
maxRetries: 2, // Explicitly set maxRetries to 2
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
const mockDbid = "mockDbid123";
|
|
143
|
+
let callCount = 0;
|
|
144
|
+
|
|
145
|
+
mockFetch.mockImplementation((url) => {
|
|
146
|
+
callCount++;
|
|
147
|
+
// Initial temp token fetch
|
|
148
|
+
if (url.includes("auth/temporary") && callCount === 1) {
|
|
149
|
+
return Promise.resolve({
|
|
150
|
+
ok: true,
|
|
151
|
+
status: 200,
|
|
152
|
+
json: () => Promise.resolve({ temporaryAuthorization: "token_1" }),
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
// First attempt: 401
|
|
156
|
+
if (url.includes("fields") && callCount === 2) {
|
|
157
|
+
return Promise.resolve({
|
|
158
|
+
ok: false,
|
|
159
|
+
status: 401,
|
|
160
|
+
json: () => Promise.resolve({ message: "Unauthorized 1" }),
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
// Second temp token fetch
|
|
164
|
+
if (url.includes("auth/temporary") && callCount === 3) {
|
|
165
|
+
return Promise.resolve({
|
|
166
|
+
ok: true,
|
|
167
|
+
status: 200,
|
|
168
|
+
json: () => Promise.resolve({ temporaryAuthorization: "token_2" }),
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
// Second attempt: 401
|
|
172
|
+
if (url.includes("fields") && callCount === 4) {
|
|
173
|
+
return Promise.resolve({
|
|
174
|
+
ok: false,
|
|
175
|
+
status: 401,
|
|
176
|
+
json: () => Promise.resolve({ message: "Unauthorized 2" }),
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
// Third temp token fetch
|
|
180
|
+
if (url.includes("auth/temporary") && callCount === 5) {
|
|
181
|
+
return Promise.resolve({
|
|
182
|
+
ok: true,
|
|
183
|
+
status: 200,
|
|
184
|
+
json: () => Promise.resolve({ temporaryAuthorization: "token_3" }),
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
// Third attempt: 401
|
|
188
|
+
if (url.includes("fields") && callCount === 6) {
|
|
189
|
+
return Promise.resolve({
|
|
190
|
+
ok: false,
|
|
191
|
+
status: 401,
|
|
192
|
+
json: () => Promise.resolve({ message: "Unauthorized 3" }),
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
return Promise.reject(new Error(`Unexpected fetch call: ${url}`));
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
const consoleSpy = vi.spyOn(console, "log");
|
|
199
|
+
|
|
200
|
+
await expect(client.getFields({ tableId: mockDbid })).rejects.toThrow(
|
|
201
|
+
"API Error: Unauthorized 3 (Status: 401)"
|
|
202
|
+
);
|
|
203
|
+
|
|
204
|
+
expect(mockFetch).toHaveBeenCalledTimes(6); // 3 token fetches + 3 API attempts
|
|
205
|
+
|
|
206
|
+
const tokenLogs = consoleSpy.mock.calls.filter((call) =>
|
|
207
|
+
call[0].includes("Fetched and cached new token")
|
|
208
|
+
);
|
|
209
|
+
expect(tokenLogs).toHaveLength(3);
|
|
210
|
+
expect(tokenLogs).toContainEqual([
|
|
211
|
+
"Fetched and cached new token for dbid: mockDbid123",
|
|
212
|
+
"token_1",
|
|
213
|
+
]);
|
|
214
|
+
expect(tokenLogs).toContainEqual([
|
|
215
|
+
"Fetched and cached new token for dbid: mockDbid123",
|
|
216
|
+
"token_2",
|
|
217
|
+
]);
|
|
218
|
+
expect(tokenLogs).toContainEqual([
|
|
219
|
+
"Fetched and cached new token for dbid: mockDbid123",
|
|
220
|
+
"token_3",
|
|
221
|
+
]);
|
|
222
|
+
|
|
223
|
+
expect(consoleSpy).toHaveBeenCalledWith(
|
|
224
|
+
"Authorization error for getFields (temp token), refreshing token:"
|
|
225
|
+
);
|
|
226
|
+
expect(consoleSpy).toHaveBeenCalledWith(
|
|
227
|
+
"[getFields] Retrying with token: token_2..."
|
|
228
|
+
);
|
|
229
|
+
expect(consoleSpy).toHaveBeenCalledWith(
|
|
230
|
+
"[getFields] Retrying with token: token_3..."
|
|
231
|
+
);
|
|
232
|
+
|
|
233
|
+
consoleSpy.mockRestore();
|
|
234
|
+
});
|
|
235
|
+
});
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
// tests/vitest/unit/auth/retryUserToken401.test.ts
|
|
2
|
+
|
|
3
|
+
import { describe, it, expect, vi } from "vitest";
|
|
4
|
+
import {
|
|
5
|
+
createClient,
|
|
6
|
+
mockFetch,
|
|
7
|
+
QB_REALM,
|
|
8
|
+
QB_TABLE_ID_1,
|
|
9
|
+
} from "@tests/setup.ts";
|
|
10
|
+
|
|
11
|
+
describe("QuickbaseClient Unit - User Token Retry on 401", () => {
|
|
12
|
+
it("retries with the same user token on 401 and succeeds", async () => {
|
|
13
|
+
const userToken = "b9f3pk_q4jd_0_b4qu5eebyvuix3xs57ysd7zn3";
|
|
14
|
+
mockFetch
|
|
15
|
+
.mockResolvedValueOnce(
|
|
16
|
+
new Response(JSON.stringify({ message: "Unauthorized" }), {
|
|
17
|
+
status: 401,
|
|
18
|
+
})
|
|
19
|
+
)
|
|
20
|
+
.mockResolvedValueOnce(
|
|
21
|
+
new Response(JSON.stringify([{ id: 1, label: "Field1" }]), {
|
|
22
|
+
status: 200,
|
|
23
|
+
})
|
|
24
|
+
);
|
|
25
|
+
|
|
26
|
+
const consoleSpy = vi.spyOn(console, "log");
|
|
27
|
+
const client = createClient(mockFetch, {
|
|
28
|
+
realm: QB_REALM,
|
|
29
|
+
userToken,
|
|
30
|
+
useTempTokens: false,
|
|
31
|
+
debug: true,
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
const result = await client.getFields({ tableId: QB_TABLE_ID_1 });
|
|
35
|
+
|
|
36
|
+
expect(result).toEqual([{ id: 1, label: "Field1" }]);
|
|
37
|
+
expect(mockFetch).toHaveBeenCalledTimes(2);
|
|
38
|
+
expect(mockFetch).toHaveBeenNthCalledWith(
|
|
39
|
+
1,
|
|
40
|
+
`https://api.quickbase.com/v1/fields?tableId=${QB_TABLE_ID_1}`,
|
|
41
|
+
expect.objectContaining({
|
|
42
|
+
method: "GET",
|
|
43
|
+
headers: expect.objectContaining({
|
|
44
|
+
Authorization: `QB-USER-TOKEN ${userToken}`,
|
|
45
|
+
"QB-Realm-Hostname": `${QB_REALM}.quickbase.com`,
|
|
46
|
+
"Content-Type": "application/json",
|
|
47
|
+
}),
|
|
48
|
+
})
|
|
49
|
+
);
|
|
50
|
+
expect(mockFetch).toHaveBeenNthCalledWith(
|
|
51
|
+
2,
|
|
52
|
+
`https://api.quickbase.com/v1/fields?tableId=${QB_TABLE_ID_1}`,
|
|
53
|
+
expect.objectContaining({
|
|
54
|
+
method: "GET",
|
|
55
|
+
headers: expect.objectContaining({
|
|
56
|
+
Authorization: `QB-USER-TOKEN ${userToken}`,
|
|
57
|
+
"QB-Realm-Hostname": `${QB_REALM}.quickbase.com`,
|
|
58
|
+
"Content-Type": "application/json",
|
|
59
|
+
}),
|
|
60
|
+
})
|
|
61
|
+
);
|
|
62
|
+
expect(consoleSpy).toHaveBeenCalledWith(
|
|
63
|
+
`Retrying getFields with existing user token: b9f3pk_q4j...`
|
|
64
|
+
);
|
|
65
|
+
expect(consoleSpy).toHaveBeenCalledWith(
|
|
66
|
+
"[getFields] Retrying with token: b9f3pk_q4j..."
|
|
67
|
+
);
|
|
68
|
+
});
|
|
69
|
+
});
|
|
@@ -0,0 +1,325 @@
|
|
|
1
|
+
// tests/vitest/unit/auth/ssoTokenRefresh.test.ts
|
|
2
|
+
|
|
3
|
+
import { describe, it, expect, beforeEach, vi } from "vitest";
|
|
4
|
+
import { createClient, mockFetch, QB_REALM, QB_APP_ID } from "@tests/setup.ts";
|
|
5
|
+
|
|
6
|
+
describe("QuickbaseClient Unit - SsoTokenStrategy", () => {
|
|
7
|
+
let client: ReturnType<typeof createClient>;
|
|
8
|
+
const mockSamlToken = "mock_saml_token_base64url";
|
|
9
|
+
const mockSsoToken = "example_token_1NiIsImtpZCI6IjllciJ9w";
|
|
10
|
+
|
|
11
|
+
beforeEach(() => {
|
|
12
|
+
mockFetch.mockClear();
|
|
13
|
+
client = createClient(mockFetch, {
|
|
14
|
+
useSso: true,
|
|
15
|
+
samlToken: mockSamlToken,
|
|
16
|
+
realm: QB_REALM,
|
|
17
|
+
debug: true,
|
|
18
|
+
});
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it("initializes without errors", () => {
|
|
22
|
+
expect(client).toBeDefined();
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it("has exchangeSsoToken method", () => {
|
|
26
|
+
expect(typeof client.exchangeSsoToken).toBe("function");
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("fetches SSO token on first call", async () => {
|
|
30
|
+
const mockResponse = {
|
|
31
|
+
access_token: mockSsoToken,
|
|
32
|
+
issued_token_type: "urn:quickbase:params:oauth:token-type:temp_token",
|
|
33
|
+
token_type: "N_A",
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
mockFetch.mockResolvedValueOnce(
|
|
37
|
+
new Response(JSON.stringify(mockResponse), {
|
|
38
|
+
status: 200,
|
|
39
|
+
headers: { "Content-Type": "application/json" },
|
|
40
|
+
})
|
|
41
|
+
);
|
|
42
|
+
|
|
43
|
+
mockFetch.mockResolvedValueOnce(
|
|
44
|
+
new Response(JSON.stringify({ id: QB_APP_ID, name: "Test App" }), {
|
|
45
|
+
status: 200,
|
|
46
|
+
headers: { "Content-Type": "application/json" },
|
|
47
|
+
})
|
|
48
|
+
);
|
|
49
|
+
|
|
50
|
+
const result = await client.getApp({ appId: QB_APP_ID });
|
|
51
|
+
|
|
52
|
+
expect(mockFetch).toHaveBeenCalledTimes(2);
|
|
53
|
+
expect(mockFetch).toHaveBeenNthCalledWith(
|
|
54
|
+
1,
|
|
55
|
+
"https://api.quickbase.com/v1/auth/oauth/token",
|
|
56
|
+
expect.objectContaining({
|
|
57
|
+
method: "POST",
|
|
58
|
+
headers: expect.objectContaining({
|
|
59
|
+
"QB-Realm-Hostname": `${QB_REALM}.quickbase.com`,
|
|
60
|
+
"Content-Type": "application/json",
|
|
61
|
+
}),
|
|
62
|
+
body: JSON.stringify({
|
|
63
|
+
grant_type: "urn:ietf:params:oauth:grant-type:token-exchange",
|
|
64
|
+
requested_token_type:
|
|
65
|
+
"urn:quickbase:params:oauth:token-type:temp_token",
|
|
66
|
+
subject_token: mockSamlToken,
|
|
67
|
+
subject_token_type: "urn:ietf:params:oauth:token-type:saml2",
|
|
68
|
+
}),
|
|
69
|
+
})
|
|
70
|
+
);
|
|
71
|
+
expect(mockFetch).toHaveBeenNthCalledWith(
|
|
72
|
+
2,
|
|
73
|
+
`https://api.quickbase.com/v1/apps/${QB_APP_ID}`,
|
|
74
|
+
expect.objectContaining({
|
|
75
|
+
headers: expect.objectContaining({
|
|
76
|
+
Authorization: `QB-TEMP-TOKEN ${mockSsoToken}`,
|
|
77
|
+
}),
|
|
78
|
+
})
|
|
79
|
+
);
|
|
80
|
+
expect(result).toEqual({ id: QB_APP_ID, name: "Test App" });
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("refreshes SSO token on 401 and retries", async () => {
|
|
84
|
+
const newToken = "new_token_abcdef123";
|
|
85
|
+
const appResponse = { id: QB_APP_ID, name: "Test App" };
|
|
86
|
+
|
|
87
|
+
mockFetch
|
|
88
|
+
.mockResolvedValueOnce(
|
|
89
|
+
new Response(
|
|
90
|
+
JSON.stringify({
|
|
91
|
+
access_token: mockSsoToken,
|
|
92
|
+
issued_token_type:
|
|
93
|
+
"urn:quickbase:params:oauth:token-type:temp_token",
|
|
94
|
+
token_type: "N_A",
|
|
95
|
+
}),
|
|
96
|
+
{ status: 200, headers: { "Content-Type": "application/json" } }
|
|
97
|
+
)
|
|
98
|
+
)
|
|
99
|
+
.mockResolvedValueOnce(
|
|
100
|
+
new Response(JSON.stringify({ message: "Unauthorized" }), {
|
|
101
|
+
status: 401,
|
|
102
|
+
headers: { "Content-Type": "application/json" },
|
|
103
|
+
})
|
|
104
|
+
)
|
|
105
|
+
.mockResolvedValueOnce(
|
|
106
|
+
new Response(
|
|
107
|
+
JSON.stringify({
|
|
108
|
+
access_token: newToken,
|
|
109
|
+
issued_token_type:
|
|
110
|
+
"urn:quickbase:params:oauth:token-type:temp_token",
|
|
111
|
+
token_type: "N_A",
|
|
112
|
+
}),
|
|
113
|
+
{ status: 200, headers: { "Content-Type": "application/json" } }
|
|
114
|
+
)
|
|
115
|
+
)
|
|
116
|
+
.mockResolvedValueOnce(
|
|
117
|
+
new Response(JSON.stringify(appResponse), {
|
|
118
|
+
status: 200,
|
|
119
|
+
headers: { "Content-Type": "application/json" },
|
|
120
|
+
})
|
|
121
|
+
);
|
|
122
|
+
|
|
123
|
+
const result = await client.getApp({ appId: QB_APP_ID });
|
|
124
|
+
|
|
125
|
+
expect(result).toEqual(appResponse);
|
|
126
|
+
expect(mockFetch).toHaveBeenCalledTimes(4);
|
|
127
|
+
expect(mockFetch).toHaveBeenNthCalledWith(
|
|
128
|
+
1,
|
|
129
|
+
"https://api.quickbase.com/v1/auth/oauth/token",
|
|
130
|
+
expect.any(Object)
|
|
131
|
+
);
|
|
132
|
+
expect(mockFetch).toHaveBeenNthCalledWith(
|
|
133
|
+
2,
|
|
134
|
+
`https://api.quickbase.com/v1/apps/${QB_APP_ID}`,
|
|
135
|
+
expect.objectContaining({
|
|
136
|
+
headers: expect.objectContaining({
|
|
137
|
+
Authorization: `QB-TEMP-TOKEN ${mockSsoToken}`,
|
|
138
|
+
}),
|
|
139
|
+
})
|
|
140
|
+
);
|
|
141
|
+
expect(mockFetch).toHaveBeenNthCalledWith(
|
|
142
|
+
3,
|
|
143
|
+
"https://api.quickbase.com/v1/auth/oauth/token",
|
|
144
|
+
expect.any(Object)
|
|
145
|
+
);
|
|
146
|
+
expect(mockFetch).toHaveBeenNthCalledWith(
|
|
147
|
+
4,
|
|
148
|
+
`https://api.quickbase.com/v1/apps/${QB_APP_ID}`,
|
|
149
|
+
expect.objectContaining({
|
|
150
|
+
headers: expect.objectContaining({
|
|
151
|
+
Authorization: `QB-TEMP-TOKEN ${newToken}`,
|
|
152
|
+
}),
|
|
153
|
+
})
|
|
154
|
+
);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it("handles API error on token exchange", async () => {
|
|
158
|
+
// Reset client to avoid interference from beforeEach
|
|
159
|
+
client = createClient(mockFetch, {
|
|
160
|
+
useSso: true,
|
|
161
|
+
samlToken: mockSamlToken,
|
|
162
|
+
realm: QB_REALM,
|
|
163
|
+
debug: true,
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
mockFetch
|
|
167
|
+
.mockResolvedValueOnce(
|
|
168
|
+
new Response(
|
|
169
|
+
JSON.stringify({
|
|
170
|
+
access_token: mockSsoToken,
|
|
171
|
+
issued_token_type:
|
|
172
|
+
"urn:quickbase:params:oauth:token-type:temp_token",
|
|
173
|
+
token_type: "N_A",
|
|
174
|
+
}),
|
|
175
|
+
{ status: 200, headers: { "Content-Type": "application/json" } }
|
|
176
|
+
)
|
|
177
|
+
)
|
|
178
|
+
.mockResolvedValueOnce(
|
|
179
|
+
new Response(JSON.stringify({ message: "Invalid SAML token" }), {
|
|
180
|
+
status: 400,
|
|
181
|
+
headers: { "Content-Type": "application/json" },
|
|
182
|
+
})
|
|
183
|
+
);
|
|
184
|
+
|
|
185
|
+
await expect(
|
|
186
|
+
client.exchangeSsoToken({
|
|
187
|
+
grant_type: "urn:ietf:params:oauth:grant-type:token-exchange",
|
|
188
|
+
requested_token_type:
|
|
189
|
+
"urn:quickbase:params:oauth:token-type:temp_token",
|
|
190
|
+
subject_token: mockSamlToken,
|
|
191
|
+
subject_token_type: "urn:ietf:params:oauth:token-type:saml2",
|
|
192
|
+
})
|
|
193
|
+
).rejects.toThrow("API Error: Invalid SAML token (Status: 400)");
|
|
194
|
+
|
|
195
|
+
expect(mockFetch).toHaveBeenCalledTimes(2);
|
|
196
|
+
expect(mockFetch).toHaveBeenNthCalledWith(
|
|
197
|
+
1,
|
|
198
|
+
"https://api.quickbase.com/v1/auth/oauth/token",
|
|
199
|
+
expect.any(Object)
|
|
200
|
+
);
|
|
201
|
+
expect(mockFetch).toHaveBeenNthCalledWith(
|
|
202
|
+
2,
|
|
203
|
+
"https://api.quickbase.com/v1/auth/oauth/token",
|
|
204
|
+
expect.any(Object)
|
|
205
|
+
);
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
it("exhausts retries on repeated 401s", async () => {
|
|
209
|
+
mockFetch
|
|
210
|
+
.mockResolvedValueOnce(
|
|
211
|
+
new Response(
|
|
212
|
+
JSON.stringify({
|
|
213
|
+
access_token: mockSsoToken,
|
|
214
|
+
issued_token_type:
|
|
215
|
+
"urn:quickbase:params:oauth:token-type:temp_token",
|
|
216
|
+
token_type: "N_A",
|
|
217
|
+
}),
|
|
218
|
+
{ status: 200, headers: { "Content-Type": "application/json" } }
|
|
219
|
+
)
|
|
220
|
+
)
|
|
221
|
+
.mockResolvedValueOnce(
|
|
222
|
+
new Response(JSON.stringify({ message: "Unauthorized" }), {
|
|
223
|
+
status: 401,
|
|
224
|
+
headers: { "Content-Type": "application/json" },
|
|
225
|
+
})
|
|
226
|
+
)
|
|
227
|
+
.mockResolvedValueOnce(
|
|
228
|
+
new Response(
|
|
229
|
+
JSON.stringify({
|
|
230
|
+
access_token: "retry_token_1",
|
|
231
|
+
issued_token_type:
|
|
232
|
+
"urn:quickbase:params:oauth:token-type:temp_token",
|
|
233
|
+
token_type: "N_A",
|
|
234
|
+
}),
|
|
235
|
+
{ status: 200, headers: { "Content-Type": "application/json" } }
|
|
236
|
+
)
|
|
237
|
+
)
|
|
238
|
+
.mockResolvedValueOnce(
|
|
239
|
+
new Response(JSON.stringify({ message: "Unauthorized" }), {
|
|
240
|
+
status: 401,
|
|
241
|
+
headers: { "Content-Type": "application/json" },
|
|
242
|
+
})
|
|
243
|
+
)
|
|
244
|
+
.mockResolvedValueOnce(
|
|
245
|
+
new Response(
|
|
246
|
+
JSON.stringify({
|
|
247
|
+
access_token: "retry_token_2",
|
|
248
|
+
issued_token_type:
|
|
249
|
+
"urn:quickbase:params:oauth:token-type:temp_token",
|
|
250
|
+
token_type: "N_A",
|
|
251
|
+
}),
|
|
252
|
+
{ status: 200, headers: { "Content-Type": "application/json" } }
|
|
253
|
+
)
|
|
254
|
+
)
|
|
255
|
+
.mockResolvedValueOnce(
|
|
256
|
+
new Response(JSON.stringify({ message: "Unauthorized" }), {
|
|
257
|
+
status: 401,
|
|
258
|
+
headers: { "Content-Type": "application/json" },
|
|
259
|
+
})
|
|
260
|
+
)
|
|
261
|
+
.mockResolvedValueOnce(
|
|
262
|
+
new Response(
|
|
263
|
+
JSON.stringify({
|
|
264
|
+
access_token: "retry_token_3",
|
|
265
|
+
issued_token_type:
|
|
266
|
+
"urn:quickbase:params:oauth:token-type:temp_token",
|
|
267
|
+
token_type: "N_A",
|
|
268
|
+
}),
|
|
269
|
+
{ status: 200, headers: { "Content-Type": "application/json" } }
|
|
270
|
+
)
|
|
271
|
+
)
|
|
272
|
+
.mockResolvedValueOnce(
|
|
273
|
+
new Response(JSON.stringify({ message: "Unauthorized" }), {
|
|
274
|
+
status: 401,
|
|
275
|
+
headers: { "Content-Type": "application/json" },
|
|
276
|
+
})
|
|
277
|
+
);
|
|
278
|
+
|
|
279
|
+
await expect(client.getApp({ appId: QB_APP_ID })).rejects.toThrow(
|
|
280
|
+
"API Error: Unauthorized (Status: 401)"
|
|
281
|
+
);
|
|
282
|
+
|
|
283
|
+
expect(mockFetch).toHaveBeenCalledTimes(8);
|
|
284
|
+
expect(mockFetch).toHaveBeenNthCalledWith(
|
|
285
|
+
1,
|
|
286
|
+
"https://api.quickbase.com/v1/auth/oauth/token",
|
|
287
|
+
expect.any(Object)
|
|
288
|
+
);
|
|
289
|
+
expect(mockFetch).toHaveBeenNthCalledWith(
|
|
290
|
+
2,
|
|
291
|
+
`https://api.quickbase.com/v1/apps/${QB_APP_ID}`,
|
|
292
|
+
expect.any(Object)
|
|
293
|
+
);
|
|
294
|
+
expect(mockFetch).toHaveBeenNthCalledWith(
|
|
295
|
+
3,
|
|
296
|
+
"https://api.quickbase.com/v1/auth/oauth/token",
|
|
297
|
+
expect.any(Object)
|
|
298
|
+
);
|
|
299
|
+
expect(mockFetch).toHaveBeenNthCalledWith(
|
|
300
|
+
4,
|
|
301
|
+
`https://api.quickbase.com/v1/apps/${QB_APP_ID}`,
|
|
302
|
+
expect.any(Object)
|
|
303
|
+
);
|
|
304
|
+
expect(mockFetch).toHaveBeenNthCalledWith(
|
|
305
|
+
5,
|
|
306
|
+
"https://api.quickbase.com/v1/auth/oauth/token",
|
|
307
|
+
expect.any(Object)
|
|
308
|
+
);
|
|
309
|
+
expect(mockFetch).toHaveBeenNthCalledWith(
|
|
310
|
+
6,
|
|
311
|
+
`https://api.quickbase.com/v1/apps/${QB_APP_ID}`,
|
|
312
|
+
expect.any(Object)
|
|
313
|
+
);
|
|
314
|
+
expect(mockFetch).toHaveBeenNthCalledWith(
|
|
315
|
+
7,
|
|
316
|
+
"https://api.quickbase.com/v1/auth/oauth/token",
|
|
317
|
+
expect.any(Object)
|
|
318
|
+
);
|
|
319
|
+
expect(mockFetch).toHaveBeenNthCalledWith(
|
|
320
|
+
8,
|
|
321
|
+
`https://api.quickbase.com/v1/apps/${QB_APP_ID}`,
|
|
322
|
+
expect.any(Object)
|
|
323
|
+
);
|
|
324
|
+
});
|
|
325
|
+
});
|