kaneo (minimalist kanban) fork to experiment adding a tangled integration
github.com/usekaneo/kaneo
1import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2import {
3 assertTaskImageKeyMatchesContext,
4 buildObjectKey,
5 buildObjectKeyPrefix,
6 createTaskImageUploadUrl,
7 getFileExtension,
8 isImageContentType,
9 parseBoolean,
10 parsePositiveInt,
11 sanitizePathSegment,
12 validateTaskAssetUploadInput,
13} from "../../../apps/api/src/storage/s3";
14
15describe("S3 helpers", () => {
16 const originalMaxSize = process.env.S3_MAX_IMAGE_UPLOAD_BYTES;
17 const originalEndpoint = process.env.S3_ENDPOINT;
18 const originalBucket = process.env.S3_BUCKET;
19 const originalAccessKeyId = process.env.S3_ACCESS_KEY_ID;
20 const originalSecretAccessKey = process.env.S3_SECRET_ACCESS_KEY;
21 const originalRegion = process.env.S3_REGION;
22 const originalPathStyle = process.env.S3_FORCE_PATH_STYLE;
23
24 beforeEach(() => {
25 delete process.env.S3_MAX_IMAGE_UPLOAD_BYTES;
26 });
27
28 afterEach(() => {
29 vi.restoreAllMocks();
30
31 if (originalMaxSize === undefined) {
32 delete process.env.S3_MAX_IMAGE_UPLOAD_BYTES;
33 } else {
34 process.env.S3_MAX_IMAGE_UPLOAD_BYTES = originalMaxSize;
35 }
36
37 if (originalEndpoint === undefined) {
38 delete process.env.S3_ENDPOINT;
39 } else {
40 process.env.S3_ENDPOINT = originalEndpoint;
41 }
42
43 if (originalBucket === undefined) {
44 delete process.env.S3_BUCKET;
45 } else {
46 process.env.S3_BUCKET = originalBucket;
47 }
48
49 if (originalAccessKeyId === undefined) {
50 delete process.env.S3_ACCESS_KEY_ID;
51 } else {
52 process.env.S3_ACCESS_KEY_ID = originalAccessKeyId;
53 }
54
55 if (originalSecretAccessKey === undefined) {
56 delete process.env.S3_SECRET_ACCESS_KEY;
57 } else {
58 process.env.S3_SECRET_ACCESS_KEY = originalSecretAccessKey;
59 }
60
61 if (originalRegion === undefined) {
62 delete process.env.S3_REGION;
63 } else {
64 process.env.S3_REGION = originalRegion;
65 }
66
67 if (originalPathStyle === undefined) {
68 delete process.env.S3_FORCE_PATH_STYLE;
69 } else {
70 process.env.S3_FORCE_PATH_STYLE = originalPathStyle;
71 }
72 });
73
74 it("recognizes allowed image content types case-insensitively", () => {
75 expect(isImageContentType("IMAGE/PNG")).toBe(true);
76 expect(isImageContentType("text/plain")).toBe(false);
77 });
78
79 it("parses booleans and positive integers with fallbacks", () => {
80 expect(parseBoolean(undefined, true)).toBe(true);
81 expect(parseBoolean(" false ", true)).toBe(false);
82 expect(parsePositiveInt("42", 10)).toBe(42);
83 expect(parsePositiveInt("0", 10)).toBe(10);
84 expect(parsePositiveInt("nope", 10)).toBe(10);
85 });
86
87 it("sanitizes path segments and extracts normalized extensions", () => {
88 expect(sanitizePathSegment(" Release Notes!!.PNG ")).toBe(
89 "release-notes-.png",
90 );
91 expect(sanitizePathSegment("")).toBe("file");
92 expect(getFileExtension("Screenshot.Final.PNG")).toBe("png");
93 expect(getFileExtension("README")).toBe("file");
94 });
95
96 it("builds stable key prefixes and keys", () => {
97 vi.spyOn(Date, "now").mockReturnValue(1_717_171_717_000);
98
99 const key = buildObjectKey({
100 workspaceId: "Workspace 1",
101 projectId: "Project 2",
102 taskId: "Task 3",
103 surface: "comment",
104 filename: "Sprint Plan Final!!.PNG",
105 contentType: "image/png",
106 });
107
108 expect(
109 buildObjectKeyPrefix({
110 workspaceId: "Workspace 1",
111 projectId: "Project 2",
112 taskId: "Task 3",
113 surface: "comment",
114 }),
115 ).toBe("workspace/workspace-1/project/project-2/task/task-3/comments");
116
117 expect(key).toMatch(
118 /^workspace\/workspace-1\/project\/project-2\/task\/task-3\/comments\/sprint-plan-final-1717171717000-[a-z0-9]+\.png$/,
119 );
120 expect(
121 assertTaskImageKeyMatchesContext(key, {
122 workspaceId: "Workspace 1",
123 projectId: "Project 2",
124 taskId: "Task 3",
125 surface: "comment",
126 }),
127 ).toBe(true);
128 });
129
130 it("validates upload size against the configured maximum", () => {
131 process.env.S3_MAX_IMAGE_UPLOAD_BYTES = "1048576";
132
133 expect(() => validateTaskAssetUploadInput("", 10)).toThrow(
134 "A valid content type is required.",
135 );
136 expect(() => validateTaskAssetUploadInput("image/png", 0)).toThrow(
137 "Upload size must be greater than zero.",
138 );
139 expect(() =>
140 validateTaskAssetUploadInput("image/png", 2 * 1024 * 1024),
141 ).toThrow("Upload exceeds the maximum upload size of 1MB.");
142 expect(() => validateTaskAssetUploadInput("image/png", 512)).not.toThrow();
143 });
144
145 it("creates presigned upload URLs without hoisted checksum query params", async () => {
146 process.env.S3_ENDPOINT = "https://storage.example.test";
147 process.env.S3_BUCKET = "kaneo";
148 process.env.S3_ACCESS_KEY_ID = "test-access-key";
149 process.env.S3_SECRET_ACCESS_KEY = "test-secret-key";
150 process.env.S3_REGION = "us-east-1";
151 process.env.S3_FORCE_PATH_STYLE = "true";
152
153 const upload = await createTaskImageUploadUrl({
154 workspaceId: "workspace-1",
155 projectId: "project-1",
156 taskId: "task-1",
157 surface: "description",
158 filename: "report.png",
159 contentType: "image/png",
160 });
161
162 const searchParams = new URL(upload.uploadUrl).searchParams;
163 expect(searchParams.has("x-amz-checksum-crc32")).toBe(false);
164 expect(searchParams.has("x-amz-sdk-checksum-algorithm")).toBe(false);
165 });
166});