@20syldev/api 4.5.0 → 4.7.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.
Files changed (75) hide show
  1. package/README.md +11 -4
  2. package/eslint.config.js +4 -0
  3. package/package.json +3 -2
  4. package/src/app.ts +7 -6
  5. package/src/config/env.ts +1 -0
  6. package/src/config/versions.ts +10 -2
  7. package/src/middleware/error.ts +2 -1
  8. package/src/middleware/json.ts +1 -1
  9. package/src/middleware/logger.ts +2 -1
  10. package/src/middleware/ratelimit.ts +4 -3
  11. package/src/middleware/version.ts +2 -1
  12. package/src/modules/v3.js +1 -1
  13. package/src/modules/v4/address.ts +413 -0
  14. package/src/modules/v4/agent.ts +104 -0
  15. package/src/modules/v4/algorithms.ts +79 -0
  16. package/src/modules/v4/captcha.ts +8 -0
  17. package/src/modules/v4/chat.ts +10 -2
  18. package/src/modules/v4/color.ts +8 -1
  19. package/src/modules/v4/convert.ts +9 -0
  20. package/src/modules/v4/dice.ts +7 -0
  21. package/src/modules/v4/domain.ts +6 -1
  22. package/src/modules/v4/encode.ts +71 -0
  23. package/src/modules/v4/geo.ts +10 -0
  24. package/src/modules/v4/hash.ts +10 -1
  25. package/src/modules/v4/hyperplanning.ts +10 -1
  26. package/src/modules/v4/ip.ts +134 -0
  27. package/src/modules/v4/levenshtein.ts +8 -0
  28. package/src/modules/v4/palette.ts +9 -1
  29. package/src/modules/v4/password.ts +381 -0
  30. package/src/modules/v4/personal.ts +9 -16
  31. package/src/modules/v4/placeholder.ts +8 -0
  32. package/src/modules/v4/qrcode.ts +9 -1
  33. package/src/modules/v4/statistics.ts +7 -0
  34. package/src/modules/v4/text.ts +30 -0
  35. package/src/modules/v4/tic_tac_toe.ts +11 -2
  36. package/src/modules/v4/time.ts +11 -0
  37. package/src/modules/v4/token.ts +18 -8
  38. package/src/modules/v4/username.ts +5 -0
  39. package/src/modules/v4/validate.ts +21 -0
  40. package/src/modules/v4.ts +5 -1
  41. package/src/routes/delete.ts +2 -1
  42. package/src/routes/get.ts +115 -7
  43. package/src/routes/index.ts +4 -3
  44. package/src/routes/patch.ts +2 -1
  45. package/src/routes/post.ts +5 -4
  46. package/src/storage/index.ts +1 -1
  47. package/src/utils/response.ts +1 -0
  48. package/tests/integration/api.test.ts +142 -2
  49. package/tests/unit/address.test.ts +84 -0
  50. package/tests/unit/agent.test.ts +113 -0
  51. package/tests/unit/algorithms.test.ts +2 -1
  52. package/tests/unit/captcha.test.ts +2 -1
  53. package/tests/unit/chat.test.ts +2 -1
  54. package/tests/unit/color.test.ts +2 -1
  55. package/tests/unit/convert.test.ts +2 -1
  56. package/tests/unit/dice.test.ts +2 -1
  57. package/tests/unit/domain.test.ts +2 -1
  58. package/tests/unit/encode.test.ts +8 -7
  59. package/tests/unit/geo.test.ts +2 -1
  60. package/tests/unit/hash.test.ts +2 -1
  61. package/tests/unit/hyperplanning.test.ts +2 -1
  62. package/tests/unit/ip.test.ts +140 -0
  63. package/tests/unit/levenshtein.test.ts +2 -1
  64. package/tests/unit/palette.test.ts +2 -1
  65. package/tests/unit/password.test.ts +86 -0
  66. package/tests/unit/personal.test.ts +2 -1
  67. package/tests/unit/placeholder.test.ts +2 -1
  68. package/tests/unit/qrcode.test.ts +2 -1
  69. package/tests/unit/statistics.test.ts +2 -1
  70. package/tests/unit/text.test.ts +3 -2
  71. package/tests/unit/tic_tac_toe.test.ts +2 -1
  72. package/tests/unit/time.test.ts +2 -1
  73. package/tests/unit/token.test.ts +2 -1
  74. package/tests/unit/username.test.ts +2 -1
  75. package/tests/unit/validate.test.ts +3 -2
@@ -142,6 +142,13 @@ export interface TextStats {
142
142
  mostFrequentChar: string;
143
143
  }
144
144
 
145
+ /**
146
+ * Analyzes a text string and returns character, word, sentence, and reading time statistics.
147
+ *
148
+ * @param value - The text to analyze
149
+ * @returns Object containing character counts, word/sentence/paragraph counts, reading time, and most frequent character
150
+ * @throws Error if value is missing, not a string, or too long
151
+ */
145
152
  export function stats(value: string): TextStats {
146
153
  checkText(value);
147
154
 
@@ -168,6 +175,13 @@ export function stats(value: string): TextStats {
168
175
  return { characters, charactersNoSpaces, words, sentences, paragraphs, readingTime, mostFrequentChar };
169
176
  }
170
177
 
178
+ /**
179
+ * Converts a string to a URL-friendly slug.
180
+ *
181
+ * @param value - The string to slugify
182
+ * @returns Lowercase hyphenated slug with diacritics and special characters removed
183
+ * @throws Error if value is missing, not a string, or too long
184
+ */
171
185
  export function slug(value: string): string {
172
186
  checkText(value);
173
187
  return value
@@ -179,6 +193,14 @@ export function slug(value: string): string {
179
193
  .replace(/[\s-]+/g, '-');
180
194
  }
181
195
 
196
+ /**
197
+ * Generates Lorem Ipsum placeholder text.
198
+ *
199
+ * @param type - Content type: "words", "sentences", or "paragraphs"
200
+ * @param count - Number of words, sentences, or paragraphs to generate
201
+ * @returns Generated Lorem Ipsum text
202
+ * @throws Error if count is out of range or type is not one of the accepted values
203
+ */
182
204
  export function lorem(type: string, count: string): string {
183
205
  const n = Number(count) || 5;
184
206
  if (n < 1 || n > 500) throw new Error('Count must be between 1 and 500');
@@ -283,6 +305,14 @@ function numberToEnglish(n: number): string {
283
305
  throw new Error('Number must be less than 1 billion');
284
306
  }
285
307
 
308
+ /**
309
+ * Converts an integer to its written-out word form in French or English.
310
+ *
311
+ * @param value - The integer to convert (must be less than 1 billion)
312
+ * @param lang - Language: "fr" for French or "en" for English
313
+ * @returns The number written out in words
314
+ * @throws Error if value is not an integer, exceeds the maximum, or lang is not supported
315
+ */
286
316
  export function number(value: string, lang: string): string {
287
317
  const n = Number(value);
288
318
  if (isNaN(n)) throw new Error('Value must be a number');
@@ -1,6 +1,7 @@
1
1
  import { randomBytes } from 'crypto';
2
- import { SESSION_TTL, GAME_CLEANUP_TTL, RATE_LIMIT_WINDOW, RATE_LIMIT_MAX } from '../../constants.js';
3
- import type { TicTacToeStorage, TicTacToeMove, TicTacToeGame } from '../../types/storage.js';
2
+
3
+ import { GAME_CLEANUP_TTL, RATE_LIMIT_MAX, RATE_LIMIT_WINDOW, SESSION_TTL } from '../../constants.js';
4
+ import type { TicTacToeGame, TicTacToeMove, TicTacToeStorage } from '../../types/storage.js';
4
5
 
5
6
  interface TicTacToeParams {
6
7
  username?: string;
@@ -17,6 +18,14 @@ interface GameResult {
17
18
  tie?: boolean;
18
19
  }
19
20
 
21
+ /**
22
+ * Handles Tic-Tac-Toe game actions including playing a move, fetching game state, listing games, and forfeiting.
23
+ *
24
+ * @param action - The action to perform: "play", "fetch", "list", or "forfeit"
25
+ * @param params - Game parameters including username, move, session, game ID, and shared storage
26
+ * @returns Game state or action result depending on the action
27
+ * @throws Error if a required parameter is missing, the action is invalid, or a rate limit is exceeded
28
+ */
20
29
  export default function tic_tac_toe(action: string, params: TicTacToeParams): Record<string, unknown> {
21
30
  const storage = params.storage;
22
31
 
@@ -24,6 +24,17 @@ const validTimezones = ['UTC', 'America/New_York', 'Europe/Paris', 'Asia/Tokyo',
24
24
  type TimeFormat = (typeof validFormats)[number];
25
25
  type Timezone = (typeof validTimezones)[number];
26
26
 
27
+ /**
28
+ * Returns the current or a random date/time in various formats and timezones.
29
+ *
30
+ * @param type - "live" for the current time or "random" for a random date within a range
31
+ * @param start - Optional start date for random mode (YYYY-MM-DD)
32
+ * @param end - Optional end date for random mode (YYYY-MM-DD)
33
+ * @param format - Optional specific format to return (e.g. "iso", "timestamp", "year")
34
+ * @param timezone - Optional timezone (e.g. "UTC", "Europe/Paris")
35
+ * @returns Object containing all time formats, or a single format if specified
36
+ * @throws Error if type, format, or timezone is invalid
37
+ */
27
38
  export default function time(
28
39
  type: string = 'live',
29
40
  start?: string,
@@ -1,7 +1,16 @@
1
- import { randomBytes } from 'crypto';
1
+ import { randomBytes, randomInt } from 'crypto';
2
2
  import { v4 } from 'uuid';
3
- import { MIN_TOKEN_LENGTH, MAX_TOKEN_LENGTH } from '../../constants.js';
4
3
 
4
+ import { MAX_TOKEN_LENGTH, MIN_TOKEN_LENGTH } from '../../constants.js';
5
+
6
+ /**
7
+ * Generates a cryptographically random token of the specified length and type.
8
+ *
9
+ * @param len - Token length (must be between 12 and 4096)
10
+ * @param type - Character set to use: "alpha", "alphanum", "base64", "hex", "num", "punct", "urlsafe", or "uuid"
11
+ * @returns The generated token string
12
+ * @throws Error if length is out of range or the type is not valid
13
+ */
5
14
  export default function token(len: number, type: string = 'alphanum'): string {
6
15
  if (isNaN(len) || len < MIN_TOKEN_LENGTH) {
7
16
  throw new Error('Length must be a number greater than or equal to 12');
@@ -11,11 +20,8 @@ export default function token(len: number, type: string = 'alphanum'): string {
11
20
  throw new Error('Length cannot exceed 4096');
12
21
  }
13
22
 
14
- const genToken = (chars: string, length: number): string => {
15
- return Array.from({ length }, () => {
16
- return chars[Math.floor(Math.random() * chars.length)];
17
- }).join('');
18
- };
23
+ const genToken = (chars: string, length: number): string =>
24
+ Array.from({ length }, () => chars[randomInt(chars.length)]).join('');
19
25
 
20
26
  const tokenTypes: Record<string, () => string> = {
21
27
  alpha: () => genToken('abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ', len),
@@ -31,7 +37,11 @@ export default function token(len: number, type: string = 'alphanum'): string {
31
37
  num: () => genToken('0123456789', len),
32
38
  punct: () => genToken('!"#$%&\'()*+,-./:;<=>?@[\\]^_`{|}~', len),
33
39
  urlsafe: () => genToken('abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_', len),
34
- uuid: () => v4().replace(/-/g, '').slice(0, len),
40
+ uuid: () => {
41
+ let result = '';
42
+ while (result.length < len) result += v4().replace(/-/g, '');
43
+ return result.slice(0, len);
44
+ },
35
45
  };
36
46
 
37
47
  const lowerType = type.toLowerCase();
@@ -1,5 +1,10 @@
1
1
  import { random, randomNumber } from '../../utils/helpers.js';
2
2
 
3
+ /**
4
+ * Generates a random username combining adjectives, animals, and job titles.
5
+ *
6
+ * @returns Object containing the generated username, the number used, and the components (adjective, animal, job)
7
+ */
3
8
  export default function username(): Record<string, unknown> {
4
9
  const adj = [
5
10
  'Happy',
@@ -7,6 +7,13 @@ function checkValue(value: string): void {
7
7
  throw new Error(`Value must be less than ${MAX_STRING_LENGTH} characters long`);
8
8
  }
9
9
 
10
+ /**
11
+ * Validates a credit/debit card number using the Luhn algorithm.
12
+ *
13
+ * @param value - Card number string (digits, spaces, or dashes allowed)
14
+ * @returns Object containing the validity result and the sanitized digit string
15
+ * @throws Error if value is missing, contains non-digit characters, or has an invalid length
16
+ */
10
17
  export function luhn(value: string): { valid: boolean; value: string } {
11
18
  checkValue(value);
12
19
  const digits = value.replace(/\s|-/g, '');
@@ -28,6 +35,13 @@ export function luhn(value: string): { valid: boolean; value: string } {
28
35
  return { valid: sum % 10 === 0, value: digits };
29
36
  }
30
37
 
38
+ /**
39
+ * Validates an IBAN using the mod-97 checksum algorithm.
40
+ *
41
+ * @param value - IBAN string (spaces allowed)
42
+ * @returns Object containing the validity result, sanitized IBAN, and country code
43
+ * @throws Error if value is missing, has an invalid format, or is out of length bounds
44
+ */
31
45
  export function iban(value: string): { valid: boolean; value: string; country?: string } {
32
46
  checkValue(value);
33
47
  const cleaned = value.replace(/\s/g, '').toUpperCase();
@@ -48,6 +62,13 @@ export function iban(value: string): { valid: boolean; value: string; country?:
48
62
  return { valid: remainder === 1, value: cleaned, country: cleaned.slice(0, 2) };
49
63
  }
50
64
 
65
+ /**
66
+ * Validates an email address against a basic format check.
67
+ *
68
+ * @param value - The email address string to validate
69
+ * @returns Object containing the validity result and the original value
70
+ * @throws Error if value is missing, not a string, or too long
71
+ */
51
72
  export function email(value: string): { valid: boolean; value: string } {
52
73
  checkValue(value);
53
74
  const regex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
package/src/modules/v4.ts CHANGED
@@ -1,6 +1,8 @@
1
+ export { default as address } from './v4/address.js';
2
+ export { default as agent } from './v4/agent.js';
1
3
  export * as algorithms from './v4/algorithms.js';
2
- export { default as chat } from './v4/chat.js';
3
4
  export { default as captcha } from './v4/captcha.js';
5
+ export { default as chat } from './v4/chat.js';
4
6
  export { default as color } from './v4/color.js';
5
7
  export { default as convert } from './v4/convert.js';
6
8
  export { default as dice } from './v4/dice.js';
@@ -9,8 +11,10 @@ export * as encode from './v4/encode.js';
9
11
  export { default as geo } from './v4/geo.js';
10
12
  export { default as hash } from './v4/hash.js';
11
13
  export { default as hyperplanning } from './v4/hyperplanning.js';
14
+ export { default as ip } from './v4/ip.js';
12
15
  export { default as levenshtein } from './v4/levenshtein.js';
13
16
  export { default as palette } from './v4/palette.js';
17
+ export { default as password } from './v4/password.js';
14
18
  export { default as personal } from './v4/personal.js';
15
19
  export { default as placeholder } from './v4/placeholder.js';
16
20
  export { default as qrcode } from './v4/qrcode.js';
@@ -1,4 +1,5 @@
1
- import { Router, type Request, type Response } from 'express';
1
+ import { type Request, type Response, Router } from 'express';
2
+
2
3
  import { chatStorage, ticTacToeStorage } from '../storage/index.js';
3
4
  import { error } from '../utils/response.js';
4
5
 
package/src/routes/get.ts CHANGED
@@ -1,14 +1,18 @@
1
- import { Router, type Request, type Response } from 'express';
2
- import { versions } from '../config/versions.js';
1
+ import { type Request, type Response, Router } from 'express';
2
+
3
3
  import { env } from '../config/env.js';
4
- import { ipLimits } from '../storage/index.js';
5
- import { chatStorage } from '../storage/index.js';
4
+ import { versions } from '../config/versions.js';
6
5
  import { DOCS_URL, GITHUB_CACHE_TTL } from '../constants.js';
7
- import { error } from '../utils/response.js';
8
- import { since } from '../utils/helpers.js';
9
- import type { QRCodeOptions, QRCodeResult } from '../modules/v4/qrcode.js';
6
+ import type { AddressResult } from '../modules/v4/address.js';
7
+ import type { UserAgentResult } from '../modules/v4/agent.js';
10
8
  import type { CaptchaOptions, CaptchaResult } from '../modules/v4/captcha.js';
11
9
  import type { ColorResult } from '../modules/v4/color.js';
10
+ import type { IpResult } from '../modules/v4/ip.js';
11
+ import type { PasswordResult } from '../modules/v4/password.js';
12
+ import type { QRCodeOptions, QRCodeResult } from '../modules/v4/qrcode.js';
13
+ import { chatStorage, ipLimits } from '../storage/index.js';
14
+ import { since } from '../utils/helpers.js';
15
+ import { error } from '../utils/response.js';
12
16
 
13
17
  const router = Router();
14
18
 
@@ -174,6 +178,75 @@ router.get('/:version/convert', (req: Request, res: Response) => {
174
178
  }
175
179
  });
176
180
 
181
+ // Generate a fictional postal address
182
+ router.get('/:version/address', (req: Request, res: Response) => {
183
+ const { country, count } = req.query;
184
+ const { version } = req.params;
185
+
186
+ const addressFn = (req.module as { address?: (c?: string, n?: number) => AddressResult }).address;
187
+ if (!addressFn) {
188
+ error(res, 404, `Endpoint not available in ${version}.`, `${version}/address`);
189
+ return;
190
+ }
191
+
192
+ const parsedCount = count !== undefined ? parseInt(count as string, 10) : 1;
193
+
194
+ try {
195
+ const result = addressFn(country as string | undefined, parsedCount);
196
+ res.jsonResponse(result);
197
+ } catch (err) {
198
+ error(res, 400, (err as Error).message, `${req.version}/address`);
199
+ }
200
+ });
201
+
202
+ // Echo request headers
203
+ router.get('/:version/headers', (req: Request, res: Response) => {
204
+ const redacted = new Set(['authorization', 'cookie', 'set-cookie', 'proxy-authorization']);
205
+ let headers: Record<string, unknown> = {};
206
+
207
+ for (const [k, v] of Object.entries(req.headers)) {
208
+ headers[k] = redacted.has(k) ? '[redacted]' : v;
209
+ }
210
+
211
+ const filter = req.query.filter as string | undefined;
212
+ if (filter) {
213
+ const keys = new Set(filter.split(',').map((k) => k.trim().toLowerCase()));
214
+ headers = Object.fromEntries(Object.entries(headers).filter(([k]) => keys.has(k)));
215
+ }
216
+
217
+ res.jsonResponse({
218
+ count: Object.keys(headers).length,
219
+ headers,
220
+ ip: req.ip,
221
+ method: req.method,
222
+ url: req.originalUrl,
223
+ });
224
+ });
225
+
226
+ // Analyze an IP address
227
+ router.get('/:version/ip', (req: Request, res: Response) => {
228
+ const address = (req.query.address as string | undefined) ?? req.ip ?? '';
229
+ try {
230
+ const ipFn = (req.module as Record<string, unknown>).ip as (a: string) => IpResult;
231
+ const result = ipFn(address);
232
+ res.jsonResponse(result);
233
+ } catch (err) {
234
+ error(res, 400, (err as Error).message, `${req.version}/ip`);
235
+ }
236
+ });
237
+
238
+ // Parse a User-Agent string
239
+ router.get('/:version/agent', (req: Request, res: Response) => {
240
+ const ua = (req.query.ua as string | undefined) ?? (req.headers['user-agent'] as string) ?? '';
241
+ try {
242
+ const agentFn = (req.module as Record<string, unknown>).agent as (ua: string) => UserAgentResult;
243
+ const result = agentFn(ua);
244
+ res.jsonResponse(result);
245
+ } catch (err) {
246
+ error(res, 400, (err as Error).message, `${req.version}/agent`);
247
+ }
248
+ });
249
+
177
250
  // Generate domain informations
178
251
  router.get('/:version/domain', (req: Request, res: Response) => {
179
252
  try {
@@ -326,6 +399,41 @@ router.get('/:version/palette', (req: Request, res: Response) => {
326
399
  }
327
400
  });
328
401
 
402
+ // Generate a password or passphrase
403
+ router.get('/:version/password', (req: Request, res: Response) => {
404
+ const { type, length, uppercase, lowercase, digits, symbols, exclude, count, separator } = req.query;
405
+ const { version } = req.params;
406
+
407
+ const passwordFn = (
408
+ req.module as { password?: (t: string, l: number, o: Record<string, unknown>) => PasswordResult }
409
+ ).password;
410
+ if (!passwordFn) {
411
+ error(res, 404, `Endpoint not available in ${version}.`, `${version}/password`);
412
+ return;
413
+ }
414
+
415
+ const parseBool = (v: unknown, def: boolean): boolean => (v === undefined ? def : v !== 'false');
416
+
417
+ try {
418
+ const result = passwordFn(
419
+ (type as string) ?? 'random',
420
+ length !== undefined ? parseInt(length as string, 10) : 16,
421
+ {
422
+ uppercase: parseBool(uppercase, true),
423
+ lowercase: parseBool(lowercase, true),
424
+ digits: parseBool(digits, true),
425
+ symbols: parseBool(symbols, false),
426
+ exclude: (exclude as string) ?? '',
427
+ count: count !== undefined ? parseInt(count as string, 10) : 1,
428
+ separator: (separator as string) ?? '-',
429
+ },
430
+ );
431
+ res.jsonResponse(result);
432
+ } catch (err) {
433
+ error(res, 400, (err as Error).message, `${req.version}/password`);
434
+ }
435
+ });
436
+
329
437
  // Generate personal data
330
438
  router.get('/:version/personal', (req: Request, res: Response) => {
331
439
  try {
@@ -1,8 +1,9 @@
1
- import { Router, type Request, type Response } from 'express';
1
+ import { type Request, type Response, Router } from 'express';
2
+
2
3
  import { versions } from '../config/versions.js';
3
- import { ipLimits } from '../storage/index.js';
4
- import { logger } from '../middleware/logger.js';
5
4
  import { APP_VERSION, DOCS_URL, START_TIME } from '../constants.js';
5
+ import { logger } from '../middleware/logger.js';
6
+ import { ipLimits } from '../storage/index.js';
6
7
 
7
8
  const router = Router();
8
9
 
@@ -1,4 +1,5 @@
1
- import { Router, type Request, type Response } from 'express';
1
+ import { type Request, type Response, Router } from 'express';
2
+
2
3
  import { ticTacToeStorage } from '../storage/index.js';
3
4
  import { error } from '../utils/response.js';
4
5
 
@@ -1,9 +1,10 @@
1
- import { Router, type Request, type Response } from 'express';
1
+ import { type Request, type Response, Router } from 'express';
2
+
3
+ import { MAX_TOKEN_LENGTH, MIN_TOKEN_LENGTH } from '../constants.js';
4
+ import type { HashResult } from '../modules/v4/hash.js';
2
5
  import { chatStorage, ticTacToeStorage } from '../storage/index.js';
3
- import { MIN_TOKEN_LENGTH, MAX_TOKEN_LENGTH } from '../constants.js';
4
- import { error } from '../utils/response.js';
5
6
  import { since } from '../utils/helpers.js';
6
- import type { HashResult } from '../modules/v4/hash.js';
7
+ import { error } from '../utils/response.js';
7
8
 
8
9
  const router = Router();
9
10
 
@@ -1,4 +1,4 @@
1
- import type { ChatStorage, TicTacToeStorage, IpLimits } from '../types/storage.js';
1
+ import type { ChatStorage, IpLimits, TicTacToeStorage } from '../types/storage.js';
2
2
 
3
3
  export const ipLimits: IpLimits = {};
4
4
 
@@ -1,4 +1,5 @@
1
1
  import type { Response } from 'express';
2
+
2
3
  import { DOCS_URL, STATUS_MESSAGES } from '../constants.js';
3
4
 
4
5
  /**
@@ -1,7 +1,8 @@
1
- import { test, describe, before, after } from 'node:test';
2
1
  import { strict as assert } from 'node:assert';
3
- import type { AddressInfo } from 'node:net';
4
2
  import type { Server } from 'node:http';
3
+ import type { AddressInfo } from 'node:net';
4
+ import { after, before, describe, test } from 'node:test';
5
+
5
6
  import app from '../../src/app.js';
6
7
 
7
8
  let server: Server;
@@ -558,6 +559,145 @@ describe('Payload size limit', () => {
558
559
  });
559
560
  });
560
561
 
562
+ describe('GET /v4/headers', () => {
563
+ test('returns headers object with count, ip, method, url', async () => {
564
+ const { status, body } = await getJson('/v4/headers');
565
+ assert.equal(status, 200);
566
+ assert.ok(typeof body.count === 'number');
567
+ assert.ok(typeof body.headers === 'object');
568
+ assert.ok('method' in body);
569
+ assert.equal(body.method, 'GET');
570
+ assert.ok((body.url as string).includes('/v4/headers'));
571
+ });
572
+
573
+ test('filter param returns only matching headers', async () => {
574
+ const { body } = await getJson('/v4/headers?filter=host');
575
+ const headers = body.headers as Record<string, unknown>;
576
+ assert.ok('host' in headers);
577
+ assert.equal(body.count, 1);
578
+ });
579
+
580
+ test('cookie header is redacted', async () => {
581
+ const res = await fetch(`${baseUrl}/v4/headers`, {
582
+ headers: { Cookie: 'session=secret123' },
583
+ });
584
+ const body = (await res.json()) as Record<string, unknown>;
585
+ const headers = body.headers as Record<string, unknown>;
586
+ assert.equal(headers['cookie'], '[redacted]');
587
+ });
588
+ });
589
+
590
+ describe('GET /v4/ip', () => {
591
+ test('returns valid ip result with no address param', async () => {
592
+ const { status, body } = await getJson('/v4/ip');
593
+ assert.equal(status, 200);
594
+ assert.ok(['IPv4', 'IPv6'].includes(body.version as string));
595
+ assert.ok(typeof body.binary === 'string');
596
+ assert.ok(typeof body.reverse === 'string');
597
+ });
598
+
599
+ test('loopback 127.0.0.1', async () => {
600
+ const { status, body } = await getJson('/v4/ip?address=127.0.0.1');
601
+ assert.equal(status, 200);
602
+ assert.equal(body.type, 'loopback');
603
+ assert.equal(body.version, 'IPv4');
604
+ });
605
+
606
+ test('private 192.168.1.1', async () => {
607
+ const { body } = await getJson('/v4/ip?address=192.168.1.1');
608
+ assert.equal(body.type, 'private');
609
+ assert.equal(body.decimal, 3232235777);
610
+ });
611
+
612
+ test('invalid address returns 400', async () => {
613
+ const { status } = await getJson('/v4/ip?address=notanip');
614
+ assert.equal(status, 400);
615
+ });
616
+ });
617
+
618
+ describe('GET /v4/agent', () => {
619
+ test('returns parsed result with browser, os, device, engine, bot', async () => {
620
+ const { status, body } = await getJson('/v4/agent');
621
+ assert.equal(status, 200);
622
+ assert.ok(typeof body.browser === 'object');
623
+ assert.ok(typeof body.os === 'object');
624
+ assert.ok(typeof body.device === 'object');
625
+ assert.ok(typeof body.engine === 'object');
626
+ assert.ok(typeof body.bot === 'boolean');
627
+ });
628
+
629
+ test('custom ua param is parsed', async () => {
630
+ const ua = encodeURIComponent(
631
+ 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:121.0) Gecko/20100101 Firefox/121.0',
632
+ );
633
+ const { body } = await getJson(`/v4/agent?ua=${ua}`);
634
+ const browser = body.browser as Record<string, string>;
635
+ assert.equal(browser.name, 'Firefox');
636
+ assert.equal(browser.major, '121');
637
+ });
638
+
639
+ test('bot ua detected', async () => {
640
+ const ua = encodeURIComponent('Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)');
641
+ const { body } = await getJson(`/v4/agent?ua=${ua}`);
642
+ assert.equal(body.bot, true);
643
+ });
644
+ });
645
+
646
+ describe('GET /v4/address', () => {
647
+ test('returns one address by default', async () => {
648
+ const { status, body } = await getJson('/v4/address');
649
+ assert.equal(status, 200);
650
+ assert.equal((body.addresses as unknown[]).length, 1);
651
+ });
652
+
653
+ test('country=fr returns a French address', async () => {
654
+ const { body } = await getJson('/v4/address?country=fr');
655
+ const a = (body.addresses as Record<string, string>[])[0]!;
656
+ assert.equal(a.countryCode, 'FR');
657
+ });
658
+
659
+ test('count=3 returns 3 addresses', async () => {
660
+ const { body } = await getJson('/v4/address?count=3');
661
+ assert.equal((body.addresses as unknown[]).length, 3);
662
+ });
663
+
664
+ test('unknown country returns 400', async () => {
665
+ const { status } = await getJson('/v4/address?country=xx');
666
+ assert.equal(status, 400);
667
+ });
668
+ });
669
+
670
+ describe('GET /v4/password', () => {
671
+ test('returns one password by default', async () => {
672
+ const { status, body } = await getJson('/v4/password');
673
+ assert.equal(status, 200);
674
+ assert.equal((body.passwords as string[]).length, 1);
675
+ assert.equal((body.passwords as string[])[0]!.length, 16);
676
+ });
677
+
678
+ test('length param is respected', async () => {
679
+ const { body } = await getJson('/v4/password?length=32');
680
+ assert.equal((body.passwords as string[])[0]!.length, 32);
681
+ });
682
+
683
+ test('passphrase mode returns words', async () => {
684
+ const { body } = await getJson('/v4/password?type=passphrase&length=4');
685
+ assert.equal(body.type, 'passphrase');
686
+ assert.equal((body.passwords as string[])[0]!.split('-').length, 4);
687
+ });
688
+
689
+ test('count=5 returns 5 passwords', async () => {
690
+ const { body } = await getJson('/v4/password?count=5');
691
+ assert.equal((body.passwords as string[]).length, 5);
692
+ });
693
+
694
+ test('no charset active returns 400', async () => {
695
+ const { status } = await getJson('/v4/password?uppercase=false&lowercase=false&digits=false&symbols=false');
696
+ assert.equal(status, 400);
697
+ });
698
+ });
699
+
700
+
561
701
  describe('Prototype access on dynamic endpoints', () => {
562
702
  test('algorithms?method=toString returns 400', async () => {
563
703
  const { status } = await getJson('/v4/algorithms?method=toString');
@@ -0,0 +1,84 @@
1
+ import { strict as assert } from 'node:assert';
2
+ import { describe, test } from 'node:test';
3
+
4
+ import address from '../../src/modules/v4/address.js';
5
+
6
+ describe('address', () => {
7
+ describe('default behavior', () => {
8
+ test('returns an addresses array with one entry by default', () => {
9
+ const r = address();
10
+ assert.equal(r.addresses.length, 1);
11
+ });
12
+
13
+ test('each address has required fields', () => {
14
+ const r = address();
15
+ const a = r.addresses[0]!;
16
+ assert.ok(a.street);
17
+ assert.ok(a.city);
18
+ assert.ok(a.zip);
19
+ assert.ok(a.state);
20
+ assert.ok(a.country);
21
+ assert.ok(a.countryCode);
22
+ });
23
+ });
24
+
25
+ describe('country filtering', () => {
26
+ test('fr returns a French address', () => {
27
+ const r = address('fr');
28
+ assert.equal(r.addresses[0]!.countryCode, 'FR');
29
+ assert.equal(r.addresses[0]!.country, 'France');
30
+ });
31
+
32
+ test('us returns a US address', () => {
33
+ const r = address('us');
34
+ assert.equal(r.addresses[0]!.countryCode, 'US');
35
+ assert.equal(r.addresses[0]!.country, 'United States');
36
+ });
37
+
38
+ test('uk returns a UK address', () => {
39
+ const r = address('uk');
40
+ assert.equal(r.addresses[0]!.countryCode, 'UK');
41
+ });
42
+
43
+ test('de returns a German address', () => {
44
+ const r = address('de');
45
+ assert.equal(r.addresses[0]!.countryCode, 'DE');
46
+ });
47
+
48
+ test('es returns a Spanish address', () => {
49
+ const r = address('es');
50
+ assert.equal(r.addresses[0]!.countryCode, 'ES');
51
+ });
52
+
53
+ test('country code is case-insensitive', () => {
54
+ const r = address('FR');
55
+ assert.equal(r.addresses[0]!.countryCode, 'FR');
56
+ });
57
+ });
58
+
59
+ describe('count', () => {
60
+ test('count=5 returns 5 addresses', () => {
61
+ const r = address('fr', 5);
62
+ assert.equal(r.addresses.length, 5);
63
+ });
64
+
65
+ test('count=10 returns 10 addresses', () => {
66
+ const r = address('us', 10);
67
+ assert.equal(r.addresses.length, 10);
68
+ });
69
+ });
70
+
71
+ describe('errors', () => {
72
+ test('unknown country throws', () => {
73
+ assert.throws(() => address('xx'), /Unknown country code/);
74
+ });
75
+
76
+ test('count=0 throws', () => {
77
+ assert.throws(() => address('fr', 0), /Count must be between/);
78
+ });
79
+
80
+ test('count=11 throws', () => {
81
+ assert.throws(() => address('fr', 11), /Count must be between/);
82
+ });
83
+ });
84
+ });