@just-be/wildcard 0.1.1 → 0.2.0
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/README.md +0 -1
- package/package.json +6 -3
- package/src/handlers/static.ts +7 -1
- package/src/schemas.ts +9 -22
- package/src/utils.test.ts +343 -0
package/README.md
CHANGED
|
@@ -157,7 +157,6 @@ See the `services/wildcard` directory for a complete Cloudflare Workers implemen
|
|
|
157
157
|
- `RedirectConfigSchema` - Validates redirect config
|
|
158
158
|
- `RewriteConfigSchema` - Validates rewrite config
|
|
159
159
|
- `RouteConfigSchema` - Discriminated union of all configs
|
|
160
|
-
- `R2ConfigSchema` - (Deprecated) Alias for StaticConfigSchema
|
|
161
160
|
|
|
162
161
|
### Utilities
|
|
163
162
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@just-be/wildcard",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "Portable wildcard subdomain routing with pluggable storage backends",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./src/index.ts",
|
|
@@ -25,10 +25,13 @@
|
|
|
25
25
|
"license": "MIT",
|
|
26
26
|
"repository": {
|
|
27
27
|
"type": "git",
|
|
28
|
-
"url": "https://github.com/
|
|
28
|
+
"url": "git+https://github.com/just-be-dev/just-be.dev.git",
|
|
29
29
|
"directory": "packages/wildcard"
|
|
30
30
|
},
|
|
31
31
|
"peerDependencies": {
|
|
32
|
-
"zod": "^
|
|
32
|
+
"zod": "^4.0.0"
|
|
33
|
+
},
|
|
34
|
+
"publishConfig": {
|
|
35
|
+
"access": "public"
|
|
33
36
|
}
|
|
34
37
|
}
|
package/src/handlers/static.ts
CHANGED
|
@@ -8,7 +8,13 @@ export const handleStatic: Handler<StaticConfig, { fileLoader: FileLoader }> = a
|
|
|
8
8
|
{ fileLoader }
|
|
9
9
|
) => {
|
|
10
10
|
const url = new URL(request.url);
|
|
11
|
-
const {
|
|
11
|
+
const { spa, fallback } = config;
|
|
12
|
+
|
|
13
|
+
// Extract subdomain from hostname to use as base path
|
|
14
|
+
// e.g., "foo.just-be.dev" -> "foo"
|
|
15
|
+
const hostname = url.hostname;
|
|
16
|
+
const subdomain = hostname.split(".")[0];
|
|
17
|
+
const basePath = subdomain;
|
|
12
18
|
|
|
13
19
|
return spa
|
|
14
20
|
? handleSpaMode(fileLoader, basePath)
|
package/src/schemas.ts
CHANGED
|
@@ -1,32 +1,22 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* Validates that a URL is safe (http/https only, no private IPs)
|
|
5
|
-
*
|
|
6
|
-
* This is a simplified version that checks basic URL structure.
|
|
7
|
-
* For full SSRF protection, use the complete isSafeURL implementation
|
|
8
|
-
* from the wildcard service.
|
|
9
|
-
*/
|
|
10
|
-
function isBasicSafeURL(url: string): boolean {
|
|
11
|
-
try {
|
|
12
|
-
const parsed = new URL(url);
|
|
13
|
-
return parsed.protocol === "http:" || parsed.protocol === "https:";
|
|
14
|
-
} catch {
|
|
15
|
-
return false;
|
|
16
|
-
}
|
|
17
|
-
}
|
|
2
|
+
import { isSafeURL, isValidSubdomain } from "./utils";
|
|
18
3
|
|
|
19
4
|
/** Validates that a URL is safe (http/https only, no private IPs) */
|
|
20
5
|
const safeUrl = () =>
|
|
21
|
-
z.
|
|
6
|
+
z.url().refine(isSafeURL, {
|
|
22
7
|
message: "URL must use http/https and cannot target private/internal addresses",
|
|
23
8
|
});
|
|
24
9
|
|
|
10
|
+
/** Validates subdomain format (alphanumeric with hyphens, 1-63 characters) */
|
|
11
|
+
export const subdomain = () =>
|
|
12
|
+
z.string().refine(isValidSubdomain, {
|
|
13
|
+
message: "Invalid subdomain format. Must be alphanumeric with hyphens, 1-63 characters.",
|
|
14
|
+
});
|
|
15
|
+
|
|
25
16
|
// Zod schemas for validation
|
|
26
17
|
export const StaticConfigSchema = z
|
|
27
18
|
.object({
|
|
28
19
|
type: z.literal("static"),
|
|
29
|
-
path: z.string().min(1),
|
|
30
20
|
spa: z.boolean().optional(),
|
|
31
21
|
fallback: z.string().optional(), // Fallback file path for non-SPA mode (e.g., "404.html")
|
|
32
22
|
})
|
|
@@ -54,9 +44,6 @@ export const RouteConfigSchema = z.discriminatedUnion("type", [
|
|
|
54
44
|
RewriteConfigSchema,
|
|
55
45
|
]);
|
|
56
46
|
|
|
57
|
-
/** @deprecated Use StaticConfigSchema instead */
|
|
58
|
-
export const R2ConfigSchema = StaticConfigSchema;
|
|
59
|
-
|
|
60
47
|
/**
|
|
61
48
|
* JSON codec for parsing and validating JSON strings
|
|
62
49
|
*/
|
|
@@ -86,4 +73,4 @@ export const RouteConfigCodec = json(RouteConfigSchema);
|
|
|
86
73
|
export type StaticConfig = z.infer<typeof StaticConfigSchema>;
|
|
87
74
|
export type RedirectConfig = z.infer<typeof RedirectConfigSchema>;
|
|
88
75
|
export type RewriteConfig = z.infer<typeof RewriteConfigSchema>;
|
|
89
|
-
export type
|
|
76
|
+
export type RouteConfig = z.infer<typeof RouteConfigSchema>;
|
|
@@ -0,0 +1,343 @@
|
|
|
1
|
+
import { describe, it, expect } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
getContentType,
|
|
4
|
+
isSafeURL,
|
|
5
|
+
sanitizePath,
|
|
6
|
+
filterSafeHeaders,
|
|
7
|
+
isValidSubdomain,
|
|
8
|
+
SAFE_REQUEST_HEADERS,
|
|
9
|
+
} from "./utils";
|
|
10
|
+
|
|
11
|
+
describe("getContentType", () => {
|
|
12
|
+
it("should return correct MIME types for common file extensions", () => {
|
|
13
|
+
expect(getContentType("index.html")).toBe("text/html");
|
|
14
|
+
expect(getContentType("style.css")).toBe("text/css");
|
|
15
|
+
expect(getContentType("script.js")).toBe("application/javascript");
|
|
16
|
+
expect(getContentType("data.json")).toBe("application/json");
|
|
17
|
+
expect(getContentType("image.png")).toBe("image/png");
|
|
18
|
+
expect(getContentType("photo.jpg")).toBe("image/jpeg");
|
|
19
|
+
expect(getContentType("photo.jpeg")).toBe("image/jpeg");
|
|
20
|
+
expect(getContentType("icon.svg")).toBe("image/svg+xml");
|
|
21
|
+
expect(getContentType("font.woff2")).toBe("font/woff2");
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it("should handle uppercase extensions", () => {
|
|
25
|
+
expect(getContentType("FILE.HTML")).toBe("text/html");
|
|
26
|
+
expect(getContentType("IMAGE.PNG")).toBe("image/png");
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("should handle paths with multiple dots", () => {
|
|
30
|
+
expect(getContentType("file.min.js")).toBe("application/javascript");
|
|
31
|
+
expect(getContentType("archive.tar.gz")).toBe(null);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("should return null for unknown extensions", () => {
|
|
35
|
+
expect(getContentType("file.xyz")).toBe(null);
|
|
36
|
+
expect(getContentType("README")).toBe(null);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("should return null for files without extensions", () => {
|
|
40
|
+
expect(getContentType("Dockerfile")).toBe(null);
|
|
41
|
+
expect(getContentType("Makefile")).toBe(null);
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
describe("isSafeURL", () => {
|
|
46
|
+
describe("valid URLs", () => {
|
|
47
|
+
it("should allow valid http and https URLs", () => {
|
|
48
|
+
expect(isSafeURL("https://example.com")).toBe(true);
|
|
49
|
+
expect(isSafeURL("http://example.com")).toBe(true);
|
|
50
|
+
expect(isSafeURL("https://api.example.com/path?query=value")).toBe(true);
|
|
51
|
+
expect(isSafeURL("https://sub.domain.example.com")).toBe(true);
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
describe("protocol validation", () => {
|
|
56
|
+
it("should reject non-http/https protocols", () => {
|
|
57
|
+
expect(isSafeURL("file:///etc/passwd")).toBe(false);
|
|
58
|
+
expect(isSafeURL("ftp://example.com")).toBe(false);
|
|
59
|
+
expect(isSafeURL("javascript:alert(1)")).toBe(false);
|
|
60
|
+
expect(isSafeURL("data:text/html,<script>alert(1)</script>")).toBe(false);
|
|
61
|
+
expect(isSafeURL("gopher://example.com")).toBe(false);
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
describe("SSRF protection - localhost", () => {
|
|
66
|
+
it("should block localhost", () => {
|
|
67
|
+
expect(isSafeURL("http://localhost")).toBe(false);
|
|
68
|
+
expect(isSafeURL("http://localhost:8080")).toBe(false);
|
|
69
|
+
expect(isSafeURL("http://LOCALHOST")).toBe(false);
|
|
70
|
+
expect(isSafeURL("http://localhost.localdomain")).toBe(false);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("should block 127.x.x.x (loopback)", () => {
|
|
74
|
+
expect(isSafeURL("http://127.0.0.1")).toBe(false);
|
|
75
|
+
expect(isSafeURL("http://127.0.0.1:8080")).toBe(false);
|
|
76
|
+
expect(isSafeURL("http://127.1.1.1")).toBe(false);
|
|
77
|
+
expect(isSafeURL("http://127.255.255.255")).toBe(false);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("should block IPv6 localhost", () => {
|
|
81
|
+
expect(isSafeURL("http://[::1]")).toBe(false);
|
|
82
|
+
expect(isSafeURL("http://[::1]:8080")).toBe(false);
|
|
83
|
+
});
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
describe("SSRF protection - private IP ranges", () => {
|
|
87
|
+
it("should block 10.x.x.x (private class A)", () => {
|
|
88
|
+
expect(isSafeURL("http://10.0.0.1")).toBe(false);
|
|
89
|
+
expect(isSafeURL("http://10.1.2.3")).toBe(false);
|
|
90
|
+
expect(isSafeURL("http://10.255.255.255")).toBe(false);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("should block 192.168.x.x (private class C)", () => {
|
|
94
|
+
expect(isSafeURL("http://192.168.0.1")).toBe(false);
|
|
95
|
+
expect(isSafeURL("http://192.168.1.1")).toBe(false);
|
|
96
|
+
expect(isSafeURL("http://192.168.255.255")).toBe(false);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it("should block 172.16-31.x.x (private class B)", () => {
|
|
100
|
+
expect(isSafeURL("http://172.16.0.1")).toBe(false);
|
|
101
|
+
expect(isSafeURL("http://172.20.10.5")).toBe(false);
|
|
102
|
+
expect(isSafeURL("http://172.31.255.255")).toBe(false);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it("should allow non-private 172.x.x.x addresses", () => {
|
|
106
|
+
expect(isSafeURL("http://172.15.0.1")).toBe(true);
|
|
107
|
+
expect(isSafeURL("http://172.32.0.1")).toBe(true);
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
describe("SSRF protection - cloud metadata endpoints", () => {
|
|
112
|
+
it("should block 169.254.x.x (link-local/metadata)", () => {
|
|
113
|
+
expect(isSafeURL("http://169.254.169.254")).toBe(false);
|
|
114
|
+
expect(isSafeURL("http://169.254.169.254/latest/meta-data")).toBe(false);
|
|
115
|
+
expect(isSafeURL("http://169.254.1.1")).toBe(false);
|
|
116
|
+
});
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
describe("SSRF protection - other restricted ranges", () => {
|
|
120
|
+
it("should block 0.0.0.0/8", () => {
|
|
121
|
+
expect(isSafeURL("http://0.0.0.0")).toBe(false);
|
|
122
|
+
expect(isSafeURL("http://0.1.2.3")).toBe(false);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it("should block IPv6 link-local (fe80::)", () => {
|
|
126
|
+
expect(isSafeURL("http://[fe80::1]")).toBe(false);
|
|
127
|
+
expect(isSafeURL("http://[fe80:0000:0000:0000:0000:0000:0000:0001]")).toBe(false);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it("should block IPv6 unique local (fc00::/7)", () => {
|
|
131
|
+
expect(isSafeURL("http://[fc00::1]")).toBe(false);
|
|
132
|
+
expect(isSafeURL("http://[fd00::1]")).toBe(false);
|
|
133
|
+
});
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
describe("invalid URLs", () => {
|
|
137
|
+
it("should return false for malformed URLs", () => {
|
|
138
|
+
expect(isSafeURL("not a url")).toBe(false);
|
|
139
|
+
expect(isSafeURL("")).toBe(false);
|
|
140
|
+
expect(isSafeURL("htp://example.com")).toBe(false);
|
|
141
|
+
});
|
|
142
|
+
});
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
describe("sanitizePath", () => {
|
|
146
|
+
describe("valid paths", () => {
|
|
147
|
+
it("should allow and sanitize simple paths", () => {
|
|
148
|
+
expect(sanitizePath("file.txt")).toBe("file.txt");
|
|
149
|
+
expect(sanitizePath("dir/file.txt")).toBe("dir/file.txt");
|
|
150
|
+
expect(sanitizePath("a/b/c/file.txt")).toBe("a/b/c/file.txt");
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it("should remove leading slashes", () => {
|
|
154
|
+
expect(sanitizePath("/file.txt")).toBe("file.txt");
|
|
155
|
+
expect(sanitizePath("//file.txt")).toBe("file.txt");
|
|
156
|
+
expect(sanitizePath("///path/to/file.txt")).toBe("path/to/file.txt");
|
|
157
|
+
});
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
describe("directory traversal protection", () => {
|
|
161
|
+
it("should reject paths with ..", () => {
|
|
162
|
+
expect(sanitizePath("../file.txt")).toBe(null);
|
|
163
|
+
expect(sanitizePath("dir/../file.txt")).toBe(null);
|
|
164
|
+
expect(sanitizePath("../../etc/passwd")).toBe(null);
|
|
165
|
+
expect(sanitizePath("dir/../../file.txt")).toBe(null);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it("should reject paths with .", () => {
|
|
169
|
+
expect(sanitizePath("./file.txt")).toBe(null);
|
|
170
|
+
expect(sanitizePath("dir/./file.txt")).toBe(null);
|
|
171
|
+
expect(sanitizePath(".")).toBe(null);
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it("should reject paths with double slashes (empty segments)", () => {
|
|
175
|
+
expect(sanitizePath("dir//file.txt")).toBe(null);
|
|
176
|
+
expect(sanitizePath("a/b//c")).toBe(null);
|
|
177
|
+
});
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
describe("null byte protection", () => {
|
|
181
|
+
it("should reject paths with null bytes", () => {
|
|
182
|
+
expect(sanitizePath("file\0.txt")).toBe(null);
|
|
183
|
+
expect(sanitizePath("dir/file\x00.txt")).toBe(null);
|
|
184
|
+
});
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
describe("edge cases", () => {
|
|
188
|
+
it("should handle paths with hyphens and underscores", () => {
|
|
189
|
+
expect(sanitizePath("my-file_name.txt")).toBe("my-file_name.txt");
|
|
190
|
+
expect(sanitizePath("some-dir/my_file.txt")).toBe("some-dir/my_file.txt");
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
it("should handle paths with spaces", () => {
|
|
194
|
+
expect(sanitizePath("my file.txt")).toBe("my file.txt");
|
|
195
|
+
expect(sanitizePath("some dir/my file.txt")).toBe("some dir/my file.txt");
|
|
196
|
+
});
|
|
197
|
+
});
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
describe("filterSafeHeaders", () => {
|
|
201
|
+
it("should filter and keep only safe headers", () => {
|
|
202
|
+
const headers = new Headers({
|
|
203
|
+
accept: "application/json",
|
|
204
|
+
"user-agent": "Mozilla/5.0",
|
|
205
|
+
authorization: "Bearer secret-token",
|
|
206
|
+
cookie: "session=abc123",
|
|
207
|
+
"x-forwarded-for": "1.2.3.4",
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
const filtered = filterSafeHeaders(headers);
|
|
211
|
+
|
|
212
|
+
expect(filtered.get("accept")).toBe("application/json");
|
|
213
|
+
expect(filtered.get("user-agent")).toBe("Mozilla/5.0");
|
|
214
|
+
expect(filtered.get("authorization")).toBe(null);
|
|
215
|
+
expect(filtered.get("cookie")).toBe(null);
|
|
216
|
+
expect(filtered.get("x-forwarded-for")).toBe(null);
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
it("should include all safe headers that are present", () => {
|
|
220
|
+
const headers = new Headers({
|
|
221
|
+
accept: "text/html",
|
|
222
|
+
"accept-language": "en-US",
|
|
223
|
+
"accept-encoding": "gzip",
|
|
224
|
+
"cache-control": "no-cache",
|
|
225
|
+
"content-type": "application/json",
|
|
226
|
+
"user-agent": "Test",
|
|
227
|
+
referer: "https://example.com",
|
|
228
|
+
origin: "https://example.com",
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
const filtered = filterSafeHeaders(headers);
|
|
232
|
+
|
|
233
|
+
for (const header of SAFE_REQUEST_HEADERS) {
|
|
234
|
+
expect(filtered.has(header)).toBe(true);
|
|
235
|
+
}
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
it("should return empty headers when no safe headers present", () => {
|
|
239
|
+
const headers = new Headers({
|
|
240
|
+
authorization: "Bearer token",
|
|
241
|
+
cookie: "session=abc",
|
|
242
|
+
"x-custom-header": "value",
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
const filtered = filterSafeHeaders(headers);
|
|
246
|
+
|
|
247
|
+
expect([...filtered.keys()].length).toBe(0);
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
it("should be case-insensitive for header names", () => {
|
|
251
|
+
const headers = new Headers({
|
|
252
|
+
Accept: "application/json",
|
|
253
|
+
"USER-AGENT": "Test",
|
|
254
|
+
"Content-Type": "text/plain",
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
const filtered = filterSafeHeaders(headers);
|
|
258
|
+
|
|
259
|
+
expect(filtered.get("accept")).toBe("application/json");
|
|
260
|
+
expect(filtered.get("user-agent")).toBe("Test");
|
|
261
|
+
expect(filtered.get("content-type")).toBe("text/plain");
|
|
262
|
+
});
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
describe("isValidSubdomain", () => {
|
|
266
|
+
describe("valid subdomains", () => {
|
|
267
|
+
it("should allow single alphanumeric character", () => {
|
|
268
|
+
expect(isValidSubdomain("a")).toBe(true);
|
|
269
|
+
expect(isValidSubdomain("z")).toBe(true);
|
|
270
|
+
expect(isValidSubdomain("0")).toBe(true);
|
|
271
|
+
expect(isValidSubdomain("9")).toBe(true);
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
it("should allow alphanumeric subdomains", () => {
|
|
275
|
+
expect(isValidSubdomain("test")).toBe(true);
|
|
276
|
+
expect(isValidSubdomain("api")).toBe(true);
|
|
277
|
+
expect(isValidSubdomain("www")).toBe(true);
|
|
278
|
+
expect(isValidSubdomain("app123")).toBe(true);
|
|
279
|
+
expect(isValidSubdomain("123app")).toBe(true);
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
it("should allow hyphens in the middle", () => {
|
|
283
|
+
expect(isValidSubdomain("my-app")).toBe(true);
|
|
284
|
+
expect(isValidSubdomain("test-api-v2")).toBe(true);
|
|
285
|
+
expect(isValidSubdomain("a-b-c")).toBe(true);
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
it("should allow mixed case (validation is case-insensitive)", () => {
|
|
289
|
+
expect(isValidSubdomain("MyApp")).toBe(true);
|
|
290
|
+
expect(isValidSubdomain("TEST")).toBe(true);
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
it("should allow up to 63 characters", () => {
|
|
294
|
+
const maxLength = "a".repeat(63);
|
|
295
|
+
expect(isValidSubdomain(maxLength)).toBe(true);
|
|
296
|
+
});
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
describe("invalid subdomains", () => {
|
|
300
|
+
it("should reject empty string", () => {
|
|
301
|
+
expect(isValidSubdomain("")).toBe(false);
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
it("should reject subdomains longer than 63 characters", () => {
|
|
305
|
+
const tooLong = "a".repeat(64);
|
|
306
|
+
expect(isValidSubdomain(tooLong)).toBe(false);
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
it("should reject subdomains starting with hyphen", () => {
|
|
310
|
+
expect(isValidSubdomain("-test")).toBe(false);
|
|
311
|
+
expect(isValidSubdomain("-")).toBe(false);
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
it("should reject subdomains ending with hyphen", () => {
|
|
315
|
+
expect(isValidSubdomain("test-")).toBe(false);
|
|
316
|
+
expect(isValidSubdomain("a-")).toBe(false);
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
it("should reject subdomains with consecutive hyphens", () => {
|
|
320
|
+
expect(isValidSubdomain("test--app")).toBe(false);
|
|
321
|
+
expect(isValidSubdomain("a--b")).toBe(false);
|
|
322
|
+
expect(isValidSubdomain("---")).toBe(false);
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
it("should reject subdomains with special characters", () => {
|
|
326
|
+
expect(isValidSubdomain("test_app")).toBe(false);
|
|
327
|
+
expect(isValidSubdomain("test.app")).toBe(false);
|
|
328
|
+
expect(isValidSubdomain("test@app")).toBe(false);
|
|
329
|
+
expect(isValidSubdomain("test/app")).toBe(false);
|
|
330
|
+
expect(isValidSubdomain("test app")).toBe(false);
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
it("should reject single character hyphens", () => {
|
|
334
|
+
expect(isValidSubdomain("-")).toBe(false);
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
it("should reject single character special chars", () => {
|
|
338
|
+
expect(isValidSubdomain("_")).toBe(false);
|
|
339
|
+
expect(isValidSubdomain(".")).toBe(false);
|
|
340
|
+
expect(isValidSubdomain("@")).toBe(false);
|
|
341
|
+
});
|
|
342
|
+
});
|
|
343
|
+
});
|