assistant-cloud 0.1.40 → 0.1.41
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/AssistantCloudAuthStrategy.d.ts.map +1 -1
- package/dist/AssistantCloudAuthStrategy.js +35 -12
- package/dist/AssistantCloudAuthStrategy.js.map +1 -1
- package/dist/AssistantCloudAuthTokens.d.ts.map +1 -1
- package/dist/AssistantCloudAuthTokens.js +3 -1
- package/dist/AssistantCloudAuthTokens.js.map +1 -1
- package/dist/AssistantCloudFiles.d.ts.map +1 -1
- package/dist/AssistantCloudFiles.js +16 -4
- package/dist/AssistantCloudFiles.js.map +1 -1
- package/dist/AssistantCloudRuns.d.ts.map +1 -1
- package/dist/AssistantCloudRuns.js +10 -2
- package/dist/AssistantCloudRuns.js.map +1 -1
- package/dist/AssistantCloudThreadMessages.d.ts.map +1 -1
- package/dist/AssistantCloudThreadMessages.js +3 -2
- package/dist/AssistantCloudThreadMessages.js.map +1 -1
- package/dist/AssistantCloudThreads.d.ts.map +1 -1
- package/dist/AssistantCloudThreads.js +3 -2
- package/dist/AssistantCloudThreads.js.map +1 -1
- package/package.json +4 -4
- package/src/AssistantCloudAuthStrategy.ts +49 -12
- package/src/AssistantCloudAuthTokens.test.ts +30 -0
- package/src/AssistantCloudAuthTokens.ts +7 -1
- package/src/AssistantCloudFiles.test.ts +71 -0
- package/src/AssistantCloudFiles.ts +56 -10
- package/src/AssistantCloudRuns.ts +34 -1
- package/src/AssistantCloudThreadMessages.test.ts +20 -0
- package/src/AssistantCloudThreadMessages.ts +10 -3
- package/src/AssistantCloudThreads.test.ts +17 -0
- package/src/AssistantCloudThreads.ts +6 -1
- package/src/tests/AssistantCloudAuthStrategy.test.ts +278 -5
- package/src/tests/AssistantCloudRuns.test.ts +115 -0
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from "vitest";
|
|
2
|
+
import type { AssistantCloudAPI } from "./AssistantCloudAPI";
|
|
3
|
+
import { AssistantCloudFiles } from "./AssistantCloudFiles";
|
|
4
|
+
|
|
5
|
+
const createCloudFiles = () => {
|
|
6
|
+
const makeRequest = vi.fn();
|
|
7
|
+
const api = { makeRequest } as unknown as AssistantCloudAPI;
|
|
8
|
+
return { files: new AssistantCloudFiles(api), makeRequest };
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
describe("AssistantCloudFiles responses", () => {
|
|
12
|
+
it("decodes PDF conversion responses", async () => {
|
|
13
|
+
const { files, makeRequest } = createCloudFiles();
|
|
14
|
+
makeRequest.mockResolvedValue({
|
|
15
|
+
success: true,
|
|
16
|
+
urls: ["https://example.com/page-1.png"],
|
|
17
|
+
message: "converted",
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
await expect(
|
|
21
|
+
files.pdfToImages({ file_url: "https://example.com/file.pdf" }),
|
|
22
|
+
).resolves.toEqual({
|
|
23
|
+
success: true,
|
|
24
|
+
urls: ["https://example.com/page-1.png"],
|
|
25
|
+
message: "converted",
|
|
26
|
+
});
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("rejects malformed PDF conversion responses", async () => {
|
|
30
|
+
const { files, makeRequest } = createCloudFiles();
|
|
31
|
+
makeRequest.mockResolvedValue({
|
|
32
|
+
success: true,
|
|
33
|
+
urls: [42],
|
|
34
|
+
message: "converted",
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
await expect(files.pdfToImages({ file_blob: "data" })).rejects.toThrow(
|
|
38
|
+
'Invalid Assistant Cloud response for "PDF conversion response.urls[0]": expected a string',
|
|
39
|
+
);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("decodes presigned upload responses", async () => {
|
|
43
|
+
const { files, makeRequest } = createCloudFiles();
|
|
44
|
+
makeRequest.mockResolvedValue({
|
|
45
|
+
success: true,
|
|
46
|
+
signedUrl: "https://uploads.example.com/file",
|
|
47
|
+
expiresAt: "2026-08-16T12:00:00.000Z",
|
|
48
|
+
publicUrl: "https://cdn.example.com/file",
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
await expect(
|
|
52
|
+
files.generatePresignedUploadUrl({ filename: "notes.txt" }),
|
|
53
|
+
).resolves.toEqual({
|
|
54
|
+
success: true,
|
|
55
|
+
signedUrl: "https://uploads.example.com/file",
|
|
56
|
+
expiresAt: "2026-08-16T12:00:00.000Z",
|
|
57
|
+
publicUrl: "https://cdn.example.com/file",
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("rejects malformed presigned upload responses", async () => {
|
|
62
|
+
const { files, makeRequest } = createCloudFiles();
|
|
63
|
+
makeRequest.mockResolvedValue({});
|
|
64
|
+
|
|
65
|
+
await expect(
|
|
66
|
+
files.generatePresignedUploadUrl({ filename: "notes.txt" }),
|
|
67
|
+
).rejects.toThrow(
|
|
68
|
+
'Invalid Assistant Cloud response for "presigned upload response.success": expected a boolean',
|
|
69
|
+
);
|
|
70
|
+
});
|
|
71
|
+
});
|
|
@@ -1,4 +1,10 @@
|
|
|
1
1
|
import type { AssistantCloudAPI } from "./AssistantCloudAPI";
|
|
2
|
+
import {
|
|
3
|
+
readCloudArray,
|
|
4
|
+
readCloudBoolean,
|
|
5
|
+
readCloudRecord,
|
|
6
|
+
readCloudString,
|
|
7
|
+
} from "./cloudResponse";
|
|
2
8
|
|
|
3
9
|
type PdfToImagesRequestBody = {
|
|
4
10
|
file_blob?: string | undefined;
|
|
@@ -32,21 +38,61 @@ export class AssistantCloudFiles {
|
|
|
32
38
|
public async pdfToImages(
|
|
33
39
|
body: PdfToImagesRequestBody,
|
|
34
40
|
): Promise<PdfToImagesResponse> {
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
41
|
+
const response = readCloudRecord(
|
|
42
|
+
await this.cloud.makeRequest("/files/pdf-to-images", {
|
|
43
|
+
method: "POST",
|
|
44
|
+
body,
|
|
45
|
+
}),
|
|
46
|
+
"PDF conversion response",
|
|
47
|
+
);
|
|
48
|
+
|
|
49
|
+
return {
|
|
50
|
+
success: readCloudBoolean(
|
|
51
|
+
response.success,
|
|
52
|
+
"PDF conversion response.success",
|
|
53
|
+
),
|
|
54
|
+
urls: readCloudArray(response.urls, "PDF conversion response.urls").map(
|
|
55
|
+
(url, index) =>
|
|
56
|
+
readCloudString(url, `PDF conversion response.urls[${index}]`),
|
|
57
|
+
),
|
|
58
|
+
message: readCloudString(
|
|
59
|
+
response.message,
|
|
60
|
+
"PDF conversion response.message",
|
|
61
|
+
),
|
|
62
|
+
};
|
|
39
63
|
}
|
|
40
64
|
|
|
41
65
|
public async generatePresignedUploadUrl(
|
|
42
66
|
body: GeneratePresignedUploadUrlRequestBody,
|
|
43
67
|
): Promise<GeneratePresignedUploadUrlResponse> {
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
68
|
+
const response = readCloudRecord(
|
|
69
|
+
await this.cloud.makeRequest(
|
|
70
|
+
"/files/attachments/generate-presigned-upload-url",
|
|
71
|
+
{
|
|
72
|
+
method: "POST",
|
|
73
|
+
body,
|
|
74
|
+
},
|
|
75
|
+
),
|
|
76
|
+
"presigned upload response",
|
|
50
77
|
);
|
|
78
|
+
|
|
79
|
+
return {
|
|
80
|
+
success: readCloudBoolean(
|
|
81
|
+
response.success,
|
|
82
|
+
"presigned upload response.success",
|
|
83
|
+
),
|
|
84
|
+
signedUrl: readCloudString(
|
|
85
|
+
response.signedUrl,
|
|
86
|
+
"presigned upload response.signedUrl",
|
|
87
|
+
),
|
|
88
|
+
expiresAt: readCloudString(
|
|
89
|
+
response.expiresAt,
|
|
90
|
+
"presigned upload response.expiresAt",
|
|
91
|
+
),
|
|
92
|
+
publicUrl: readCloudString(
|
|
93
|
+
response.publicUrl,
|
|
94
|
+
"presigned upload response.publicUrl",
|
|
95
|
+
),
|
|
96
|
+
};
|
|
51
97
|
}
|
|
52
98
|
}
|
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
import type { AssistantCloudAPI } from "./AssistantCloudAPI";
|
|
2
2
|
import type { SamplingCallData } from "./instrumentMcpSampling";
|
|
3
3
|
import { AssistantStream, PlainTextDecoder } from "assistant-stream";
|
|
4
|
+
import {
|
|
5
|
+
CloudResponseError,
|
|
6
|
+
readCloudRecord,
|
|
7
|
+
readCloudString,
|
|
8
|
+
} from "./cloudResponse";
|
|
4
9
|
|
|
5
10
|
type AssistantCloudRunsStreamBody = {
|
|
6
11
|
thread_id: string;
|
|
@@ -83,12 +88,40 @@ export class AssistantCloudRuns {
|
|
|
83
88
|
},
|
|
84
89
|
body,
|
|
85
90
|
});
|
|
91
|
+
|
|
92
|
+
if (!response.body) {
|
|
93
|
+
throw new CloudResponseError(
|
|
94
|
+
'Invalid Assistant Cloud response for "run stream": expected a response body',
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const receivedContentType = response.headers.get("content-type");
|
|
99
|
+
const contentType = receivedContentType
|
|
100
|
+
?.split(";", 1)[0]
|
|
101
|
+
?.trim()
|
|
102
|
+
.toLowerCase();
|
|
103
|
+
if (contentType !== "text/plain") {
|
|
104
|
+
await response.body.cancel().catch(() => undefined);
|
|
105
|
+
throw new CloudResponseError(
|
|
106
|
+
`Invalid Assistant Cloud response for "run stream": expected a "text/plain" content type, received ${
|
|
107
|
+
receivedContentType
|
|
108
|
+
? `"${receivedContentType}"`
|
|
109
|
+
: "no Content-Type header"
|
|
110
|
+
}`,
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
|
|
86
114
|
return AssistantStream.fromResponse(response, new PlainTextDecoder());
|
|
87
115
|
}
|
|
88
116
|
|
|
89
117
|
public async report(
|
|
90
118
|
body: AssistantCloudRunReport,
|
|
91
119
|
): Promise<{ run_id: string }> {
|
|
92
|
-
|
|
120
|
+
const response = readCloudRecord(
|
|
121
|
+
await this.cloud.makeRequest("/runs", { method: "POST", body }),
|
|
122
|
+
"run report response",
|
|
123
|
+
);
|
|
124
|
+
|
|
125
|
+
return { run_id: readCloudString(response.run_id, "run_id") };
|
|
93
126
|
}
|
|
94
127
|
}
|
|
@@ -12,6 +12,26 @@ const createCloudThreadMessages = () => {
|
|
|
12
12
|
};
|
|
13
13
|
|
|
14
14
|
describe("AssistantCloudThreadMessages responses", () => {
|
|
15
|
+
it("validates created message IDs", async () => {
|
|
16
|
+
const { messages, makeRequest } = createCloudThreadMessages();
|
|
17
|
+
const body = {
|
|
18
|
+
parent_id: null,
|
|
19
|
+
format: "aui/v0",
|
|
20
|
+
content: {},
|
|
21
|
+
};
|
|
22
|
+
makeRequest.mockResolvedValueOnce({ message_id: "message-1" });
|
|
23
|
+
|
|
24
|
+
await expect(messages.create("thread-1", body)).resolves.toEqual({
|
|
25
|
+
message_id: "message-1",
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
makeRequest.mockResolvedValueOnce({});
|
|
29
|
+
|
|
30
|
+
await expect(messages.create("thread-1", body)).rejects.toThrow(
|
|
31
|
+
'Invalid Assistant Cloud response for "message_id": expected a string',
|
|
32
|
+
);
|
|
33
|
+
});
|
|
34
|
+
|
|
15
35
|
it("decodes canonical message responses without changing content", async () => {
|
|
16
36
|
const { messages, makeRequest } = createCloudThreadMessages();
|
|
17
37
|
makeRequest.mockResolvedValue({
|
|
@@ -89,10 +89,17 @@ export class AssistantCloudThreadMessages {
|
|
|
89
89
|
threadId: string,
|
|
90
90
|
body: AssistantCloudThreadMessageCreateBody,
|
|
91
91
|
): Promise<AssistantCloudMessageCreateResponse> {
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
92
|
+
const response = readCloudRecord(
|
|
93
|
+
await this.cloud.makeRequest(
|
|
94
|
+
`/threads/${encodeURIComponent(threadId)}/messages`,
|
|
95
|
+
{ method: "POST", body },
|
|
96
|
+
),
|
|
97
|
+
"thread message create response",
|
|
95
98
|
);
|
|
99
|
+
|
|
100
|
+
return {
|
|
101
|
+
message_id: readCloudString(response.message_id, "message_id"),
|
|
102
|
+
};
|
|
96
103
|
}
|
|
97
104
|
|
|
98
105
|
public async update(
|
|
@@ -22,6 +22,23 @@ const threadResponse = {
|
|
|
22
22
|
};
|
|
23
23
|
|
|
24
24
|
describe("AssistantCloudThreads responses", () => {
|
|
25
|
+
it("validates created thread IDs", async () => {
|
|
26
|
+
const { threads, makeRequest } = createCloudThreads();
|
|
27
|
+
makeRequest.mockResolvedValueOnce({ thread_id: "thread-1" });
|
|
28
|
+
|
|
29
|
+
await expect(
|
|
30
|
+
threads.create({ last_message_at: new Date() }),
|
|
31
|
+
).resolves.toEqual({ thread_id: "thread-1" });
|
|
32
|
+
|
|
33
|
+
makeRequest.mockResolvedValueOnce({});
|
|
34
|
+
|
|
35
|
+
await expect(
|
|
36
|
+
threads.create({ last_message_at: new Date() }),
|
|
37
|
+
).rejects.toThrow(
|
|
38
|
+
'Invalid Assistant Cloud response for "thread_id": expected a string',
|
|
39
|
+
);
|
|
40
|
+
});
|
|
41
|
+
|
|
25
42
|
it("forwards both archive filter values", async () => {
|
|
26
43
|
const { threads, makeRequest } = createCloudThreads();
|
|
27
44
|
makeRequest.mockResolvedValue({ threads: [] });
|
|
@@ -117,7 +117,12 @@ export class AssistantCloudThreads {
|
|
|
117
117
|
public async create(
|
|
118
118
|
body: AssistantCloudThreadsCreateBody,
|
|
119
119
|
): Promise<AssistantCloudThreadsCreateResponse> {
|
|
120
|
-
|
|
120
|
+
const response = readCloudRecord(
|
|
121
|
+
await this.cloud.makeRequest("/threads", { method: "POST", body }),
|
|
122
|
+
"thread create response",
|
|
123
|
+
);
|
|
124
|
+
|
|
125
|
+
return { thread_id: readCloudString(response.thread_id, "thread_id") };
|
|
121
126
|
}
|
|
122
127
|
|
|
123
128
|
public async update(
|
|
@@ -11,6 +11,7 @@ const refreshToken = {
|
|
|
11
11
|
token: "r1",
|
|
12
12
|
expires_at: "2099-01-01",
|
|
13
13
|
};
|
|
14
|
+
const refreshTokenKey = `aui:refresh_token:${baseUrl}`;
|
|
14
15
|
|
|
15
16
|
let originalLocalStorageDescriptor: PropertyDescriptor | undefined;
|
|
16
17
|
|
|
@@ -79,9 +80,7 @@ describe("AssistantCloudAnonymousAuthStrategy", () => {
|
|
|
79
80
|
await expect(strategy.getAuthHeaders()).resolves.toEqual({
|
|
80
81
|
Authorization: `Bearer ${accessToken}`,
|
|
81
82
|
});
|
|
82
|
-
expect(values.get(
|
|
83
|
-
JSON.stringify(nextRefreshToken),
|
|
84
|
-
);
|
|
83
|
+
expect(values.get(refreshTokenKey)).toBe(JSON.stringify(nextRefreshToken));
|
|
85
84
|
expect(fetchMock).toHaveBeenCalledWith(
|
|
86
85
|
`${baseUrl}/v1/auth/tokens/anonymous`,
|
|
87
86
|
{ method: "POST" },
|
|
@@ -107,6 +106,183 @@ describe("AssistantCloudAnonymousAuthStrategy", () => {
|
|
|
107
106
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
108
107
|
});
|
|
109
108
|
|
|
109
|
+
it("scopes anonymous refresh tokens by backend", async () => {
|
|
110
|
+
const secondBaseUrl = "https://other.example.com";
|
|
111
|
+
const values = new Map<string, string>();
|
|
112
|
+
installLocalStorage({
|
|
113
|
+
getItem: (key) => values.get(key) ?? null,
|
|
114
|
+
setItem: (key, value) => {
|
|
115
|
+
values.set(key, value);
|
|
116
|
+
},
|
|
117
|
+
removeItem: (key) => {
|
|
118
|
+
values.delete(key);
|
|
119
|
+
},
|
|
120
|
+
} as Storage);
|
|
121
|
+
const secondRefreshToken = {
|
|
122
|
+
token: "r2",
|
|
123
|
+
expires_at: "2099-01-01",
|
|
124
|
+
};
|
|
125
|
+
const fetchMock = vi
|
|
126
|
+
.fn()
|
|
127
|
+
.mockResolvedValueOnce({
|
|
128
|
+
ok: true,
|
|
129
|
+
json: vi.fn().mockResolvedValue({
|
|
130
|
+
access_token: accessToken,
|
|
131
|
+
refresh_token: refreshToken,
|
|
132
|
+
}),
|
|
133
|
+
})
|
|
134
|
+
.mockResolvedValueOnce({
|
|
135
|
+
ok: true,
|
|
136
|
+
json: vi.fn().mockResolvedValue({
|
|
137
|
+
access_token: accessToken,
|
|
138
|
+
refresh_token: secondRefreshToken,
|
|
139
|
+
}),
|
|
140
|
+
});
|
|
141
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
142
|
+
|
|
143
|
+
await new AssistantCloudAnonymousAuthStrategy(baseUrl).getAuthHeaders();
|
|
144
|
+
await new AssistantCloudAnonymousAuthStrategy(
|
|
145
|
+
secondBaseUrl,
|
|
146
|
+
).getAuthHeaders();
|
|
147
|
+
|
|
148
|
+
expect(fetchMock).toHaveBeenNthCalledWith(
|
|
149
|
+
1,
|
|
150
|
+
`${baseUrl}/v1/auth/tokens/anonymous`,
|
|
151
|
+
{ method: "POST" },
|
|
152
|
+
);
|
|
153
|
+
expect(fetchMock).toHaveBeenNthCalledWith(
|
|
154
|
+
2,
|
|
155
|
+
`${secondBaseUrl}/v1/auth/tokens/anonymous`,
|
|
156
|
+
{ method: "POST" },
|
|
157
|
+
);
|
|
158
|
+
expect(values.get(refreshTokenKey)).toBe(JSON.stringify(refreshToken));
|
|
159
|
+
expect(values.get(`aui:refresh_token:${secondBaseUrl}`)).toBe(
|
|
160
|
+
JSON.stringify(secondRefreshToken),
|
|
161
|
+
);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it("migrates the legacy refresh token to the first backend", async () => {
|
|
165
|
+
const secondBaseUrl = "https://other.example.com";
|
|
166
|
+
const secondRefreshToken = {
|
|
167
|
+
token: "r2",
|
|
168
|
+
expires_at: "2099-01-01",
|
|
169
|
+
};
|
|
170
|
+
const values = new Map([
|
|
171
|
+
["aui:refresh_token", JSON.stringify(refreshToken)],
|
|
172
|
+
]);
|
|
173
|
+
installLocalStorage({
|
|
174
|
+
getItem: (key) => values.get(key) ?? null,
|
|
175
|
+
setItem: (key, value) => {
|
|
176
|
+
values.set(key, value);
|
|
177
|
+
},
|
|
178
|
+
removeItem: (key) => {
|
|
179
|
+
values.delete(key);
|
|
180
|
+
},
|
|
181
|
+
} as Storage);
|
|
182
|
+
const fetchMock = vi
|
|
183
|
+
.fn()
|
|
184
|
+
.mockResolvedValueOnce({
|
|
185
|
+
ok: true,
|
|
186
|
+
json: vi.fn().mockResolvedValue({ access_token: accessToken }),
|
|
187
|
+
})
|
|
188
|
+
.mockResolvedValueOnce({
|
|
189
|
+
ok: true,
|
|
190
|
+
json: vi.fn().mockResolvedValue({
|
|
191
|
+
access_token: accessToken,
|
|
192
|
+
refresh_token: secondRefreshToken,
|
|
193
|
+
}),
|
|
194
|
+
});
|
|
195
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
196
|
+
|
|
197
|
+
await new AssistantCloudAnonymousAuthStrategy(baseUrl).getAuthHeaders();
|
|
198
|
+
await new AssistantCloudAnonymousAuthStrategy(
|
|
199
|
+
secondBaseUrl,
|
|
200
|
+
).getAuthHeaders();
|
|
201
|
+
|
|
202
|
+
expect(fetchMock).toHaveBeenNthCalledWith(
|
|
203
|
+
1,
|
|
204
|
+
`${baseUrl}/v1/auth/tokens/refresh`,
|
|
205
|
+
{
|
|
206
|
+
method: "POST",
|
|
207
|
+
headers: { "Content-Type": "application/json" },
|
|
208
|
+
body: JSON.stringify({ refresh_token: refreshToken.token }),
|
|
209
|
+
},
|
|
210
|
+
);
|
|
211
|
+
expect(fetchMock).toHaveBeenNthCalledWith(
|
|
212
|
+
2,
|
|
213
|
+
`${secondBaseUrl}/v1/auth/tokens/anonymous`,
|
|
214
|
+
{ method: "POST" },
|
|
215
|
+
);
|
|
216
|
+
expect(values.get(refreshTokenKey)).toBe(JSON.stringify(refreshToken));
|
|
217
|
+
expect(values.get(`aui:refresh_token:${secondBaseUrl}`)).toBe(
|
|
218
|
+
JSON.stringify(secondRefreshToken),
|
|
219
|
+
);
|
|
220
|
+
expect(values.has("aui:refresh_token")).toBe(false);
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
it("retires the legacy refresh token when a scoped token exists", async () => {
|
|
224
|
+
const secondBaseUrl = "https://other.example.com";
|
|
225
|
+
const scopedRefreshToken = {
|
|
226
|
+
token: "scoped-r1",
|
|
227
|
+
expires_at: "2099-01-01",
|
|
228
|
+
};
|
|
229
|
+
const secondRefreshToken = {
|
|
230
|
+
token: "r2",
|
|
231
|
+
expires_at: "2099-01-01",
|
|
232
|
+
};
|
|
233
|
+
const values = new Map([
|
|
234
|
+
["aui:refresh_token", JSON.stringify(refreshToken)],
|
|
235
|
+
[refreshTokenKey, JSON.stringify(scopedRefreshToken)],
|
|
236
|
+
]);
|
|
237
|
+
installLocalStorage({
|
|
238
|
+
getItem: (key) => values.get(key) ?? null,
|
|
239
|
+
setItem: (key, value) => {
|
|
240
|
+
values.set(key, value);
|
|
241
|
+
},
|
|
242
|
+
removeItem: (key) => {
|
|
243
|
+
values.delete(key);
|
|
244
|
+
},
|
|
245
|
+
} as Storage);
|
|
246
|
+
const fetchMock = vi
|
|
247
|
+
.fn()
|
|
248
|
+
.mockResolvedValueOnce({
|
|
249
|
+
ok: true,
|
|
250
|
+
json: vi.fn().mockResolvedValue({ access_token: accessToken }),
|
|
251
|
+
})
|
|
252
|
+
.mockResolvedValueOnce({
|
|
253
|
+
ok: true,
|
|
254
|
+
json: vi.fn().mockResolvedValue({
|
|
255
|
+
access_token: accessToken,
|
|
256
|
+
refresh_token: secondRefreshToken,
|
|
257
|
+
}),
|
|
258
|
+
});
|
|
259
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
260
|
+
|
|
261
|
+
await new AssistantCloudAnonymousAuthStrategy(baseUrl).getAuthHeaders();
|
|
262
|
+
await new AssistantCloudAnonymousAuthStrategy(
|
|
263
|
+
secondBaseUrl,
|
|
264
|
+
).getAuthHeaders();
|
|
265
|
+
|
|
266
|
+
expect(fetchMock).toHaveBeenNthCalledWith(
|
|
267
|
+
1,
|
|
268
|
+
`${baseUrl}/v1/auth/tokens/refresh`,
|
|
269
|
+
{
|
|
270
|
+
method: "POST",
|
|
271
|
+
headers: { "Content-Type": "application/json" },
|
|
272
|
+
body: JSON.stringify({ refresh_token: scopedRefreshToken.token }),
|
|
273
|
+
},
|
|
274
|
+
);
|
|
275
|
+
expect(fetchMock).toHaveBeenNthCalledWith(
|
|
276
|
+
2,
|
|
277
|
+
`${secondBaseUrl}/v1/auth/tokens/anonymous`,
|
|
278
|
+
{ method: "POST" },
|
|
279
|
+
);
|
|
280
|
+
expect(values.has("aui:refresh_token")).toBe(false);
|
|
281
|
+
expect(values.get(`aui:refresh_token:${secondBaseUrl}`)).toBe(
|
|
282
|
+
JSON.stringify(secondRefreshToken),
|
|
283
|
+
);
|
|
284
|
+
});
|
|
285
|
+
|
|
110
286
|
it("returns an anonymous access token without localStorage", async () => {
|
|
111
287
|
delete (globalThis as { localStorage?: Storage }).localStorage;
|
|
112
288
|
mockAnonymousTokenFetch();
|
|
@@ -160,7 +336,7 @@ describe("AssistantCloudAnonymousAuthStrategy", () => {
|
|
|
160
336
|
).resolves.toEqual({ Authorization: `Bearer ${accessToken}` });
|
|
161
337
|
expect(getItem).toHaveBeenCalledTimes(2);
|
|
162
338
|
expect(setItem).toHaveBeenCalledTimes(2);
|
|
163
|
-
expect(removeItem).toHaveBeenCalledTimes(
|
|
339
|
+
expect(removeItem).toHaveBeenCalledTimes(2);
|
|
164
340
|
});
|
|
165
341
|
|
|
166
342
|
it("treats corrupted refresh token JSON as absent", async () => {
|
|
@@ -181,7 +357,8 @@ describe("AssistantCloudAnonymousAuthStrategy", () => {
|
|
|
181
357
|
await expect(strategy.getAuthHeaders()).resolves.toEqual({
|
|
182
358
|
Authorization: `Bearer ${accessToken}`,
|
|
183
359
|
});
|
|
184
|
-
expect(values.get(
|
|
360
|
+
expect(values.get(refreshTokenKey)).toBe(JSON.stringify(refreshToken));
|
|
361
|
+
expect(values.has("aui:refresh_token")).toBe(false);
|
|
185
362
|
});
|
|
186
363
|
|
|
187
364
|
it("rejects malformed anonymous token responses without persisting them", async () => {
|
|
@@ -298,6 +475,102 @@ describe("AssistantCloudAnonymousAuthStrategy", () => {
|
|
|
298
475
|
expect(setItem).not.toHaveBeenCalled();
|
|
299
476
|
});
|
|
300
477
|
|
|
478
|
+
it.each([429, 500, 503])(
|
|
479
|
+
"preserves the anonymous identity after transient status %i",
|
|
480
|
+
async (status) => {
|
|
481
|
+
const values = new Map([[refreshTokenKey, JSON.stringify(refreshToken)]]);
|
|
482
|
+
installLocalStorage({
|
|
483
|
+
getItem: (key) => values.get(key) ?? null,
|
|
484
|
+
setItem: (key, value) => {
|
|
485
|
+
values.set(key, value);
|
|
486
|
+
},
|
|
487
|
+
removeItem: (key) => {
|
|
488
|
+
values.delete(key);
|
|
489
|
+
},
|
|
490
|
+
} as Storage);
|
|
491
|
+
const fetchMock = vi.fn().mockResolvedValue({ ok: false, status });
|
|
492
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
493
|
+
|
|
494
|
+
const strategy = new AssistantCloudAnonymousAuthStrategy(baseUrl);
|
|
495
|
+
|
|
496
|
+
await expect(strategy.getAuthHeaders()).rejects.toThrow(
|
|
497
|
+
`Assistant Cloud token refresh failed with status ${status}`,
|
|
498
|
+
);
|
|
499
|
+
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
500
|
+
expect(values.get(refreshTokenKey)).toBe(JSON.stringify(refreshToken));
|
|
501
|
+
},
|
|
502
|
+
);
|
|
503
|
+
|
|
504
|
+
it.each([401, 403])(
|
|
505
|
+
"replaces an anonymous identity after refresh is rejected with %i",
|
|
506
|
+
async (status) => {
|
|
507
|
+
const replacementRefreshToken = {
|
|
508
|
+
token: "r2",
|
|
509
|
+
expires_at: "2099-02-01",
|
|
510
|
+
};
|
|
511
|
+
const values = new Map([[refreshTokenKey, JSON.stringify(refreshToken)]]);
|
|
512
|
+
installLocalStorage({
|
|
513
|
+
getItem: (key) => values.get(key) ?? null,
|
|
514
|
+
setItem: (key, value) => {
|
|
515
|
+
values.set(key, value);
|
|
516
|
+
},
|
|
517
|
+
removeItem: (key) => {
|
|
518
|
+
values.delete(key);
|
|
519
|
+
},
|
|
520
|
+
} as Storage);
|
|
521
|
+
const fetchMock = vi
|
|
522
|
+
.fn()
|
|
523
|
+
.mockResolvedValueOnce({ ok: false, status })
|
|
524
|
+
.mockResolvedValueOnce({
|
|
525
|
+
ok: true,
|
|
526
|
+
json: vi.fn().mockResolvedValue({
|
|
527
|
+
access_token: accessToken,
|
|
528
|
+
refresh_token: replacementRefreshToken,
|
|
529
|
+
}),
|
|
530
|
+
});
|
|
531
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
532
|
+
|
|
533
|
+
const strategy = new AssistantCloudAnonymousAuthStrategy(baseUrl);
|
|
534
|
+
|
|
535
|
+
await expect(strategy.getAuthHeaders()).resolves.toEqual({
|
|
536
|
+
Authorization: `Bearer ${accessToken}`,
|
|
537
|
+
});
|
|
538
|
+
expect(fetchMock).toHaveBeenNthCalledWith(
|
|
539
|
+
2,
|
|
540
|
+
`${baseUrl}/v1/auth/tokens/anonymous`,
|
|
541
|
+
{ method: "POST" },
|
|
542
|
+
);
|
|
543
|
+
expect(values.get(refreshTokenKey)).toBe(
|
|
544
|
+
JSON.stringify(replacementRefreshToken),
|
|
545
|
+
);
|
|
546
|
+
},
|
|
547
|
+
);
|
|
548
|
+
|
|
549
|
+
it("preserves a rejected refresh token until its replacement succeeds", async () => {
|
|
550
|
+
const values = new Map([[refreshTokenKey, JSON.stringify(refreshToken)]]);
|
|
551
|
+
installLocalStorage({
|
|
552
|
+
getItem: (key) => values.get(key) ?? null,
|
|
553
|
+
setItem: (key, value) => {
|
|
554
|
+
values.set(key, value);
|
|
555
|
+
},
|
|
556
|
+
removeItem: (key) => {
|
|
557
|
+
values.delete(key);
|
|
558
|
+
},
|
|
559
|
+
} as Storage);
|
|
560
|
+
vi.stubGlobal(
|
|
561
|
+
"fetch",
|
|
562
|
+
vi
|
|
563
|
+
.fn()
|
|
564
|
+
.mockResolvedValueOnce({ ok: false, status: 403 })
|
|
565
|
+
.mockResolvedValueOnce({ ok: false, status: 503 }),
|
|
566
|
+
);
|
|
567
|
+
|
|
568
|
+
const strategy = new AssistantCloudAnonymousAuthStrategy(baseUrl);
|
|
569
|
+
|
|
570
|
+
await expect(strategy.getAuthHeaders()).resolves.toBe(false);
|
|
571
|
+
expect(values.get(refreshTokenKey)).toBe(JSON.stringify(refreshToken));
|
|
572
|
+
});
|
|
573
|
+
|
|
301
574
|
it("contextualizes invalid JSON token responses", async () => {
|
|
302
575
|
delete (globalThis as { localStorage?: Storage }).localStorage;
|
|
303
576
|
vi.stubGlobal(
|