quickbase-js 0.1.0-beta.10 → 0.1.0-beta.11
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 +9 -0
- package/README.md +49 -9
- package/dist/esm/quickbase.d.ts +1 -0
- package/dist/esm/quickbase.js +60 -64
- 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 +60 -64
- 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/examples/quicbase-jsCDN.html +2 -2
- package/package.json +1 -1
- package/src/authorizationStrategy.ts +62 -35
- package/src/generated-unified/QuickbaseClient.ts +1 -1
- package/src/invokeMethod.ts +23 -63
- package/src/quickbaseClient.ts +9 -4
- package/src/tokenCache.ts +4 -0
- package/tests/playwright/qb/auth/getAppWithTempTokenConcurrency.test.ts +208 -0
- package/tests/vitest/qb/auth/ssoTokenConcurrency.test.ts +68 -0
- package/tests/vitest/qb/auth/userTokenConcurrency.test.ts +63 -0
- package/tests/vitest/unit/apps/getAppConcurrency.test.ts +151 -0
- package/tests/vitest/unit/auth/fetchTempToken401.test.ts +20 -2
- package/tests/vitest/unit/auth/userTokenConcurrency.test.ts +62 -0
- package/tests/vitest/unit/index.ts +1 -1
- package/tests/vitest/unit/quickbaseClientHttpMethods.test.ts +3 -3
- package/tools/log-gen.ts +1 -1
- package/tools/log-gen.yaml +130 -78
- package/api-examples.md +0 -49
- package/markdown-examples.md +0 -85
|
@@ -17,12 +17,12 @@
|
|
|
17
17
|
if (typeof QuickbaseJS === "undefined") {
|
|
18
18
|
document.getElementById("result").textContent = "Error: Failed to load QuickbaseJS library.";
|
|
19
19
|
} else {
|
|
20
|
-
var
|
|
20
|
+
var qb = QuickbaseJS.quickbase({
|
|
21
21
|
realm,
|
|
22
22
|
useTempTokens: true
|
|
23
23
|
});
|
|
24
24
|
|
|
25
|
-
|
|
25
|
+
qb.getApp({ appId })
|
|
26
26
|
.then(function (app) {
|
|
27
27
|
document.getElementById("result").textContent = "App Name: " + app.name;
|
|
28
28
|
})
|
package/package.json
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
// src/authorizationStrategy.ts
|
|
2
|
-
|
|
3
2
|
import { TokenCache } from "./tokenCache";
|
|
4
3
|
|
|
5
4
|
export interface AuthorizationStrategy {
|
|
@@ -15,9 +14,9 @@ export interface AuthorizationStrategy {
|
|
|
15
14
|
): Promise<string | null>;
|
|
16
15
|
}
|
|
17
16
|
|
|
18
|
-
// src/authorizationStrategy.ts (partial update)
|
|
19
|
-
|
|
20
17
|
export class TempTokenStrategy implements AuthorizationStrategy {
|
|
18
|
+
private pendingFetches: Map<string, Promise<string>> = new Map();
|
|
19
|
+
|
|
21
20
|
constructor(
|
|
22
21
|
private tokenCache: TokenCache,
|
|
23
22
|
private initialTempToken: string | undefined,
|
|
@@ -61,7 +60,15 @@ export class TempTokenStrategy implements AuthorizationStrategy {
|
|
|
61
60
|
async getToken(dbid: string): Promise<string | undefined> {
|
|
62
61
|
let token = this.tokenCache.get(dbid) || this.initialTempToken;
|
|
63
62
|
if (!token && dbid) {
|
|
64
|
-
|
|
63
|
+
if (this.pendingFetches.has(dbid)) {
|
|
64
|
+
console.log(`[getToken] Waiting for existing fetch for dbid: ${dbid}`);
|
|
65
|
+
return this.pendingFetches.get(dbid);
|
|
66
|
+
}
|
|
67
|
+
const fetchPromise = this.fetchTempToken(dbid).finally(() =>
|
|
68
|
+
this.pendingFetches.delete(dbid)
|
|
69
|
+
);
|
|
70
|
+
this.pendingFetches.set(dbid, fetchPromise);
|
|
71
|
+
token = await fetchPromise;
|
|
65
72
|
}
|
|
66
73
|
return token;
|
|
67
74
|
}
|
|
@@ -96,14 +103,31 @@ export class TempTokenStrategy implements AuthorizationStrategy {
|
|
|
96
103
|
return null;
|
|
97
104
|
}
|
|
98
105
|
if (debug) console.log(`Refreshing temp token for dbid: ${dbid}`);
|
|
99
|
-
|
|
100
|
-
this.tokenCache.
|
|
101
|
-
|
|
106
|
+
// Invalidate the cache for this dbid to force a fresh fetch
|
|
107
|
+
this.tokenCache.delete(dbid);
|
|
108
|
+
try {
|
|
109
|
+
const newToken = await this.getToken(dbid);
|
|
110
|
+
if (newToken) {
|
|
111
|
+
this.tokenCache.set(dbid, newToken);
|
|
112
|
+
return newToken;
|
|
113
|
+
}
|
|
114
|
+
return null;
|
|
115
|
+
} catch (error) {
|
|
116
|
+
if (debug)
|
|
117
|
+
console.log(
|
|
118
|
+
`[${methodName || "method"}] Failed to refresh token:`,
|
|
119
|
+
error
|
|
120
|
+
);
|
|
121
|
+
throw error;
|
|
122
|
+
}
|
|
102
123
|
}
|
|
103
124
|
}
|
|
104
125
|
|
|
105
126
|
export class UserTokenStrategy implements AuthorizationStrategy {
|
|
106
|
-
constructor(
|
|
127
|
+
constructor(
|
|
128
|
+
private userToken: string,
|
|
129
|
+
private baseUrl: string = "https://api.quickbase.com/v1"
|
|
130
|
+
) {}
|
|
107
131
|
|
|
108
132
|
async getToken(_dbid: string): Promise<string> {
|
|
109
133
|
return this.userToken;
|
|
@@ -134,23 +158,32 @@ export class UserTokenStrategy implements AuthorizationStrategy {
|
|
|
134
158
|
|
|
135
159
|
export class SsoTokenStrategy implements AuthorizationStrategy {
|
|
136
160
|
private currentToken: string | undefined;
|
|
161
|
+
private pendingFetches: Map<string, Promise<string>> = new Map();
|
|
137
162
|
|
|
138
163
|
constructor(
|
|
139
164
|
private samlToken: string,
|
|
140
165
|
private realm: string,
|
|
141
166
|
private fetchApi: typeof fetch,
|
|
142
|
-
private debug: boolean = false
|
|
167
|
+
private debug: boolean = false,
|
|
168
|
+
private baseUrl: string = "https://api.quickbase.com/v1"
|
|
143
169
|
) {}
|
|
144
170
|
|
|
145
171
|
async getToken(_dbid: string): Promise<string | undefined> {
|
|
146
172
|
if (!this.currentToken) {
|
|
147
|
-
|
|
173
|
+
if (this.pendingFetches.has("sso")) {
|
|
174
|
+
if (this.debug)
|
|
175
|
+
console.log("[getToken] Waiting for existing SSO fetch");
|
|
176
|
+
return this.pendingFetches.get("sso");
|
|
177
|
+
}
|
|
178
|
+
const fetchPromise = this.fetchSsoToken({
|
|
148
179
|
grant_type: "urn:ietf:params:oauth:grant-type:token-exchange",
|
|
149
180
|
requested_token_type:
|
|
150
181
|
"urn:quickbase:params:oauth:token-type:temp_token",
|
|
151
182
|
subject_token: this.samlToken,
|
|
152
183
|
subject_token_type: "urn:ietf:params:oauth:token-type:saml2",
|
|
153
|
-
});
|
|
184
|
+
}).finally(() => this.pendingFetches.delete("sso"));
|
|
185
|
+
this.pendingFetches.set("sso", fetchPromise);
|
|
186
|
+
this.currentToken = await fetchPromise;
|
|
154
187
|
}
|
|
155
188
|
return this.currentToken;
|
|
156
189
|
}
|
|
@@ -193,18 +226,15 @@ export class SsoTokenStrategy implements AuthorizationStrategy {
|
|
|
193
226
|
subject_token_type: "urn:ietf:params:oauth:token-type:saml2",
|
|
194
227
|
};
|
|
195
228
|
|
|
196
|
-
const response = await this.fetchApi(
|
|
197
|
-
|
|
198
|
-
{
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
credentials: "omit",
|
|
206
|
-
}
|
|
207
|
-
);
|
|
229
|
+
const response = await this.fetchApi(`${this.baseUrl}/auth/oauth/token`, {
|
|
230
|
+
method: "POST",
|
|
231
|
+
headers: {
|
|
232
|
+
"QB-Realm-Hostname": `${this.realm}.quickbase.com`,
|
|
233
|
+
"Content-Type": "application/json",
|
|
234
|
+
},
|
|
235
|
+
body: JSON.stringify(payload),
|
|
236
|
+
credentials: "omit",
|
|
237
|
+
});
|
|
208
238
|
|
|
209
239
|
if (!response.ok) {
|
|
210
240
|
const errorBody = await response.json().catch(() => ({}));
|
|
@@ -234,18 +264,15 @@ export class SsoTokenStrategy implements AuthorizationStrategy {
|
|
|
234
264
|
subject_token: string;
|
|
235
265
|
subject_token_type: string;
|
|
236
266
|
}): Promise<string> {
|
|
237
|
-
const response = await this.fetchApi(
|
|
238
|
-
|
|
239
|
-
{
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
credentials: "omit",
|
|
247
|
-
}
|
|
248
|
-
);
|
|
267
|
+
const response = await this.fetchApi(`${this.baseUrl}/auth/oauth/token`, {
|
|
268
|
+
method: "POST",
|
|
269
|
+
headers: {
|
|
270
|
+
"QB-Realm-Hostname": `${this.realm}.quickbase.com`,
|
|
271
|
+
"Content-Type": "application/json",
|
|
272
|
+
},
|
|
273
|
+
body: JSON.stringify(params),
|
|
274
|
+
credentials: "omit",
|
|
275
|
+
});
|
|
249
276
|
|
|
250
277
|
if (!response.ok) {
|
|
251
278
|
const errorBody = await response.json().catch(() => ({}));
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// Generated on 2025-03-
|
|
1
|
+
// Generated on 2025-03-23T19:11:12.725Z
|
|
2
2
|
import { AddManagersToGroup200Response, AddManagersToGroupRequest, AddMembersToGroup200Response, AddMembersToGroupRequest, AddSubgroupsToGroup200Response, AddSubgroupsToGroupRequest, Audit200Response, ChangesetSolution200Response, ChangesetSolutionFromRecord200Response, CloneUserToken200Response, CopyApp200Response, CopyAppRequest, CreateApp200Response, CreateAppRequest, CreateField200Response, CreateFieldRequest, CreateRelationship200Response, CreateRelationshipRequest, CreateSolution200Response, CreateSolutionFromRecord200Response, CreateTable200Response, CreateTableRequest, DeactivateUserToken200Response, DeleteApp200Response, DeleteAppRequest, DeleteFields200Response, DeleteFieldsRequest, DeleteFile200Response, DeleteRecords200Response, DeleteRecordsRequest, DeleteRelationship200Response, DeleteTable200Response, DeleteUserToken200Response, DenyUsers200Response, DenyUsersAndGroups200Response, DenyUsersAndGroupsRequest, DenyUsersRequest, DownloadFile200Response, ExchangeSsoToken200Response, ExportSolution200Response, ExportSolutionToRecord200Response, GenerateDocument200Response, GetApp200Response, GetAppEvents200Response, GetAppTables200Response, GetField200Response, GetFieldUsage200Response, GetFields200Response, GetFieldsUsage200Response, GetRelationships200Response, GetReport200Response, GetTable200Response, GetTableReports200Response, GetTempTokenDBID200Response, GetUsers200Response, GetUsersRequest, PlatformAnalyticEventSummaries200Response, PlatformAnalyticReads200Response, RemoveManagersFromGroup200Response, RemoveManagersFromGroupRequest, RemoveMembersFromGroup200Response, RemoveMembersFromGroupRequest, RemoveSubgroupsFromGroup200Response, RemoveSubgroupsFromGroupRequest, RunFormula200Response, RunQuery200Response, RunQueryRequest, RunReport200Response, TransferUserToken200Response, UndenyUsers200Response, UndenyUsersRequest, UpdateApp200Response, UpdateAppRequest, UpdateField200Response, UpdateFieldRequest, UpdateRelationship200Response, UpdateRelationshipRequest, UpdateSolution200Response, UpdateSolutionToRecord200Response, UpdateTable200Response, UpdateTableRequest, Upsert200Response, Upsert207Response, UpsertRequest } from "../generated/models";
|
|
3
3
|
|
|
4
4
|
export interface QuickbaseClient {
|
package/src/invokeMethod.ts
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
// src/invokeMethod.ts
|
|
2
|
-
|
|
3
2
|
import { AuthorizationStrategy, extractDbid } from "./authorizationStrategy";
|
|
4
3
|
import { RateLimiter } from "./rateLimiter";
|
|
5
4
|
import { RateLimitError } from "./RateLimitError";
|
|
@@ -31,10 +30,10 @@ export async function invokeMethod<K extends keyof QuickbaseClient>(
|
|
|
31
30
|
rateLimiter: RateLimiter,
|
|
32
31
|
transformDates: (obj: any, convertStringsToDates: boolean) => any,
|
|
33
32
|
debug: boolean | undefined,
|
|
34
|
-
convertDates: boolean
|
|
33
|
+
convertDates: boolean,
|
|
34
|
+
attempt: number = 0,
|
|
35
|
+
maxAttempts: number = rateLimiter.maxRetries + 1
|
|
35
36
|
): Promise<ReturnType<QuickbaseClient[K]>> {
|
|
36
|
-
console.log("[invokeMethod] Starting for method:", methodName);
|
|
37
|
-
|
|
38
37
|
const methodInfo = methodMap[methodName];
|
|
39
38
|
if (!methodInfo) throw new Error(`Method ${methodName} not found`);
|
|
40
39
|
|
|
@@ -56,42 +55,27 @@ export async function invokeMethod<K extends keyof QuickbaseClient>(
|
|
|
56
55
|
};
|
|
57
56
|
|
|
58
57
|
let dbid: string | undefined = extractDbid(params);
|
|
59
|
-
console.log("[invokeMethod] Extracted dbid:", dbid);
|
|
60
|
-
|
|
61
58
|
let token = dbid
|
|
62
59
|
? await authStrategy.getToken(dbid)
|
|
63
60
|
: await authStrategy.getToken("");
|
|
64
|
-
console.log("[invokeMethod] Initial token:", token);
|
|
65
61
|
|
|
66
62
|
if (token) {
|
|
67
63
|
authStrategy.applyHeaders(baseHeaders, token);
|
|
68
64
|
requestOptions.headers = { ...baseHeaders };
|
|
69
|
-
console.log(
|
|
70
|
-
"[invokeMethod] Token found, headers set:",
|
|
71
|
-
requestOptions.headers
|
|
72
|
-
);
|
|
73
65
|
if (methodName === "getTempTokenDBID") {
|
|
74
|
-
console.log("[invokeMethod] Using cached token:", token);
|
|
75
66
|
return { temporaryAuthorization: token } as ReturnType<
|
|
76
67
|
QuickbaseClient[K]
|
|
77
68
|
>;
|
|
78
69
|
}
|
|
79
70
|
}
|
|
80
71
|
|
|
81
|
-
if (debug) {
|
|
82
|
-
console.log(`[${methodName}] requestParameters:`, requestParameters);
|
|
83
|
-
console.log(`[${methodName}] requestOptions:`, requestOptions);
|
|
84
|
-
}
|
|
85
|
-
|
|
86
72
|
async function processResponse(
|
|
87
73
|
rawResponse: any
|
|
88
74
|
): Promise<ReturnType<QuickbaseClient[K]>> {
|
|
89
|
-
if (debug) console.log(`[${methodName}] rawResponse:`, rawResponse);
|
|
90
75
|
if (rawResponse instanceof Response) {
|
|
91
76
|
const contentType = rawResponse.headers
|
|
92
77
|
.get("Content-Type")
|
|
93
78
|
?.toLowerCase();
|
|
94
|
-
if (debug) console.log(`[${methodName}] contentType:`, contentType);
|
|
95
79
|
if (contentType?.includes("application/json")) {
|
|
96
80
|
const jsonResponse = await rawResponse.json();
|
|
97
81
|
return transformDates(jsonResponse, convertDates) as ReturnType<
|
|
@@ -102,8 +86,6 @@ export async function invokeMethod<K extends keyof QuickbaseClient>(
|
|
|
102
86
|
}
|
|
103
87
|
if (rawResponse && typeof rawResponse.value === "function") {
|
|
104
88
|
const response = await rawResponse.value();
|
|
105
|
-
if (debug)
|
|
106
|
-
console.log(`[${methodName}] Resolved JSONApiResponse:`, response);
|
|
107
89
|
return transformDates(response, convertDates) as ReturnType<
|
|
108
90
|
QuickbaseClient[K]
|
|
109
91
|
>;
|
|
@@ -140,36 +122,20 @@ export async function invokeMethod<K extends keyof QuickbaseClient>(
|
|
|
140
122
|
message = (await response.text()) || message;
|
|
141
123
|
}
|
|
142
124
|
} catch (e) {
|
|
143
|
-
if (debug) console.log(`[${methodName}] Error parsing response body:`, e);
|
|
144
125
|
message = "Failed to parse error response";
|
|
145
126
|
}
|
|
146
|
-
if (debug)
|
|
147
|
-
console.log(
|
|
148
|
-
"[invokeMethod] Parsed error - status:",
|
|
149
|
-
status,
|
|
150
|
-
"message:",
|
|
151
|
-
message
|
|
152
|
-
);
|
|
153
127
|
return { message, status };
|
|
154
128
|
}
|
|
155
129
|
|
|
156
|
-
let attempt = 0;
|
|
157
|
-
const maxAttempts = rateLimiter.maxRetries + 1;
|
|
158
|
-
|
|
159
130
|
while (attempt < maxAttempts) {
|
|
160
|
-
console.log("[invokeMethod] Attempt:", attempt + 1, "of", maxAttempts);
|
|
161
131
|
try {
|
|
162
132
|
await rateLimiter.throttle();
|
|
163
|
-
console.log("[invokeMethod] About to call API for method:", methodName);
|
|
164
133
|
const response = await methodInfo.method(
|
|
165
134
|
requestParameters,
|
|
166
135
|
requestOptions
|
|
167
136
|
);
|
|
168
|
-
console.log("[invokeMethod] API call completed for method:", methodName);
|
|
169
137
|
return await processResponse(response);
|
|
170
138
|
} catch (error: unknown) {
|
|
171
|
-
if (debug) console.log("[invokeMethod] Caught error:", error);
|
|
172
|
-
|
|
173
139
|
let status: number;
|
|
174
140
|
let message: string;
|
|
175
141
|
let response: Response | undefined;
|
|
@@ -181,29 +147,14 @@ export async function invokeMethod<K extends keyof QuickbaseClient>(
|
|
|
181
147
|
response = error;
|
|
182
148
|
({ message, status } = await parseErrorResponse(response));
|
|
183
149
|
} else {
|
|
184
|
-
|
|
185
|
-
// Attempt to handle as a fetch error with a response
|
|
186
|
-
if (error instanceof Error && "response" in error) {
|
|
187
|
-
response = (error as any).response;
|
|
188
|
-
if (response instanceof Response) {
|
|
189
|
-
({ message, status } = await parseErrorResponse(response));
|
|
190
|
-
} else {
|
|
191
|
-
throw error; // Rethrow if no response
|
|
192
|
-
}
|
|
193
|
-
} else {
|
|
194
|
-
throw error; // Rethrow truly unexpected errors
|
|
195
|
-
}
|
|
150
|
+
throw error; // Rethrow truly unexpected errors
|
|
196
151
|
}
|
|
197
152
|
|
|
198
|
-
if (debug)
|
|
199
|
-
console.log("[invokeMethod] Handling error with status:", status);
|
|
200
|
-
|
|
201
153
|
if (status === 429) {
|
|
202
154
|
if (!(error instanceof ResponseError)) {
|
|
203
155
|
throw new Error("Expected ResponseError for 429 handling");
|
|
204
156
|
}
|
|
205
157
|
const delay = await rateLimiter.handle429(error, attempt + 1);
|
|
206
|
-
if (debug) console.log(`[${methodName}] 429 delay: ${delay}ms`);
|
|
207
158
|
if (attempt + 1 === maxAttempts) {
|
|
208
159
|
throw new RateLimitError(
|
|
209
160
|
`API Error: ${message} (Status: ${status})`,
|
|
@@ -218,26 +169,35 @@ export async function invokeMethod<K extends keyof QuickbaseClient>(
|
|
|
218
169
|
continue;
|
|
219
170
|
}
|
|
220
171
|
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
172
|
+
// Handle authentication errors
|
|
173
|
+
let newToken: string | null;
|
|
174
|
+
try {
|
|
175
|
+
newToken = await authStrategy.handleError(
|
|
176
|
+
status,
|
|
177
|
+
params,
|
|
178
|
+
attempt,
|
|
179
|
+
maxAttempts,
|
|
180
|
+
debug,
|
|
181
|
+
methodName
|
|
182
|
+
);
|
|
183
|
+
} catch (authError) {
|
|
184
|
+
throw authError; // Propagate the fetchTempToken error and exit immediately
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// Only proceed with retry if we have a valid new token
|
|
188
|
+
if (newToken !== null) {
|
|
230
189
|
token = newToken;
|
|
231
190
|
authStrategy.applyHeaders(baseHeaders, token);
|
|
232
191
|
requestOptions.headers = { ...baseHeaders };
|
|
233
|
-
attempt++;
|
|
234
192
|
if (debug)
|
|
235
193
|
console.log(
|
|
236
194
|
`[${methodName}] Retrying with token: ${token.substring(0, 10)}...`
|
|
237
195
|
);
|
|
196
|
+
attempt++;
|
|
238
197
|
continue;
|
|
239
198
|
}
|
|
240
199
|
|
|
200
|
+
// If no new token was provided, throw the original error
|
|
241
201
|
throw new Error(`API Error: ${message} (Status: ${status})`);
|
|
242
202
|
}
|
|
243
203
|
}
|
package/src/quickbaseClient.ts
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
// src/quickbaseClient.ts
|
|
2
|
-
|
|
3
2
|
import { QuickbaseClient as IQuickbaseClient } from "./generated-unified/QuickbaseClient";
|
|
4
3
|
import { Configuration, HTTPHeaders } from "./generated/runtime";
|
|
5
4
|
import * as apis from "./generated/apis";
|
|
@@ -9,7 +8,7 @@ import {
|
|
|
9
8
|
getParamNames,
|
|
10
9
|
transformDates,
|
|
11
10
|
extractHttpMethod,
|
|
12
|
-
} from "./utils";
|
|
11
|
+
} from "./utils";
|
|
13
12
|
import { invokeMethod, MethodInfo } from "./invokeMethod";
|
|
14
13
|
import {
|
|
15
14
|
TempTokenStrategy,
|
|
@@ -82,7 +81,13 @@ export function quickbase(config: QuickbaseConfig): QuickbaseClient {
|
|
|
82
81
|
}
|
|
83
82
|
|
|
84
83
|
const authStrategy: AuthorizationStrategy = useSso
|
|
85
|
-
? new SsoTokenStrategy(
|
|
84
|
+
? new SsoTokenStrategy(
|
|
85
|
+
samlToken || "",
|
|
86
|
+
realm,
|
|
87
|
+
effectiveFetch,
|
|
88
|
+
debug,
|
|
89
|
+
baseUrl
|
|
90
|
+
)
|
|
86
91
|
: useTempTokens
|
|
87
92
|
? new TempTokenStrategy(
|
|
88
93
|
tokenCache,
|
|
@@ -91,7 +96,7 @@ export function quickbase(config: QuickbaseConfig): QuickbaseClient {
|
|
|
91
96
|
realm,
|
|
92
97
|
baseUrl
|
|
93
98
|
)
|
|
94
|
-
: new UserTokenStrategy(userToken || "");
|
|
99
|
+
: new UserTokenStrategy(userToken || "", baseUrl);
|
|
95
100
|
|
|
96
101
|
const baseHeaders: HTTPHeaders = {
|
|
97
102
|
"QB-Realm-Hostname": `${realm}.quickbase.com`,
|
package/src/tokenCache.ts
CHANGED
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
// tests/playwright/qb/auth/getAppWithTempTokenConcurrency.test.ts
|
|
2
|
+
import { test, expect } from "@playwright/test";
|
|
3
|
+
import { quickbase } from "../../../../src/quickbaseClient.ts"; // Keep .ts for now
|
|
4
|
+
import dotenv from "dotenv";
|
|
5
|
+
|
|
6
|
+
dotenv.config();
|
|
7
|
+
|
|
8
|
+
const loginToQuickbase = async (
|
|
9
|
+
page: import("@playwright/test").Page,
|
|
10
|
+
quickbaseUrl: string,
|
|
11
|
+
username: string,
|
|
12
|
+
password: string
|
|
13
|
+
): Promise<boolean> => {
|
|
14
|
+
let loginSuccess = false;
|
|
15
|
+
const maxLoginAttempts = 3;
|
|
16
|
+
let loginAttempt = 0;
|
|
17
|
+
|
|
18
|
+
while (!loginSuccess && loginAttempt < maxLoginAttempts) {
|
|
19
|
+
try {
|
|
20
|
+
loginAttempt++;
|
|
21
|
+
if (loginAttempt > 1) console.log(`Login attempt ${loginAttempt}`);
|
|
22
|
+
|
|
23
|
+
await page.goto(quickbaseUrl, { timeout: 60000 });
|
|
24
|
+
await page.waitForSelector("input[name='loginid']", { timeout: 60000 });
|
|
25
|
+
await page.fill("input[name='loginid']", username);
|
|
26
|
+
await page.fill("input[name='password']", password);
|
|
27
|
+
await page.click("#signin");
|
|
28
|
+
await page.waitForURL(`https://*.quickbase.com/**`, {
|
|
29
|
+
waitUntil: "networkidle",
|
|
30
|
+
timeout: 60000,
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
const loginError = await page.$(".login-error");
|
|
34
|
+
if (loginError) {
|
|
35
|
+
throw new Error("Login failed. Please check your credentials.");
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
console.log("Signed In to QuickBase.");
|
|
39
|
+
loginSuccess = true;
|
|
40
|
+
} catch (error) {
|
|
41
|
+
console.error(
|
|
42
|
+
`Login attempt ${loginAttempt} failed: ${(error as Error).message}`
|
|
43
|
+
);
|
|
44
|
+
if (loginAttempt >= maxLoginAttempts) {
|
|
45
|
+
console.error("Max login attempts reached. Exiting.");
|
|
46
|
+
await page.context().browser()!.close();
|
|
47
|
+
return false;
|
|
48
|
+
}
|
|
49
|
+
await new Promise((resolve) => setTimeout(resolve, 5000));
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return loginSuccess;
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
test.describe("QuickbaseClient Integration - getApp with Temporary Token Concurrency", () => {
|
|
56
|
+
test("handles concurrent getApp requests with a single token fetch", async ({
|
|
57
|
+
page,
|
|
58
|
+
}) => {
|
|
59
|
+
const realm = process.env.QB_REALM;
|
|
60
|
+
const appId = process.env.QB_APP_ID;
|
|
61
|
+
const username = process.env.QB_USERNAME;
|
|
62
|
+
const password = process.env.QB_PASSWORD;
|
|
63
|
+
|
|
64
|
+
if (!realm) throw new Error("QB_REALM is not defined in .env");
|
|
65
|
+
if (!appId) throw new Error("QB_APP_ID is not defined in .env");
|
|
66
|
+
if (!username) throw new Error("QB_USERNAME is not defined in .env");
|
|
67
|
+
if (!password) throw new Error("QB_PASSWORD is not defined in .env");
|
|
68
|
+
|
|
69
|
+
const fetchInits: { url: string; init: RequestInit }[] = [];
|
|
70
|
+
|
|
71
|
+
const quickbaseUrl = `https://${realm}.quickbase.com/db/main?a=SignIn`;
|
|
72
|
+
const loginSuccess = await loginToQuickbase(
|
|
73
|
+
page,
|
|
74
|
+
quickbaseUrl,
|
|
75
|
+
username,
|
|
76
|
+
password
|
|
77
|
+
);
|
|
78
|
+
if (!loginSuccess) {
|
|
79
|
+
throw new Error("Failed to log in to QuickBase after max attempts.");
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
await page.goto(`https://${realm}.quickbase.com/db/${appId}`, {
|
|
83
|
+
waitUntil: "networkidle",
|
|
84
|
+
timeout: 60000,
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
const client = quickbase({
|
|
88
|
+
realm,
|
|
89
|
+
useTempTokens: true,
|
|
90
|
+
debug: true, // Enable debug logs to verify concurrency behavior
|
|
91
|
+
fetchApi: async (url: RequestInfo | URL, init?: RequestInit) => {
|
|
92
|
+
const effectiveInit = init || {};
|
|
93
|
+
fetchInits.push({ url: String(url), init: effectiveInit });
|
|
94
|
+
const response = await page.evaluate(
|
|
95
|
+
async (args: [string, RequestInit]) => {
|
|
96
|
+
const [fetchUrl, fetchInit] = args;
|
|
97
|
+
const res = await fetch(fetchUrl, fetchInit);
|
|
98
|
+
const body = await res.text();
|
|
99
|
+
const headersObj: Record<string, string> = {};
|
|
100
|
+
res.headers.forEach((value, key) => {
|
|
101
|
+
headersObj[key] = value;
|
|
102
|
+
});
|
|
103
|
+
return {
|
|
104
|
+
ok: res.ok,
|
|
105
|
+
status: res.status,
|
|
106
|
+
statusText: res.statusText,
|
|
107
|
+
headers: headersObj,
|
|
108
|
+
body,
|
|
109
|
+
};
|
|
110
|
+
},
|
|
111
|
+
[String(url), effectiveInit] as [string, RequestInit]
|
|
112
|
+
);
|
|
113
|
+
|
|
114
|
+
const fetchResponse = new Response(response.body || null, {
|
|
115
|
+
status: response.status,
|
|
116
|
+
statusText: response.statusText,
|
|
117
|
+
headers: new Headers(response.headers),
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
fetchResponse.json = async () => {
|
|
121
|
+
if (!response.body) {
|
|
122
|
+
throw new Error("Empty response body from API");
|
|
123
|
+
}
|
|
124
|
+
try {
|
|
125
|
+
return JSON.parse(response.body);
|
|
126
|
+
} catch (e) {
|
|
127
|
+
throw new SyntaxError(`Invalid JSON response: ${response.body}`);
|
|
128
|
+
}
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
return fetchResponse;
|
|
132
|
+
},
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
// Make three concurrent getApp requests
|
|
136
|
+
const concurrentRequests = [
|
|
137
|
+
client.getApp({ appId }),
|
|
138
|
+
client.getApp({ appId }),
|
|
139
|
+
client.getApp({ appId }),
|
|
140
|
+
];
|
|
141
|
+
|
|
142
|
+
// Wait for all responses to complete
|
|
143
|
+
await Promise.all([
|
|
144
|
+
page.waitForResponse(`https://api.quickbase.com/v1/apps/${appId}`, {
|
|
145
|
+
timeout: 30000,
|
|
146
|
+
}),
|
|
147
|
+
page.waitForResponse(`https://api.quickbase.com/v1/apps/${appId}`, {
|
|
148
|
+
timeout: 30000,
|
|
149
|
+
}),
|
|
150
|
+
page.waitForResponse(`https://api.quickbase.com/v1/apps/${appId}`, {
|
|
151
|
+
timeout: 30000,
|
|
152
|
+
}),
|
|
153
|
+
]);
|
|
154
|
+
|
|
155
|
+
const results = await Promise.all(concurrentRequests);
|
|
156
|
+
|
|
157
|
+
// Expected app data (based on your previous test)
|
|
158
|
+
const expectedAppData = {
|
|
159
|
+
id: appId,
|
|
160
|
+
name: "qb-copy",
|
|
161
|
+
created: expect.any(Date),
|
|
162
|
+
updated: expect.any(Date),
|
|
163
|
+
description: "",
|
|
164
|
+
timeZone: "(UTC-08:00) Pacific Time (US & Canada)",
|
|
165
|
+
dateFormat: "MM-DD-YYYY",
|
|
166
|
+
hasEveryoneOnTheInternet: false,
|
|
167
|
+
memoryInfo: { estMemory: 0, estMemoryInclDependentApps: 0 },
|
|
168
|
+
securityProperties: {
|
|
169
|
+
allowClone: false,
|
|
170
|
+
allowExport: true,
|
|
171
|
+
enableAppTokens: true,
|
|
172
|
+
hideFromPublic: false,
|
|
173
|
+
mustBeRealmApproved: false,
|
|
174
|
+
useIPFilter: false,
|
|
175
|
+
},
|
|
176
|
+
ancestorId: undefined,
|
|
177
|
+
dataClassification: undefined,
|
|
178
|
+
variables: [{ name: "TestVar", value: "TestValue" }],
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
// Assert all results match the expected data
|
|
182
|
+
expect(results).toHaveLength(3);
|
|
183
|
+
results.forEach((result) => {
|
|
184
|
+
expect(result).toEqual(expectedAppData);
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
// Verify token fetch behavior
|
|
188
|
+
const tokenFetchInits = fetchInits.filter((entry) =>
|
|
189
|
+
entry.url.includes(`/auth/temporary/${appId}`)
|
|
190
|
+
);
|
|
191
|
+
expect(tokenFetchInits).toHaveLength(1); // Only one token fetch should occur
|
|
192
|
+
expect(tokenFetchInits[0].init.credentials).toBe("include");
|
|
193
|
+
|
|
194
|
+
const appFetchInits = fetchInits.filter((entry) =>
|
|
195
|
+
entry.url.includes(`/apps/${appId}`)
|
|
196
|
+
);
|
|
197
|
+
expect(appFetchInits).toHaveLength(3); // Three getApp requests
|
|
198
|
+
appFetchInits.forEach((init) => {
|
|
199
|
+
expect(init.init.credentials).toBe("omit");
|
|
200
|
+
expect(init.init.headers).toHaveProperty("Authorization");
|
|
201
|
+
expect(
|
|
202
|
+
(init.init.headers as Record<string, string>)["Authorization"]
|
|
203
|
+
).toMatch(/^QB-TEMP-TOKEN .+$/);
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
console.log("Test completed. Check debug logs for concurrency details.");
|
|
207
|
+
});
|
|
208
|
+
});
|