zitejs 0.9.118 → 0.9.120
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/cjs/auth/useAuth.test.d.ts +1 -0
- package/dist/cjs/auth/useAuth.test.js +96 -0
- package/dist/cjs/dev/index.js +10 -16
- package/dist/cjs/sync/lib.d.ts +6 -0
- package/dist/cjs/sync/lib.js +13 -0
- package/dist/cjs/sync/lib.test.js +28 -0
- package/dist/cjs/upload/index.d.ts +1 -0
- package/dist/cjs/upload/index.js +62 -40
- package/dist/cjs/upload/index.test.d.ts +1 -0
- package/dist/cjs/upload/index.test.js +68 -0
- package/dist/esm/auth/useAuth.test.d.ts +1 -0
- package/dist/esm/auth/useAuth.test.js +94 -0
- package/dist/esm/dev/index.js +11 -17
- package/dist/esm/sync/lib.d.ts +6 -0
- package/dist/esm/sync/lib.js +12 -0
- package/dist/esm/sync/lib.test.js +29 -1
- package/dist/esm/upload/index.d.ts +1 -0
- package/dist/esm/upload/index.js +61 -40
- package/dist/esm/upload/index.test.d.ts +1 -0
- package/dist/esm/upload/index.test.js +66 -0
- package/package.json +1 -1
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
const vitest_1 = require("vitest");
|
|
4
|
+
/**
|
|
5
|
+
* The signed-out redirect on internal apps.
|
|
6
|
+
*
|
|
7
|
+
* A separate file from `index.test.ts` because it has to replace
|
|
8
|
+
* `better-auth/react` wholesale to drive `useSession`, and that would strip the
|
|
9
|
+
* real client the export tests next door assert against.
|
|
10
|
+
*
|
|
11
|
+
* The value here is entirely in WHICH signed-out renders it reacts to. An
|
|
12
|
+
* external app has a real logged-out state, and an app built before the
|
|
13
|
+
* platform sent an access mode tells us nothing — redirecting either turns a
|
|
14
|
+
* working public page into a forced sign-in.
|
|
15
|
+
*/
|
|
16
|
+
const { useSessionMock } = vitest_1.vi.hoisted(() => ({ useSessionMock: vitest_1.vi.fn() }));
|
|
17
|
+
vitest_1.vi.mock('better-auth/react', () => ({
|
|
18
|
+
createAuthClient: () => ({
|
|
19
|
+
useSession: useSessionMock,
|
|
20
|
+
signIn: {},
|
|
21
|
+
signUp: {},
|
|
22
|
+
signOut: vitest_1.vi.fn(),
|
|
23
|
+
updateUser: vitest_1.vi.fn(),
|
|
24
|
+
}),
|
|
25
|
+
}));
|
|
26
|
+
vitest_1.vi.mock('better-auth/client/plugins', () => ({
|
|
27
|
+
magicLinkClient: () => ({}),
|
|
28
|
+
inferAdditionalFields: () => ({}),
|
|
29
|
+
}));
|
|
30
|
+
// `useAuth` takes only `useEffect` from React. Running it inline is the whole
|
|
31
|
+
// of what a render would do here, and avoids pulling in a renderer.
|
|
32
|
+
vitest_1.vi.mock('react', () => ({ useEffect: (fn) => fn() }));
|
|
33
|
+
const index_js_1 = require("./index.js");
|
|
34
|
+
const APP_URL = 'https://app.zite.so/dashboard';
|
|
35
|
+
function stubLocation(href) {
|
|
36
|
+
const location = { pathname: new URL(href).pathname, href };
|
|
37
|
+
vitest_1.vi.stubGlobal('window', { location });
|
|
38
|
+
return location;
|
|
39
|
+
}
|
|
40
|
+
const signedOut = { data: null, isPending: false };
|
|
41
|
+
(0, vitest_1.beforeEach)(() => {
|
|
42
|
+
delete process.env.ZITE_ACCESS_MODE;
|
|
43
|
+
});
|
|
44
|
+
(0, vitest_1.afterEach)(() => {
|
|
45
|
+
delete process.env.ZITE_ACCESS_MODE;
|
|
46
|
+
vitest_1.vi.unstubAllGlobals();
|
|
47
|
+
useSessionMock.mockReset();
|
|
48
|
+
});
|
|
49
|
+
const render = (session, { accessMode, href = APP_URL } = {}) => {
|
|
50
|
+
if (accessMode !== undefined)
|
|
51
|
+
process.env.ZITE_ACCESS_MODE = accessMode;
|
|
52
|
+
const location = stubLocation(href);
|
|
53
|
+
useSessionMock.mockReturnValue(session);
|
|
54
|
+
return { result: (0, index_js_1.useAuth)(), location };
|
|
55
|
+
};
|
|
56
|
+
(0, vitest_1.describe)('useAuth signed-out handling', () => {
|
|
57
|
+
(0, vitest_1.it)('sends a signed-out visitor on an internal app to sign-in', () => {
|
|
58
|
+
const { result, location } = render(signedOut, { accessMode: 'internal' });
|
|
59
|
+
(0, vitest_1.expect)(location.href.startsWith('/auth/login')).toBe(true);
|
|
60
|
+
// Reported as loading rather than signed out: the signed-out branch of an
|
|
61
|
+
// internal app is the empty screen this exists to prevent.
|
|
62
|
+
(0, vitest_1.expect)(result.isLoading).toBe(true);
|
|
63
|
+
});
|
|
64
|
+
(0, vitest_1.it)('leaves a signed-out visitor on an external app alone', () => {
|
|
65
|
+
const { result, location } = render(signedOut, { accessMode: 'external' });
|
|
66
|
+
(0, vitest_1.expect)(location.href).toBe(APP_URL);
|
|
67
|
+
(0, vitest_1.expect)(result.isLoading).toBe(false);
|
|
68
|
+
(0, vitest_1.expect)(result.user).toBe(null);
|
|
69
|
+
});
|
|
70
|
+
// Every app published before the platform started sending an access mode.
|
|
71
|
+
(0, vitest_1.it)('leaves an app that never declared an access mode alone', () => {
|
|
72
|
+
const { result, location } = render(signedOut);
|
|
73
|
+
(0, vitest_1.expect)(location.href).toBe(APP_URL);
|
|
74
|
+
(0, vitest_1.expect)(result.isLoading).toBe(false);
|
|
75
|
+
});
|
|
76
|
+
(0, vitest_1.it)('does not redirect while the session is still resolving', () => {
|
|
77
|
+
const { result, location } = render({ data: null, isPending: true }, { accessMode: 'internal' });
|
|
78
|
+
(0, vitest_1.expect)(location.href).toBe(APP_URL);
|
|
79
|
+
(0, vitest_1.expect)(result.isLoading).toBe(true);
|
|
80
|
+
});
|
|
81
|
+
(0, vitest_1.it)('does not redirect a signed-in visitor', () => {
|
|
82
|
+
const { result, location } = render({ data: { user: { id: 'u1', email: 'a@b.com' } }, isPending: false }, { accessMode: 'internal' });
|
|
83
|
+
(0, vitest_1.expect)(location.href).toBe(APP_URL);
|
|
84
|
+
(0, vitest_1.expect)(result.isLoading).toBe(false);
|
|
85
|
+
(0, vitest_1.expect)(result.user).toMatchObject({ id: 'u1' });
|
|
86
|
+
});
|
|
87
|
+
// `loginWithRedirect` refuses to navigate away from an auth page, so claiming
|
|
88
|
+
// a redirect here would hang the caller on `isLoading` forever.
|
|
89
|
+
(0, vitest_1.it)('does not claim to be loading on an auth page it cannot leave', () => {
|
|
90
|
+
const { result } = render(signedOut, {
|
|
91
|
+
accessMode: 'internal',
|
|
92
|
+
href: 'https://app.zite.so/auth/login',
|
|
93
|
+
});
|
|
94
|
+
(0, vitest_1.expect)(result.isLoading).toBe(false);
|
|
95
|
+
});
|
|
96
|
+
});
|
package/dist/cjs/dev/index.js
CHANGED
|
@@ -33,24 +33,18 @@ function getFlowId(appDir) {
|
|
|
33
33
|
catch { }
|
|
34
34
|
return undefined;
|
|
35
35
|
}
|
|
36
|
-
|
|
37
|
-
* Find the connected email integration's key in an app's zite.config.json,
|
|
38
|
-
* if any. The key is the integrationId used by the runtime SDK bridge.
|
|
39
|
-
*/
|
|
40
|
-
function getEmailIntegrationId(appDir) {
|
|
36
|
+
const readJsonFile = (path) => {
|
|
41
37
|
try {
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
const config = JSON.parse((0, fs_2.readFileSync)(configPath, "utf-8"));
|
|
46
|
-
const integrations = config.integrations ?? {};
|
|
47
|
-
for (const [id, int] of Object.entries(integrations)) {
|
|
48
|
-
if (int?.type === "email")
|
|
49
|
-
return id;
|
|
50
|
-
}
|
|
38
|
+
return (0, fs_2.existsSync)(path)
|
|
39
|
+
? JSON.parse((0, fs_2.readFileSync)(path, "utf-8"))
|
|
40
|
+
: undefined;
|
|
51
41
|
}
|
|
52
|
-
catch {
|
|
53
|
-
|
|
42
|
+
catch {
|
|
43
|
+
return undefined;
|
|
44
|
+
}
|
|
45
|
+
};
|
|
46
|
+
function getEmailIntegrationId(appDir) {
|
|
47
|
+
return (0, lib_js_1.findEmailIntegrationId)(readJsonFile((0, path_1.join)("apps", appDir, "zite.config.json")), readJsonFile("zite.config.json"));
|
|
54
48
|
}
|
|
55
49
|
function getDeclaredEnvVarNames(appDir) {
|
|
56
50
|
try {
|
package/dist/cjs/sync/lib.d.ts
CHANGED
|
@@ -82,6 +82,12 @@ export declare function normalizeAirtableLockNames(lock: AirtableLock): {
|
|
|
82
82
|
};
|
|
83
83
|
export declare function generateAirtableTs(inputLock: AirtableLock): string | null;
|
|
84
84
|
export declare function generateBackendWrapperTs(envVarNames?: string[]): string;
|
|
85
|
+
/**
|
|
86
|
+
* The key of the email integration an app sends through, which is the
|
|
87
|
+
* integrationId the runtime SDK bridge dispatches on. The app's own entry wins;
|
|
88
|
+
* otherwise it is the workspace email the app has `integrationSettings` for.
|
|
89
|
+
*/
|
|
90
|
+
export declare function findEmailIntegrationId(appConfig: unknown, workspaceConfig: unknown): string | undefined;
|
|
85
91
|
/**
|
|
86
92
|
* Generate `.zite/integrations/email.ts` — the `Email` client for an app with
|
|
87
93
|
* an email integration connected. Mirrors the airtable SDK generation: a thin
|
package/dist/cjs/sync/lib.js
CHANGED
|
@@ -7,6 +7,7 @@ exports.generateApiTs = generateApiTs;
|
|
|
7
7
|
exports.normalizeAirtableLockNames = normalizeAirtableLockNames;
|
|
8
8
|
exports.generateAirtableTs = generateAirtableTs;
|
|
9
9
|
exports.generateBackendWrapperTs = generateBackendWrapperTs;
|
|
10
|
+
exports.findEmailIntegrationId = findEmailIntegrationId;
|
|
10
11
|
exports.generateEmailSdk = generateEmailSdk;
|
|
11
12
|
const parser_1 = require("@babel/parser");
|
|
12
13
|
const sdkNames_js_1 = require("./sdkNames.js");
|
|
@@ -1346,6 +1347,18 @@ function generateBackendWrapperTs(envVarNames = []) {
|
|
|
1346
1347
|
"",
|
|
1347
1348
|
].join("\n");
|
|
1348
1349
|
}
|
|
1350
|
+
/**
|
|
1351
|
+
* The key of the email integration an app sends through, which is the
|
|
1352
|
+
* integrationId the runtime SDK bridge dispatches on. The app's own entry wins;
|
|
1353
|
+
* otherwise it is the workspace email the app has `integrationSettings` for.
|
|
1354
|
+
*/
|
|
1355
|
+
function findEmailIntegrationId(appConfig, workspaceConfig) {
|
|
1356
|
+
const app = (appConfig ?? {});
|
|
1357
|
+
const workspace = (workspaceConfig ?? {});
|
|
1358
|
+
const isEmail = (config, id) => config.integrations?.[id]?.type === "email";
|
|
1359
|
+
return (Object.keys(app.integrations ?? {}).find((id) => isEmail(app, id)) ??
|
|
1360
|
+
Object.keys(app.integrationSettings ?? {}).find((id) => isEmail(workspace, id)));
|
|
1361
|
+
}
|
|
1349
1362
|
/**
|
|
1350
1363
|
* Generate `.zite/integrations/email.ts` — the `Email` client for an app with
|
|
1351
1364
|
* an email integration connected. Mirrors the airtable SDK generation: a thin
|
|
@@ -385,3 +385,31 @@ const duplicateIdentifierErrorsIn = (source) => {
|
|
|
385
385
|
(0, vitest_1.expect)(output).toContain('ZiteError');
|
|
386
386
|
});
|
|
387
387
|
});
|
|
388
|
+
(0, vitest_1.describe)('findEmailIntegrationId', () => {
|
|
389
|
+
const workspace = {
|
|
390
|
+
integrations: {
|
|
391
|
+
slack: { type: 'slack' },
|
|
392
|
+
'team-email': { type: 'email' },
|
|
393
|
+
},
|
|
394
|
+
};
|
|
395
|
+
(0, vitest_1.it)('finds the app\'s own email entry', () => {
|
|
396
|
+
(0, vitest_1.expect)((0, lib_js_1.findEmailIntegrationId)({ integrations: { mail: { type: 'email' } } }, workspace)).toBe('mail');
|
|
397
|
+
});
|
|
398
|
+
(0, vitest_1.it)('finds the workspace email the app has settings for', () => {
|
|
399
|
+
(0, vitest_1.expect)((0, lib_js_1.findEmailIntegrationId)({ integrationSettings: { slack: {}, 'team-email': { fromName: 'Ops' } } }, workspace)).toBe('team-email');
|
|
400
|
+
});
|
|
401
|
+
// An app that never opted in generates no client, so `zitejs/email` stays
|
|
402
|
+
// unresolved there instead of sending as a sender it never chose.
|
|
403
|
+
(0, vitest_1.it)('ignores a workspace email the app has no settings for', () => {
|
|
404
|
+
(0, vitest_1.expect)((0, lib_js_1.findEmailIntegrationId)({}, workspace)).toBeUndefined();
|
|
405
|
+
});
|
|
406
|
+
(0, vitest_1.it)('prefers the app\'s own entry over a workspace one', () => {
|
|
407
|
+
(0, vitest_1.expect)((0, lib_js_1.findEmailIntegrationId)({
|
|
408
|
+
integrations: { mail: { type: 'email' } },
|
|
409
|
+
integrationSettings: { 'team-email': {} },
|
|
410
|
+
}, workspace)).toBe('mail');
|
|
411
|
+
});
|
|
412
|
+
(0, vitest_1.it)('tolerates missing configs', () => {
|
|
413
|
+
(0, vitest_1.expect)((0, lib_js_1.findEmailIntegrationId)(undefined, undefined)).toBeUndefined();
|
|
414
|
+
});
|
|
415
|
+
});
|
|
@@ -2,6 +2,7 @@ export type UploadData = string | Blob | ArrayBuffer | File;
|
|
|
2
2
|
export declare class FileUploadError extends Error {
|
|
3
3
|
constructor(message: string);
|
|
4
4
|
}
|
|
5
|
+
export declare function toUploadBlob(data: UploadData): Blob;
|
|
5
6
|
export declare function uploadFile({ data, filename, }: {
|
|
6
7
|
data: UploadData;
|
|
7
8
|
filename: string;
|
package/dist/cjs/upload/index.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.FileUploadError = void 0;
|
|
4
|
+
exports.toUploadBlob = toUploadBlob;
|
|
4
5
|
exports.uploadFile = uploadFile;
|
|
5
6
|
exports.useUpload = useUpload;
|
|
6
7
|
const react_1 = require("react");
|
|
@@ -24,33 +25,35 @@ function getZiteAppMode(hostname) {
|
|
|
24
25
|
}
|
|
25
26
|
return 'live';
|
|
26
27
|
}
|
|
27
|
-
function
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
28
|
+
function decodeDataUrl(dataUrl) {
|
|
29
|
+
const comma = dataUrl.indexOf(',');
|
|
30
|
+
if (comma === -1) {
|
|
31
|
+
throw new FileUploadError('Invalid data URL');
|
|
32
|
+
}
|
|
33
|
+
const header = dataUrl.slice(0, comma);
|
|
34
|
+
const payload = dataUrl.slice(comma + 1);
|
|
35
|
+
const mimeMatch = /^data:([^;,]*)/.exec(header);
|
|
36
|
+
const mimeType = mimeMatch?.[1] || 'application/octet-stream';
|
|
37
|
+
const bytes = Uint8Array.from(atob(payload), c => c.charCodeAt(0));
|
|
38
|
+
return new Blob([bytes], { type: mimeType });
|
|
34
39
|
}
|
|
35
|
-
|
|
36
|
-
if (data instanceof Blob) {
|
|
37
|
-
return
|
|
40
|
+
function toUploadBlob(data) {
|
|
41
|
+
if (typeof Blob !== 'undefined' && data instanceof Blob) {
|
|
42
|
+
return data;
|
|
38
43
|
}
|
|
39
44
|
if (data instanceof ArrayBuffer) {
|
|
40
|
-
|
|
41
|
-
let binary = '';
|
|
42
|
-
for (let i = 0; i < bytes.byteLength; i++) {
|
|
43
|
-
binary += String.fromCharCode(bytes[i]);
|
|
44
|
-
}
|
|
45
|
-
return 'data:application/octet-stream;base64,' + btoa(binary);
|
|
45
|
+
return new Blob([data], { type: 'application/octet-stream' });
|
|
46
46
|
}
|
|
47
47
|
if (typeof data === 'string') {
|
|
48
48
|
if (data.startsWith('data:'))
|
|
49
|
-
return data;
|
|
50
|
-
|
|
51
|
-
|
|
49
|
+
return decodeDataUrl(data);
|
|
50
|
+
// Only treat as raw base64 when the string is padded/aligned — otherwise
|
|
51
|
+
// short text like "hello" matches the alphabet and atob throws.
|
|
52
|
+
if (data.length % 4 === 0 && /^[A-Za-z0-9+/]+={0,2}$/.test(data)) {
|
|
53
|
+
const bytes = Uint8Array.from(atob(data), c => c.charCodeAt(0));
|
|
54
|
+
return new Blob([bytes], { type: 'application/octet-stream' });
|
|
52
55
|
}
|
|
53
|
-
return
|
|
56
|
+
return new Blob([data], { type: 'text/plain' });
|
|
54
57
|
}
|
|
55
58
|
throw new FileUploadError('Invalid data format. Expected string, Blob, ArrayBuffer, or File.');
|
|
56
59
|
}
|
|
@@ -62,36 +65,55 @@ function resolveFilename(data, filename) {
|
|
|
62
65
|
}
|
|
63
66
|
throw new FileUploadError('A filename is required unless the uploaded data is a File.');
|
|
64
67
|
}
|
|
65
|
-
async function
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
68
|
+
async function readErrorMessage(res, fallback) {
|
|
69
|
+
try {
|
|
70
|
+
const err = (await res.json());
|
|
71
|
+
if (err.message)
|
|
72
|
+
return err.message;
|
|
73
|
+
}
|
|
74
|
+
catch { }
|
|
75
|
+
return fallback;
|
|
76
|
+
}
|
|
77
|
+
function authHeaders() {
|
|
69
78
|
const token = typeof localStorage !== 'undefined'
|
|
70
79
|
? localStorage.getItem('zite.auth.token')
|
|
71
80
|
: null;
|
|
72
|
-
|
|
81
|
+
return token ? { Authorization: 'Bearer ' + token } : {};
|
|
82
|
+
}
|
|
83
|
+
async function performUpload(data, filename) {
|
|
84
|
+
const blob = toUploadBlob(data);
|
|
85
|
+
const mode = getZiteAppMode(window.location.hostname);
|
|
86
|
+
const flowId = (0, config_js_1.getFlowId)();
|
|
87
|
+
const sessionRes = await fetch((0, config_js_1.getApiUrl)() + '/v1/zite/public/' + flowId + '/upload-session?mode=' + mode, {
|
|
73
88
|
method: 'POST',
|
|
74
89
|
headers: {
|
|
75
90
|
'Content-Type': 'application/json',
|
|
76
|
-
...(
|
|
91
|
+
...authHeaders(),
|
|
77
92
|
},
|
|
78
|
-
body: JSON.stringify({
|
|
93
|
+
body: JSON.stringify({
|
|
94
|
+
filename,
|
|
95
|
+
uploadLength: blob.size,
|
|
96
|
+
contentType: blob.type || 'application/octet-stream',
|
|
97
|
+
}),
|
|
79
98
|
});
|
|
80
|
-
if (!
|
|
81
|
-
|
|
82
|
-
try {
|
|
83
|
-
const err = (await res.json());
|
|
84
|
-
if (err.message)
|
|
85
|
-
message = err.message;
|
|
86
|
-
}
|
|
87
|
-
catch { }
|
|
88
|
-
throw new FileUploadError(message);
|
|
99
|
+
if (!sessionRes.ok) {
|
|
100
|
+
throw new FileUploadError(await readErrorMessage(sessionRes, 'Upload failed'));
|
|
89
101
|
}
|
|
90
|
-
const
|
|
91
|
-
if (!
|
|
92
|
-
throw new FileUploadError(
|
|
102
|
+
const session = (await sessionRes.json());
|
|
103
|
+
if (!session.success || !session.presignedUrl || !session.fileUrl) {
|
|
104
|
+
throw new FileUploadError(session.message ?? 'Upload failed');
|
|
105
|
+
}
|
|
106
|
+
const putRes = await fetch(session.presignedUrl, {
|
|
107
|
+
method: 'PUT',
|
|
108
|
+
headers: {
|
|
109
|
+
'Content-Type': session.contentType || blob.type || 'application/octet-stream',
|
|
110
|
+
},
|
|
111
|
+
body: blob,
|
|
112
|
+
});
|
|
113
|
+
if (!putRes.ok) {
|
|
114
|
+
throw new FileUploadError('Upload failed');
|
|
93
115
|
}
|
|
94
|
-
return
|
|
116
|
+
return session.fileUrl;
|
|
95
117
|
}
|
|
96
118
|
async function uploadFile({ data, filename, }) {
|
|
97
119
|
return { fileUrl: await performUpload(data, filename) };
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
const vitest_1 = require("vitest");
|
|
4
|
+
const index_js_1 = require("./index.js");
|
|
5
|
+
vitest_1.vi.mock('../auth/config.js', () => ({
|
|
6
|
+
getApiUrl: () => 'https://api.example.com',
|
|
7
|
+
getFlowId: () => 'app_123',
|
|
8
|
+
}));
|
|
9
|
+
const fetchMock = vitest_1.vi.fn();
|
|
10
|
+
(0, vitest_1.beforeEach)(() => {
|
|
11
|
+
fetchMock.mockReset();
|
|
12
|
+
vitest_1.vi.stubGlobal('fetch', fetchMock);
|
|
13
|
+
vitest_1.vi.stubGlobal('window', { location: { hostname: 'portal.acme.com' } });
|
|
14
|
+
vitest_1.vi.stubGlobal('localStorage', { getItem: () => null });
|
|
15
|
+
});
|
|
16
|
+
(0, vitest_1.afterEach)(() => {
|
|
17
|
+
vitest_1.vi.unstubAllGlobals();
|
|
18
|
+
});
|
|
19
|
+
(0, vitest_1.describe)('toUploadBlob', () => {
|
|
20
|
+
(0, vitest_1.it)('wraps a string as text/plain and preserves File/Blob size', () => {
|
|
21
|
+
(0, vitest_1.expect)((0, index_js_1.toUploadBlob)('hello').size).toBe(5);
|
|
22
|
+
(0, vitest_1.expect)((0, index_js_1.toUploadBlob)('hello').type).toBe('text/plain');
|
|
23
|
+
const file = new File(['abc'], 'note.txt', { type: 'text/plain' });
|
|
24
|
+
(0, vitest_1.expect)((0, index_js_1.toUploadBlob)(file).size).toBe(3);
|
|
25
|
+
});
|
|
26
|
+
});
|
|
27
|
+
(0, vitest_1.describe)('uploadFile', () => {
|
|
28
|
+
(0, vitest_1.it)('requests a session then PUTs the bytes to the presigned URL', async () => {
|
|
29
|
+
const file = new File(['hello-world'], 'hello.txt', { type: 'text/plain' });
|
|
30
|
+
fetchMock
|
|
31
|
+
.mockResolvedValueOnce({
|
|
32
|
+
ok: true,
|
|
33
|
+
json: async () => ({
|
|
34
|
+
success: true,
|
|
35
|
+
presignedUrl: 'https://s3.example.com/put',
|
|
36
|
+
fileUrl: 'https://uploads.zite.com/orgid-1/zite-uploads/app_123/hello.txt',
|
|
37
|
+
contentType: 'text/plain',
|
|
38
|
+
}),
|
|
39
|
+
})
|
|
40
|
+
.mockResolvedValueOnce({ ok: true });
|
|
41
|
+
const result = await (0, index_js_1.uploadFile)({ data: file, filename: 'hello.txt' });
|
|
42
|
+
(0, vitest_1.expect)(result.fileUrl).toContain('hello.txt');
|
|
43
|
+
(0, vitest_1.expect)(fetchMock).toHaveBeenCalledTimes(2);
|
|
44
|
+
(0, vitest_1.expect)(fetchMock.mock.calls[0][0]).toBe('https://api.example.com/v1/zite/public/app_123/upload-session?mode=live');
|
|
45
|
+
const sessionInit = fetchMock.mock.calls[0][1];
|
|
46
|
+
(0, vitest_1.expect)(JSON.parse(sessionInit.body)).toEqual({
|
|
47
|
+
filename: 'hello.txt',
|
|
48
|
+
uploadLength: 11,
|
|
49
|
+
contentType: 'text/plain',
|
|
50
|
+
});
|
|
51
|
+
(0, vitest_1.expect)(fetchMock.mock.calls[1][0]).toBe('https://s3.example.com/put');
|
|
52
|
+
(0, vitest_1.expect)(fetchMock.mock.calls[1][1].method).toBe('PUT');
|
|
53
|
+
});
|
|
54
|
+
(0, vitest_1.it)('surfaces the plan-limit message from the session endpoint', async () => {
|
|
55
|
+
fetchMock.mockResolvedValueOnce({
|
|
56
|
+
ok: false,
|
|
57
|
+
json: async () => ({
|
|
58
|
+
message: 'File size exceeds maximum of 20 MB',
|
|
59
|
+
}),
|
|
60
|
+
});
|
|
61
|
+
const upload = (0, index_js_1.uploadFile)({
|
|
62
|
+
data: new File(['x'], 'big.bin'),
|
|
63
|
+
filename: 'big.bin',
|
|
64
|
+
});
|
|
65
|
+
await (0, vitest_1.expect)(upload).rejects.toBeInstanceOf(index_js_1.FileUploadError);
|
|
66
|
+
await (0, vitest_1.expect)(upload).rejects.toThrow('File size exceeds maximum of 20 MB');
|
|
67
|
+
});
|
|
68
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { describe, it, expect, afterEach, beforeEach, vi } from 'vitest';
|
|
2
|
+
/**
|
|
3
|
+
* The signed-out redirect on internal apps.
|
|
4
|
+
*
|
|
5
|
+
* A separate file from `index.test.ts` because it has to replace
|
|
6
|
+
* `better-auth/react` wholesale to drive `useSession`, and that would strip the
|
|
7
|
+
* real client the export tests next door assert against.
|
|
8
|
+
*
|
|
9
|
+
* The value here is entirely in WHICH signed-out renders it reacts to. An
|
|
10
|
+
* external app has a real logged-out state, and an app built before the
|
|
11
|
+
* platform sent an access mode tells us nothing — redirecting either turns a
|
|
12
|
+
* working public page into a forced sign-in.
|
|
13
|
+
*/
|
|
14
|
+
const { useSessionMock } = vi.hoisted(() => ({ useSessionMock: vi.fn() }));
|
|
15
|
+
vi.mock('better-auth/react', () => ({
|
|
16
|
+
createAuthClient: () => ({
|
|
17
|
+
useSession: useSessionMock,
|
|
18
|
+
signIn: {},
|
|
19
|
+
signUp: {},
|
|
20
|
+
signOut: vi.fn(),
|
|
21
|
+
updateUser: vi.fn(),
|
|
22
|
+
}),
|
|
23
|
+
}));
|
|
24
|
+
vi.mock('better-auth/client/plugins', () => ({
|
|
25
|
+
magicLinkClient: () => ({}),
|
|
26
|
+
inferAdditionalFields: () => ({}),
|
|
27
|
+
}));
|
|
28
|
+
// `useAuth` takes only `useEffect` from React. Running it inline is the whole
|
|
29
|
+
// of what a render would do here, and avoids pulling in a renderer.
|
|
30
|
+
vi.mock('react', () => ({ useEffect: (fn) => fn() }));
|
|
31
|
+
import { useAuth } from './index.js';
|
|
32
|
+
const APP_URL = 'https://app.zite.so/dashboard';
|
|
33
|
+
function stubLocation(href) {
|
|
34
|
+
const location = { pathname: new URL(href).pathname, href };
|
|
35
|
+
vi.stubGlobal('window', { location });
|
|
36
|
+
return location;
|
|
37
|
+
}
|
|
38
|
+
const signedOut = { data: null, isPending: false };
|
|
39
|
+
beforeEach(() => {
|
|
40
|
+
delete process.env.ZITE_ACCESS_MODE;
|
|
41
|
+
});
|
|
42
|
+
afterEach(() => {
|
|
43
|
+
delete process.env.ZITE_ACCESS_MODE;
|
|
44
|
+
vi.unstubAllGlobals();
|
|
45
|
+
useSessionMock.mockReset();
|
|
46
|
+
});
|
|
47
|
+
const render = (session, { accessMode, href = APP_URL } = {}) => {
|
|
48
|
+
if (accessMode !== undefined)
|
|
49
|
+
process.env.ZITE_ACCESS_MODE = accessMode;
|
|
50
|
+
const location = stubLocation(href);
|
|
51
|
+
useSessionMock.mockReturnValue(session);
|
|
52
|
+
return { result: useAuth(), location };
|
|
53
|
+
};
|
|
54
|
+
describe('useAuth signed-out handling', () => {
|
|
55
|
+
it('sends a signed-out visitor on an internal app to sign-in', () => {
|
|
56
|
+
const { result, location } = render(signedOut, { accessMode: 'internal' });
|
|
57
|
+
expect(location.href.startsWith('/auth/login')).toBe(true);
|
|
58
|
+
// Reported as loading rather than signed out: the signed-out branch of an
|
|
59
|
+
// internal app is the empty screen this exists to prevent.
|
|
60
|
+
expect(result.isLoading).toBe(true);
|
|
61
|
+
});
|
|
62
|
+
it('leaves a signed-out visitor on an external app alone', () => {
|
|
63
|
+
const { result, location } = render(signedOut, { accessMode: 'external' });
|
|
64
|
+
expect(location.href).toBe(APP_URL);
|
|
65
|
+
expect(result.isLoading).toBe(false);
|
|
66
|
+
expect(result.user).toBe(null);
|
|
67
|
+
});
|
|
68
|
+
// Every app published before the platform started sending an access mode.
|
|
69
|
+
it('leaves an app that never declared an access mode alone', () => {
|
|
70
|
+
const { result, location } = render(signedOut);
|
|
71
|
+
expect(location.href).toBe(APP_URL);
|
|
72
|
+
expect(result.isLoading).toBe(false);
|
|
73
|
+
});
|
|
74
|
+
it('does not redirect while the session is still resolving', () => {
|
|
75
|
+
const { result, location } = render({ data: null, isPending: true }, { accessMode: 'internal' });
|
|
76
|
+
expect(location.href).toBe(APP_URL);
|
|
77
|
+
expect(result.isLoading).toBe(true);
|
|
78
|
+
});
|
|
79
|
+
it('does not redirect a signed-in visitor', () => {
|
|
80
|
+
const { result, location } = render({ data: { user: { id: 'u1', email: 'a@b.com' } }, isPending: false }, { accessMode: 'internal' });
|
|
81
|
+
expect(location.href).toBe(APP_URL);
|
|
82
|
+
expect(result.isLoading).toBe(false);
|
|
83
|
+
expect(result.user).toMatchObject({ id: 'u1' });
|
|
84
|
+
});
|
|
85
|
+
// `loginWithRedirect` refuses to navigate away from an auth page, so claiming
|
|
86
|
+
// a redirect here would hang the caller on `isLoading` forever.
|
|
87
|
+
it('does not claim to be loading on an auth page it cannot leave', () => {
|
|
88
|
+
const { result } = render(signedOut, {
|
|
89
|
+
accessMode: 'internal',
|
|
90
|
+
href: 'https://app.zite.so/auth/login',
|
|
91
|
+
});
|
|
92
|
+
expect(result.isLoading).toBe(false);
|
|
93
|
+
});
|
|
94
|
+
});
|
package/dist/esm/dev/index.js
CHANGED
|
@@ -2,7 +2,7 @@ import { watch } from "fs";
|
|
|
2
2
|
import { existsSync, readdirSync, readFileSync, writeFileSync, mkdirSync, } from "fs";
|
|
3
3
|
import { join } from "path";
|
|
4
4
|
import { runSync } from "../sync/index.js";
|
|
5
|
-
import { generateDbTs, generateApiTs, generateBackendWrapperTs, generateAirtableTs, generateEmailSdk, } from "../sync/lib.js";
|
|
5
|
+
import { generateDbTs, generateApiTs, generateBackendWrapperTs, generateAirtableTs, findEmailIntegrationId, generateEmailSdk, } from "../sync/lib.js";
|
|
6
6
|
const debounceTimers = new Map();
|
|
7
7
|
function debounce(key, fn, ms) {
|
|
8
8
|
const existing = debounceTimers.get(key);
|
|
@@ -29,24 +29,18 @@ function getFlowId(appDir) {
|
|
|
29
29
|
catch { }
|
|
30
30
|
return undefined;
|
|
31
31
|
}
|
|
32
|
-
|
|
33
|
-
* Find the connected email integration's key in an app's zite.config.json,
|
|
34
|
-
* if any. The key is the integrationId used by the runtime SDK bridge.
|
|
35
|
-
*/
|
|
36
|
-
function getEmailIntegrationId(appDir) {
|
|
32
|
+
const readJsonFile = (path) => {
|
|
37
33
|
try {
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
const config = JSON.parse(readFileSync(configPath, "utf-8"));
|
|
42
|
-
const integrations = config.integrations ?? {};
|
|
43
|
-
for (const [id, int] of Object.entries(integrations)) {
|
|
44
|
-
if (int?.type === "email")
|
|
45
|
-
return id;
|
|
46
|
-
}
|
|
34
|
+
return existsSync(path)
|
|
35
|
+
? JSON.parse(readFileSync(path, "utf-8"))
|
|
36
|
+
: undefined;
|
|
47
37
|
}
|
|
48
|
-
catch {
|
|
49
|
-
|
|
38
|
+
catch {
|
|
39
|
+
return undefined;
|
|
40
|
+
}
|
|
41
|
+
};
|
|
42
|
+
function getEmailIntegrationId(appDir) {
|
|
43
|
+
return findEmailIntegrationId(readJsonFile(join("apps", appDir, "zite.config.json")), readJsonFile("zite.config.json"));
|
|
50
44
|
}
|
|
51
45
|
function getDeclaredEnvVarNames(appDir) {
|
|
52
46
|
try {
|
package/dist/esm/sync/lib.d.ts
CHANGED
|
@@ -82,6 +82,12 @@ export declare function normalizeAirtableLockNames(lock: AirtableLock): {
|
|
|
82
82
|
};
|
|
83
83
|
export declare function generateAirtableTs(inputLock: AirtableLock): string | null;
|
|
84
84
|
export declare function generateBackendWrapperTs(envVarNames?: string[]): string;
|
|
85
|
+
/**
|
|
86
|
+
* The key of the email integration an app sends through, which is the
|
|
87
|
+
* integrationId the runtime SDK bridge dispatches on. The app's own entry wins;
|
|
88
|
+
* otherwise it is the workspace email the app has `integrationSettings` for.
|
|
89
|
+
*/
|
|
90
|
+
export declare function findEmailIntegrationId(appConfig: unknown, workspaceConfig: unknown): string | undefined;
|
|
85
91
|
/**
|
|
86
92
|
* Generate `.zite/integrations/email.ts` — the `Email` client for an app with
|
|
87
93
|
* an email integration connected. Mirrors the airtable SDK generation: a thin
|
package/dist/esm/sync/lib.js
CHANGED
|
@@ -1333,6 +1333,18 @@ export function generateBackendWrapperTs(envVarNames = []) {
|
|
|
1333
1333
|
"",
|
|
1334
1334
|
].join("\n");
|
|
1335
1335
|
}
|
|
1336
|
+
/**
|
|
1337
|
+
* The key of the email integration an app sends through, which is the
|
|
1338
|
+
* integrationId the runtime SDK bridge dispatches on. The app's own entry wins;
|
|
1339
|
+
* otherwise it is the workspace email the app has `integrationSettings` for.
|
|
1340
|
+
*/
|
|
1341
|
+
export function findEmailIntegrationId(appConfig, workspaceConfig) {
|
|
1342
|
+
const app = (appConfig ?? {});
|
|
1343
|
+
const workspace = (workspaceConfig ?? {});
|
|
1344
|
+
const isEmail = (config, id) => config.integrations?.[id]?.type === "email";
|
|
1345
|
+
return (Object.keys(app.integrations ?? {}).find((id) => isEmail(app, id)) ??
|
|
1346
|
+
Object.keys(app.integrationSettings ?? {}).find((id) => isEmail(workspace, id)));
|
|
1347
|
+
}
|
|
1336
1348
|
/**
|
|
1337
1349
|
* Generate `.zite/integrations/email.ts` — the `Email` client for an app with
|
|
1338
1350
|
* an email integration connected. Mirrors the airtable SDK generation: a thin
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { describe, it, expect } from 'vitest';
|
|
2
2
|
import ts from 'typescript';
|
|
3
|
-
import { generateAirtableTs, generateApiTs, generateBackendWrapperTs, generateDbTs, generateEmailSdk, } from './lib.js';
|
|
3
|
+
import { findEmailIntegrationId, generateAirtableTs, generateApiTs, generateBackendWrapperTs, generateDbTs, generateEmailSdk, } from './lib.js';
|
|
4
4
|
/**
|
|
5
5
|
* Syntax errors in the emitted source. `.zite/db.ts` sits at the repo root and
|
|
6
6
|
* every app imports it, so anything unparseable here fails typecheck for the
|
|
@@ -380,3 +380,31 @@ describe('generateBackendWrapperTs', () => {
|
|
|
380
380
|
expect(output).toContain('ZiteError');
|
|
381
381
|
});
|
|
382
382
|
});
|
|
383
|
+
describe('findEmailIntegrationId', () => {
|
|
384
|
+
const workspace = {
|
|
385
|
+
integrations: {
|
|
386
|
+
slack: { type: 'slack' },
|
|
387
|
+
'team-email': { type: 'email' },
|
|
388
|
+
},
|
|
389
|
+
};
|
|
390
|
+
it('finds the app\'s own email entry', () => {
|
|
391
|
+
expect(findEmailIntegrationId({ integrations: { mail: { type: 'email' } } }, workspace)).toBe('mail');
|
|
392
|
+
});
|
|
393
|
+
it('finds the workspace email the app has settings for', () => {
|
|
394
|
+
expect(findEmailIntegrationId({ integrationSettings: { slack: {}, 'team-email': { fromName: 'Ops' } } }, workspace)).toBe('team-email');
|
|
395
|
+
});
|
|
396
|
+
// An app that never opted in generates no client, so `zitejs/email` stays
|
|
397
|
+
// unresolved there instead of sending as a sender it never chose.
|
|
398
|
+
it('ignores a workspace email the app has no settings for', () => {
|
|
399
|
+
expect(findEmailIntegrationId({}, workspace)).toBeUndefined();
|
|
400
|
+
});
|
|
401
|
+
it('prefers the app\'s own entry over a workspace one', () => {
|
|
402
|
+
expect(findEmailIntegrationId({
|
|
403
|
+
integrations: { mail: { type: 'email' } },
|
|
404
|
+
integrationSettings: { 'team-email': {} },
|
|
405
|
+
}, workspace)).toBe('mail');
|
|
406
|
+
});
|
|
407
|
+
it('tolerates missing configs', () => {
|
|
408
|
+
expect(findEmailIntegrationId(undefined, undefined)).toBeUndefined();
|
|
409
|
+
});
|
|
410
|
+
});
|
|
@@ -2,6 +2,7 @@ export type UploadData = string | Blob | ArrayBuffer | File;
|
|
|
2
2
|
export declare class FileUploadError extends Error {
|
|
3
3
|
constructor(message: string);
|
|
4
4
|
}
|
|
5
|
+
export declare function toUploadBlob(data: UploadData): Blob;
|
|
5
6
|
export declare function uploadFile({ data, filename, }: {
|
|
6
7
|
data: UploadData;
|
|
7
8
|
filename: string;
|
package/dist/esm/upload/index.js
CHANGED
|
@@ -18,33 +18,35 @@ function getZiteAppMode(hostname) {
|
|
|
18
18
|
}
|
|
19
19
|
return 'live';
|
|
20
20
|
}
|
|
21
|
-
function
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
21
|
+
function decodeDataUrl(dataUrl) {
|
|
22
|
+
const comma = dataUrl.indexOf(',');
|
|
23
|
+
if (comma === -1) {
|
|
24
|
+
throw new FileUploadError('Invalid data URL');
|
|
25
|
+
}
|
|
26
|
+
const header = dataUrl.slice(0, comma);
|
|
27
|
+
const payload = dataUrl.slice(comma + 1);
|
|
28
|
+
const mimeMatch = /^data:([^;,]*)/.exec(header);
|
|
29
|
+
const mimeType = mimeMatch?.[1] || 'application/octet-stream';
|
|
30
|
+
const bytes = Uint8Array.from(atob(payload), c => c.charCodeAt(0));
|
|
31
|
+
return new Blob([bytes], { type: mimeType });
|
|
28
32
|
}
|
|
29
|
-
|
|
30
|
-
if (data instanceof Blob) {
|
|
31
|
-
return
|
|
33
|
+
export function toUploadBlob(data) {
|
|
34
|
+
if (typeof Blob !== 'undefined' && data instanceof Blob) {
|
|
35
|
+
return data;
|
|
32
36
|
}
|
|
33
37
|
if (data instanceof ArrayBuffer) {
|
|
34
|
-
|
|
35
|
-
let binary = '';
|
|
36
|
-
for (let i = 0; i < bytes.byteLength; i++) {
|
|
37
|
-
binary += String.fromCharCode(bytes[i]);
|
|
38
|
-
}
|
|
39
|
-
return 'data:application/octet-stream;base64,' + btoa(binary);
|
|
38
|
+
return new Blob([data], { type: 'application/octet-stream' });
|
|
40
39
|
}
|
|
41
40
|
if (typeof data === 'string') {
|
|
42
41
|
if (data.startsWith('data:'))
|
|
43
|
-
return data;
|
|
44
|
-
|
|
45
|
-
|
|
42
|
+
return decodeDataUrl(data);
|
|
43
|
+
// Only treat as raw base64 when the string is padded/aligned — otherwise
|
|
44
|
+
// short text like "hello" matches the alphabet and atob throws.
|
|
45
|
+
if (data.length % 4 === 0 && /^[A-Za-z0-9+/]+={0,2}$/.test(data)) {
|
|
46
|
+
const bytes = Uint8Array.from(atob(data), c => c.charCodeAt(0));
|
|
47
|
+
return new Blob([bytes], { type: 'application/octet-stream' });
|
|
46
48
|
}
|
|
47
|
-
return
|
|
49
|
+
return new Blob([data], { type: 'text/plain' });
|
|
48
50
|
}
|
|
49
51
|
throw new FileUploadError('Invalid data format. Expected string, Blob, ArrayBuffer, or File.');
|
|
50
52
|
}
|
|
@@ -56,36 +58,55 @@ function resolveFilename(data, filename) {
|
|
|
56
58
|
}
|
|
57
59
|
throw new FileUploadError('A filename is required unless the uploaded data is a File.');
|
|
58
60
|
}
|
|
59
|
-
async function
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
61
|
+
async function readErrorMessage(res, fallback) {
|
|
62
|
+
try {
|
|
63
|
+
const err = (await res.json());
|
|
64
|
+
if (err.message)
|
|
65
|
+
return err.message;
|
|
66
|
+
}
|
|
67
|
+
catch { }
|
|
68
|
+
return fallback;
|
|
69
|
+
}
|
|
70
|
+
function authHeaders() {
|
|
63
71
|
const token = typeof localStorage !== 'undefined'
|
|
64
72
|
? localStorage.getItem('zite.auth.token')
|
|
65
73
|
: null;
|
|
66
|
-
|
|
74
|
+
return token ? { Authorization: 'Bearer ' + token } : {};
|
|
75
|
+
}
|
|
76
|
+
async function performUpload(data, filename) {
|
|
77
|
+
const blob = toUploadBlob(data);
|
|
78
|
+
const mode = getZiteAppMode(window.location.hostname);
|
|
79
|
+
const flowId = getFlowId();
|
|
80
|
+
const sessionRes = await fetch(getApiUrl() + '/v1/zite/public/' + flowId + '/upload-session?mode=' + mode, {
|
|
67
81
|
method: 'POST',
|
|
68
82
|
headers: {
|
|
69
83
|
'Content-Type': 'application/json',
|
|
70
|
-
...(
|
|
84
|
+
...authHeaders(),
|
|
71
85
|
},
|
|
72
|
-
body: JSON.stringify({
|
|
86
|
+
body: JSON.stringify({
|
|
87
|
+
filename,
|
|
88
|
+
uploadLength: blob.size,
|
|
89
|
+
contentType: blob.type || 'application/octet-stream',
|
|
90
|
+
}),
|
|
73
91
|
});
|
|
74
|
-
if (!
|
|
75
|
-
|
|
76
|
-
try {
|
|
77
|
-
const err = (await res.json());
|
|
78
|
-
if (err.message)
|
|
79
|
-
message = err.message;
|
|
80
|
-
}
|
|
81
|
-
catch { }
|
|
82
|
-
throw new FileUploadError(message);
|
|
92
|
+
if (!sessionRes.ok) {
|
|
93
|
+
throw new FileUploadError(await readErrorMessage(sessionRes, 'Upload failed'));
|
|
83
94
|
}
|
|
84
|
-
const
|
|
85
|
-
if (!
|
|
86
|
-
throw new FileUploadError(
|
|
95
|
+
const session = (await sessionRes.json());
|
|
96
|
+
if (!session.success || !session.presignedUrl || !session.fileUrl) {
|
|
97
|
+
throw new FileUploadError(session.message ?? 'Upload failed');
|
|
98
|
+
}
|
|
99
|
+
const putRes = await fetch(session.presignedUrl, {
|
|
100
|
+
method: 'PUT',
|
|
101
|
+
headers: {
|
|
102
|
+
'Content-Type': session.contentType || blob.type || 'application/octet-stream',
|
|
103
|
+
},
|
|
104
|
+
body: blob,
|
|
105
|
+
});
|
|
106
|
+
if (!putRes.ok) {
|
|
107
|
+
throw new FileUploadError('Upload failed');
|
|
87
108
|
}
|
|
88
|
-
return
|
|
109
|
+
return session.fileUrl;
|
|
89
110
|
}
|
|
90
111
|
export async function uploadFile({ data, filename, }) {
|
|
91
112
|
return { fileUrl: await performUpload(data, filename) };
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { FileUploadError, toUploadBlob, uploadFile } from './index.js';
|
|
3
|
+
vi.mock('../auth/config.js', () => ({
|
|
4
|
+
getApiUrl: () => 'https://api.example.com',
|
|
5
|
+
getFlowId: () => 'app_123',
|
|
6
|
+
}));
|
|
7
|
+
const fetchMock = vi.fn();
|
|
8
|
+
beforeEach(() => {
|
|
9
|
+
fetchMock.mockReset();
|
|
10
|
+
vi.stubGlobal('fetch', fetchMock);
|
|
11
|
+
vi.stubGlobal('window', { location: { hostname: 'portal.acme.com' } });
|
|
12
|
+
vi.stubGlobal('localStorage', { getItem: () => null });
|
|
13
|
+
});
|
|
14
|
+
afterEach(() => {
|
|
15
|
+
vi.unstubAllGlobals();
|
|
16
|
+
});
|
|
17
|
+
describe('toUploadBlob', () => {
|
|
18
|
+
it('wraps a string as text/plain and preserves File/Blob size', () => {
|
|
19
|
+
expect(toUploadBlob('hello').size).toBe(5);
|
|
20
|
+
expect(toUploadBlob('hello').type).toBe('text/plain');
|
|
21
|
+
const file = new File(['abc'], 'note.txt', { type: 'text/plain' });
|
|
22
|
+
expect(toUploadBlob(file).size).toBe(3);
|
|
23
|
+
});
|
|
24
|
+
});
|
|
25
|
+
describe('uploadFile', () => {
|
|
26
|
+
it('requests a session then PUTs the bytes to the presigned URL', async () => {
|
|
27
|
+
const file = new File(['hello-world'], 'hello.txt', { type: 'text/plain' });
|
|
28
|
+
fetchMock
|
|
29
|
+
.mockResolvedValueOnce({
|
|
30
|
+
ok: true,
|
|
31
|
+
json: async () => ({
|
|
32
|
+
success: true,
|
|
33
|
+
presignedUrl: 'https://s3.example.com/put',
|
|
34
|
+
fileUrl: 'https://uploads.zite.com/orgid-1/zite-uploads/app_123/hello.txt',
|
|
35
|
+
contentType: 'text/plain',
|
|
36
|
+
}),
|
|
37
|
+
})
|
|
38
|
+
.mockResolvedValueOnce({ ok: true });
|
|
39
|
+
const result = await uploadFile({ data: file, filename: 'hello.txt' });
|
|
40
|
+
expect(result.fileUrl).toContain('hello.txt');
|
|
41
|
+
expect(fetchMock).toHaveBeenCalledTimes(2);
|
|
42
|
+
expect(fetchMock.mock.calls[0][0]).toBe('https://api.example.com/v1/zite/public/app_123/upload-session?mode=live');
|
|
43
|
+
const sessionInit = fetchMock.mock.calls[0][1];
|
|
44
|
+
expect(JSON.parse(sessionInit.body)).toEqual({
|
|
45
|
+
filename: 'hello.txt',
|
|
46
|
+
uploadLength: 11,
|
|
47
|
+
contentType: 'text/plain',
|
|
48
|
+
});
|
|
49
|
+
expect(fetchMock.mock.calls[1][0]).toBe('https://s3.example.com/put');
|
|
50
|
+
expect(fetchMock.mock.calls[1][1].method).toBe('PUT');
|
|
51
|
+
});
|
|
52
|
+
it('surfaces the plan-limit message from the session endpoint', async () => {
|
|
53
|
+
fetchMock.mockResolvedValueOnce({
|
|
54
|
+
ok: false,
|
|
55
|
+
json: async () => ({
|
|
56
|
+
message: 'File size exceeds maximum of 20 MB',
|
|
57
|
+
}),
|
|
58
|
+
});
|
|
59
|
+
const upload = uploadFile({
|
|
60
|
+
data: new File(['x'], 'big.bin'),
|
|
61
|
+
filename: 'big.bin',
|
|
62
|
+
});
|
|
63
|
+
await expect(upload).rejects.toBeInstanceOf(FileUploadError);
|
|
64
|
+
await expect(upload).rejects.toThrow('File size exceeds maximum of 20 MB');
|
|
65
|
+
});
|
|
66
|
+
});
|