diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 74612e6..6bebc3f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -52,6 +52,11 @@ jobs: image: redis:7-alpine ports: - 6379:6379 + options: >- + --health-cmd "redis-cli ping" + --health-interval 5s + --health-timeout 5s + --health-retries 5 steps: - uses: actions/checkout@v4 diff --git a/package-lock.json b/package-lock.json index c07be2d..a569c9d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "linkforge", - "version": "0.3.0", + "version": "0.4.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "linkforge", - "version": "0.3.0", + "version": "0.4.0", "license": "MIT", "dependencies": { "@fastify/cors": "^11.2.0", @@ -18,6 +18,7 @@ "fastify": "^5.8.5", "ioredis": "^5.10.1", "jose": "^6.2.3", + "nanoid": "^5.1.11", "pg": "^8.21.0", "pino": "^10.3.1", "zod": "^4.4.3" @@ -5771,10 +5772,9 @@ "optional": true }, "node_modules/nanoid": { - "version": "3.3.12", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", - "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", - "dev": true, + "version": "5.1.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.1.11.tgz", + "integrity": "sha512-v+KEsUv2ps74PaSKv0gHTxTCgMXOIfBEbaqa6w6ISIGC7ZsvHN4N9oJ8d4cmf0n5oTzQz2SLmThbQWhjd/8eKg==", "funding": [ { "type": "github", @@ -5783,10 +5783,10 @@ ], "license": "MIT", "bin": { - "nanoid": "bin/nanoid.cjs" + "nanoid": "bin/nanoid.js" }, "engines": { - "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + "node": "^18 || >=20" } }, "node_modules/natural-compare": { @@ -6254,6 +6254,25 @@ } } }, + "node_modules/postcss/node_modules/nanoid": { + "version": "3.3.12", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", + "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, "node_modules/postgres": { "version": "3.4.7", "resolved": "https://registry.npmjs.org/postgres/-/postgres-3.4.7.tgz", diff --git a/package.json b/package.json index 8d01a31..c28fa31 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "linkforge", - "version": "0.3.0", + "version": "0.4.0", "description": "URL shortener API with authentication, API keys, async click tracking and analytics.", "type": "module", "license": "MIT", @@ -38,6 +38,7 @@ "fastify": "^5.8.5", "ioredis": "^5.10.1", "jose": "^6.2.3", + "nanoid": "^5.1.11", "pg": "^8.21.0", "pino": "^10.3.1", "zod": "^4.4.3" diff --git a/prisma/migrations/20260526160925_links_model/migration.sql b/prisma/migrations/20260526160925_links_model/migration.sql new file mode 100644 index 0000000..f3cc246 --- /dev/null +++ b/prisma/migrations/20260526160925_links_model/migration.sql @@ -0,0 +1,22 @@ +-- CreateTable +CREATE TABLE "links" ( + "id" UUID NOT NULL, + "code" TEXT NOT NULL, + "target" TEXT NOT NULL, + "userId" UUID NOT NULL, + "expiresAt" TIMESTAMP(3), + "deletedAt" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "links_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "links_code_key" ON "links"("code"); + +-- CreateIndex +CREATE INDEX "links_userId_createdAt_id_idx" ON "links"("userId", "createdAt", "id"); + +-- AddForeignKey +ALTER TABLE "links" ADD CONSTRAINT "links_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 893bc29..35f89da 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -15,6 +15,7 @@ model User { refreshTokens RefreshToken[] apiKeys ApiKey[] + links Link[] @@map("users") } @@ -46,4 +47,20 @@ model ApiKey { @@index([userId]) @@map("api_keys") +} + +model Link { + id String @id @default(uuid()) @db.Uuid + code String @unique + target String + userId String @db.Uuid + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + expiresAt DateTime? + deletedAt DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + // Cursor pagination scans by (userId, createdAt, id). + @@index([userId, createdAt, id]) + @@map("links") } \ No newline at end of file diff --git a/src/app.ts b/src/app.ts index 09b24da..3e23202 100644 --- a/src/app.ts +++ b/src/app.ts @@ -7,6 +7,7 @@ import { registerErrorHandler } from '@/shared/errors/error-handler'; import { healthRoutes } from '@/modules/health/health.routes'; import { authRoutes } from './modules/auth/auth.routes'; import { apiKeyRoutes } from './modules/api-keys/api-keys.routes'; +import { linkRoutes } from './modules/links/links.routes'; /** * Builds a fully configured Fastify instance without starting the server. @@ -30,6 +31,7 @@ export async function buildApp(): Promise { (v1) => { v1.register(authRoutes); v1.register(apiKeyRoutes); + v1.register(linkRoutes); }, { prefix: '/v1' }, ); diff --git a/src/modules/links/links.controller.ts b/src/modules/links/links.controller.ts new file mode 100644 index 0000000..768f960 --- /dev/null +++ b/src/modules/links/links.controller.ts @@ -0,0 +1,45 @@ +import type { FastifyReply, FastifyRequest } from 'fastify'; +import { getAuth } from '@/shared/middleware/authenticate'; +import { getIdempotencyKey } from '@/shared/middleware/idempotency'; +import type { LinksService } from './links.service'; +import { + createLinkSchema, + linkIdParamsSchema, + listLinksQuerySchema, + updateLinkSchema, +} from './links.schemas'; + +export function createLinksController(service: LinksService) { + return { + create: async (request: FastifyRequest, reply: FastifyReply) => { + const { userId } = getAuth(request); + const input = createLinkSchema.parse(request.body); + const link = await service.create({ + userId, + input, + idempotencyKey: getIdempotencyKey(request), + }); + return reply.status(201).send(link); + }, + + list: async (request: FastifyRequest, reply: FastifyReply) => { + const { userId } = getAuth(request); + const { cursor, limit } = listLinksQuerySchema.parse(request.query); + return reply.send(await service.list(userId, limit, cursor)); + }, + + update: async (request: FastifyRequest, reply: FastifyReply) => { + const { userId } = getAuth(request); + const { id } = linkIdParamsSchema.parse(request.params); + const input = updateLinkSchema.parse(request.body); + return reply.send(await service.update(userId, id, input)); + }, + + remove: async (request: FastifyRequest, reply: FastifyReply) => { + const { userId } = getAuth(request); + const { id } = linkIdParamsSchema.parse(request.params); + await service.remove(userId, id); + return reply.status(204).send(); + }, + }; +} diff --git a/src/modules/links/links.repository.ts b/src/modules/links/links.repository.ts new file mode 100644 index 0000000..918fe7b --- /dev/null +++ b/src/modules/links/links.repository.ts @@ -0,0 +1,69 @@ +import type { Prisma, PrismaClient } from '@prisma/client'; + +export function createLinksRepository(db: PrismaClient) { + return { + findByCode(code: string) { + return db.link.findFirst({ + where: { + code, + deletedAt: null, + }, + }); + }, + + findById(id: string) { + return db.link.findFirst({ + where: { + id, + deletedAt: null, + }, + }); + }, + + create(data: { code: string; target: string; userId: string; expiresAt?: Date | null }) { + return db.link.create({ + data, + }); + }, + + /** Cursor pagination: fetch limit+1 to detect whether another page exists. */ + listByUser(userId: string, limit: number, cursorId?: string) { + return db.link.findMany({ + where: { + userId, + deletedAt: null, + }, + + orderBy: [{ createdAt: 'desc' }, { id: 'desc' }], + + take: limit + 1, + + ...(cursorId + ? { + cursor: { id: cursorId }, + skip: 1, + } + : {}), + }); + }, + + update(id: string, data: Prisma.LinkUpdateInput) { + return db.link.update({ + where: { id }, + data, + }); + }, + + softDelete(id: string) { + return db.link.update({ + where: { id }, + + data: { + deletedAt: new Date(), + }, + }); + }, + }; +} + +export type LinksRepository = ReturnType; diff --git a/src/modules/links/links.routes.ts b/src/modules/links/links.routes.ts new file mode 100644 index 0000000..65a856f --- /dev/null +++ b/src/modules/links/links.routes.ts @@ -0,0 +1,20 @@ +import type { FastifyInstance } from 'fastify'; +import { prisma } from '@/shared/db'; +import { redis } from '@/shared/cache/redis'; +import { createCacheService } from '@/shared/cache/cache.service'; +import { authenticate, requireScope } from '@/shared/middleware/authenticate'; +import { createLinksRepository } from './links.repository'; +import { createLinksService } from './links.service'; +import { createLinksController } from './links.controller'; + +export function linkRoutes(app: FastifyInstance): void { + const service = createLinksService(createLinksRepository(prisma), createCacheService(redis)); + const controller = createLinksController(service); + + app.addHook('preHandler', authenticate); + + app.post('/links', { preHandler: requireScope('write') }, controller.create); + app.get('/links', { preHandler: requireScope('read') }, controller.list); + app.patch('/links/:id', { preHandler: requireScope('write') }, controller.update); + app.delete('/links/:id', { preHandler: requireScope('write') }, controller.remove); +} diff --git a/src/modules/links/links.schemas.ts b/src/modules/links/links.schemas.ts new file mode 100644 index 0000000..45cc1eb --- /dev/null +++ b/src/modules/links/links.schemas.ts @@ -0,0 +1,30 @@ +import { z } from 'zod'; + +const slugPattern = /^[a-zA-Z0-9_-]{3,30}$/; + +export const createLinkSchema = z.object({ + target: z.url(), + customSlug: z + .string() + .regex(slugPattern, 'Slug must be 3-30 chars: a-z, A-Z, 0-9, _ or -') + .optional(), + expiresAt: z.iso.datetime().optional(), +}); + +export const updateLinkSchema = z + .object({ + target: z.url().optional(), + expiresAt: z.iso.datetime().nullable().optional(), + }) + .refine((data) => Object.keys(data).length > 0, { error: 'At least one field is required' }); + +export const listLinksQuerySchema = z.object({ + cursor: z.string().optional(), + limit: z.coerce.number().int().min(1).max(100).default(20), +}); + +export const linkIdParamsSchema = z.object({ id: z.uuid() }); + +export type CreateLinkInput = z.infer; +export type UpdateLinkInput = z.infer; +export type ListLinksQuery = z.infer; diff --git a/src/modules/links/links.service.ts b/src/modules/links/links.service.ts new file mode 100644 index 0000000..1831fb4 --- /dev/null +++ b/src/modules/links/links.service.ts @@ -0,0 +1,146 @@ +import { Errors } from '@/shared/errors/app-error'; +import { env } from '@/config/env'; +import { generateShortCode } from '@/shared/utils/short-code'; +import { isSafePublicUrl } from '@/shared/utils/url'; + +import type { CacheService } from '@/shared/cache/cache.service'; +import type { LinksRepository } from './links.repository'; + +import type { CreateLinkInput, UpdateLinkInput } from './links.schemas'; + +interface CreateOptions { + userId: string; + input: CreateLinkInput; + idempotencyKey?: string; +} + +export function createLinksService(repo: LinksRepository, cache: CacheService) { + async function generateUniqueCode(maxAttempts = 5): Promise { + for (let attempt = 0; attempt < maxAttempts; attempt++) { + const code = generateShortCode(); + + if (!(await repo.findByCode(code))) { + return code; + } + } + + throw new Error('Unable to generate a unique short code'); + } + + function present(link: { + id: string; + code: string; + target: string; + expiresAt: Date | null; + createdAt: Date; + }) { + return { + id: link.id, + code: link.code, + target: link.target, + shortUrl: `${env.BASE_URL}/${link.code}`, + expiresAt: link.expiresAt, + createdAt: link.createdAt, + }; + } + + return { + async create({ userId, input, idempotencyKey }: CreateOptions) { + if (!isSafePublicUrl(input.target)) { + throw Errors.badRequest('Target must be a public http(s) URL'); + } + + if (idempotencyKey) { + const cached = await cache.getJson<{ + linkId: string; + }>(`idem:${userId}:${idempotencyKey}`); + + if (cached) { + const existing = await repo.findById(cached.linkId); + + if (existing) { + return present(existing); + } + } + } + + if (input.customSlug) { + const taken = await repo.findByCode(input.customSlug); + + if (taken) { + throw Errors.conflict(`Slug "${input.customSlug}" is already taken`); + } + } + + const code = input.customSlug ?? (await generateUniqueCode()); + + const link = await repo.create({ + code, + target: input.target, + userId, + expiresAt: input.expiresAt ? new Date(input.expiresAt) : null, + }); + + if (idempotencyKey) { + await cache.setJson(`idem:${userId}:${idempotencyKey}`, { linkId: link.id }, 86_400); + } + + return present(link); + }, + + async list(userId: string, limit: number, cursor?: string) { + const rows = await repo.listByUser(userId, limit, cursor); + + const hasMore = rows.length > limit; + + const items = hasMore ? rows.slice(0, limit) : rows; + + return { + items: items.map((link) => present(link)), + + nextCursor: hasMore ? (items.at(-1)?.id ?? null) : null, + }; + }, + + async update(userId: string, id: string, input: UpdateLinkInput) { + const link = await repo.findById(id); + + if (!link || link.userId !== userId) { + throw Errors.notFound('Link'); + } + + if (input.target && !isSafePublicUrl(input.target)) { + throw Errors.badRequest('Target must be a public http(s) URL'); + } + + const updated = await repo.update(id, { + ...(input.target ? { target: input.target } : {}), + + ...(input.expiresAt !== undefined + ? { + expiresAt: input.expiresAt ? new Date(input.expiresAt) : null, + } + : {}), + }); + + // Invalidate redirect cache + await cache.del(`link:${link.code}`); + + return present(updated); + }, + + async remove(userId: string, id: string): Promise { + const link = await repo.findById(id); + + if (!link || link.userId !== userId) { + throw Errors.notFound('Link'); + } + + await repo.softDelete(id); + + await cache.del(`link:${link.code}`); + }, + }; +} + +export type LinksService = ReturnType; diff --git a/src/shared/cache/cache.service.ts b/src/shared/cache/cache.service.ts new file mode 100644 index 0000000..442cc7d --- /dev/null +++ b/src/shared/cache/cache.service.ts @@ -0,0 +1,21 @@ +import type { Redis } from 'ioredis'; + +export function createCacheService(redis: Redis) { + return { + async getJson(key: string): Promise { + const raw = await redis.get(key); + + return raw ? (JSON.parse(raw) as T) : null; + }, + + async setJson(key: string, value: unknown, ttlSeconds: number): Promise { + await redis.set(key, JSON.stringify(value), 'EX', ttlSeconds); + }, + + async del(key: string): Promise { + await redis.del(key); + }, + }; +} + +export type CacheService = ReturnType; diff --git a/src/shared/middleware/authenticate.ts b/src/shared/middleware/authenticate.ts index b058ebd..532d1fb 100644 --- a/src/shared/middleware/authenticate.ts +++ b/src/shared/middleware/authenticate.ts @@ -1,4 +1,4 @@ -import type { FastifyRequest } from 'fastify'; +import type { FastifyReply, FastifyRequest } from 'fastify'; import { Errors } from '@/shared/errors/app-error'; import { verifyAccessToken } from '@/shared/auth/jwt'; import { sha256 } from '@/shared/auth/tokens'; @@ -54,8 +54,12 @@ export function getAuth(request: FastifyRequest): AuthContext { } export function requireScope(scope: string) { - return (request: FastifyRequest): void => { + return (request: FastifyRequest, _reply: FastifyReply, done: (err?: Error) => void): void => { const auth = getAuth(request); - if (!auth.scopes.includes(scope)) throw Errors.forbidden(`Missing scope: ${scope}`); + if (!auth.scopes.includes(scope)) { + done(Errors.forbidden(`Missing scope: ${scope}`)); + return; + } + done(); }; } diff --git a/src/shared/middleware/idempotency.ts b/src/shared/middleware/idempotency.ts new file mode 100644 index 0000000..4692a68 --- /dev/null +++ b/src/shared/middleware/idempotency.ts @@ -0,0 +1,9 @@ +import type { FastifyRequest } from 'fastify'; + +/** Reads and lightly validates the optional Idempotency-Key header. */ +export function getIdempotencyKey(request: FastifyRequest): string | undefined { + const value = request.headers['idempotency-key']; + if (typeof value !== 'string') return undefined; + const trimmed = value.trim(); + return trimmed.length > 0 && trimmed.length <= 255 ? trimmed : undefined; +} diff --git a/src/shared/utils/short-code.ts b/src/shared/utils/short-code.ts new file mode 100644 index 0000000..5b56965 --- /dev/null +++ b/src/shared/utils/short-code.ts @@ -0,0 +1,11 @@ +import { customAlphabet } from 'nanoid'; + +// Base62, 7 chars. ~3.5e12 combinations — ample headroom before collisions matter. +const ALPHABET = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'; +const CODE_LENGTH = 7; + +const nanoid = customAlphabet(ALPHABET, CODE_LENGTH); + +export function generateShortCode(): string { + return nanoid(); +} diff --git a/src/shared/utils/url.ts b/src/shared/utils/url.ts new file mode 100644 index 0000000..ee8371f --- /dev/null +++ b/src/shared/utils/url.ts @@ -0,0 +1,42 @@ +import { isIP } from 'node:net'; + +const BLOCKED_HOSTNAMES = new Set(['localhost', '127.0.0.1', '0.0.0.0', '::1']); + +/** + * Rejects targets that point to the local machine or private network ranges, + * to avoid the shortener being used as an SSRF pivot. Only http(s) is allowed. + */ +export function isSafePublicUrl(raw: string): boolean { + let url: URL; + try { + url = new URL(raw); + } catch { + return false; + } + + if (url.protocol !== 'http:' && url.protocol !== 'https:') return false; + + const host = url.hostname.toLowerCase(); + if (BLOCKED_HOSTNAMES.has(host)) return false; + if (host.endsWith('.localhost') || host.endsWith('.internal')) return false; + + if (isIP(host) && isPrivateIp(host)) return false; + + return true; +} + +function isPrivateIp(ip: string): boolean { + // IPv6 private/link-local/unique-local. + if (ip.includes(':')) { + return ip.startsWith('fe80') || ip.startsWith('fc') || ip.startsWith('fd'); + } + + const parts = ip.split('.').map(Number); + const [a, b] = parts as [number, number, number, number]; + if (a === 10) return true; // 10.0.0.0/8 + if (a === 127) return true; // loopback + if (a === 169 && b === 254) return true; // link-local + if (a === 172 && b >= 16 && b <= 31) return true; // 172.16.0.0/12 + if (a === 192 && b === 168) return true; // 192.168.0.0/16 + return false; +} diff --git a/tests/integration/links.test.ts b/tests/integration/links.test.ts new file mode 100644 index 0000000..646a12f --- /dev/null +++ b/tests/integration/links.test.ts @@ -0,0 +1,142 @@ +import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest'; +import type { FastifyInstance } from 'fastify'; +import { buildTestApp, resetDb } from '../helpers/test-app'; +import { prisma } from '@/shared/db'; + +type AuthBody = { accessToken: string }; +type LinkBody = { id: string; code: string; shortUrl: string }; +type PageBody = { items: { id: string }[]; nextCursor: string | null }; + +describe('Links CRUD', () => { + let app: FastifyInstance; + + beforeAll(async () => { + app = await buildTestApp(); + }); + + afterAll(async () => { + await app.close(); + await prisma.$disconnect(); + }); + + beforeEach(resetDb); + + async function auth(): Promise<{ authorization: string }> { + const res = await app.inject({ + method: 'POST', + url: '/v1/auth/register', + payload: { email: 'owner@example.com', password: 'SuperSecret123' }, + }); + const { accessToken } = res.json(); + return { authorization: `Bearer ${accessToken}` }; + } + + it('creates a link with an auto-generated 7-char code', async () => { + const headers = await auth(); + const res = await app.inject({ + method: 'POST', + url: '/v1/links', + headers, + payload: { target: 'https://example.com' }, + }); + expect(res.statusCode).toBe(201); + const body = res.json(); + expect(body.code).toMatch(/^[a-zA-Z0-9]{7}$/); + expect(body.shortUrl).toContain(body.code); + }); + + it('rejects a private/SSRF target with 400', async () => { + const headers = await auth(); + const res = await app.inject({ + method: 'POST', + url: '/v1/links', + headers, + payload: { target: 'http://169.254.169.254/latest/meta-data' }, + }); + expect(res.statusCode).toBe(400); + }); + + it('rejects a duplicate custom slug with 409', async () => { + const headers = await auth(); + const payload = { target: 'https://example.com', customSlug: 'my-link' }; + await app.inject({ method: 'POST', url: '/v1/links', headers, payload }); + const res = await app.inject({ method: 'POST', url: '/v1/links', headers, payload }); + expect(res.statusCode).toBe(409); + }); + + it('returns the same link for a repeated idempotency key', async () => { + const headers = { ...(await auth()), 'idempotency-key': 'key-123' }; + const payload = { target: 'https://example.com/page' }; + const first = await app.inject({ method: 'POST', url: '/v1/links', headers, payload }); + const second = await app.inject({ method: 'POST', url: '/v1/links', headers, payload }); + expect(first.json().id).toBe(second.json().id); + }); + + it('paginates with a cursor', async () => { + const headers = await auth(); + for (let i = 0; i < 3; i++) { + await app.inject({ + method: 'POST', + url: '/v1/links', + headers, + payload: { target: `https://example.com/${i}` }, + }); + } + const page1 = await app.inject({ method: 'GET', url: '/v1/links?limit=2', headers }); + const body1 = page1.json(); + expect(body1.items).toHaveLength(2); + expect(body1.nextCursor).not.toBeNull(); + + const page2 = await app.inject({ + method: 'GET', + url: `/v1/links?limit=2&cursor=${body1.nextCursor}`, + headers, + }); + const body2 = page2.json(); + expect(body2.items).toHaveLength(1); + expect(body2.nextCursor).toBeNull(); + }); + + it('soft-deletes a link and hides it from the list', async () => { + const headers = await auth(); + const created = await app.inject({ + method: 'POST', + url: '/v1/links', + headers, + payload: { target: 'https://example.com' }, + }); + const { id } = created.json(); + + const del = await app.inject({ method: 'DELETE', url: `/v1/links/${id}`, headers }); + expect(del.statusCode).toBe(204); + + const list = await app.inject({ method: 'GET', url: '/v1/links', headers }); + expect(list.json().items).toHaveLength(0); + }); + + it("forbids acting on another user's link with 404", async () => { + const ownerHeaders = await auth(); + const created = await app.inject({ + method: 'POST', + url: '/v1/links', + headers: ownerHeaders, + payload: { target: 'https://example.com' }, + }); + const { id } = created.json(); + + const otherRes = await app.inject({ + method: 'POST', + url: '/v1/auth/register', + payload: { email: 'intruder@example.com', password: 'SuperSecret123' }, + }); + const intruderHeaders = { authorization: `Bearer ${otherRes.json().accessToken}` }; + + const res = await app.inject({ + method: 'PATCH', + url: `/v1/links/${id}`, + headers: intruderHeaders, + payload: { target: 'https://evil.example.com' }, + }); + expect(res.statusCode).toBe(404); + }); +}); diff --git a/tests/unit/url.test.ts b/tests/unit/url.test.ts new file mode 100644 index 0000000..6aa878f --- /dev/null +++ b/tests/unit/url.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from 'vitest'; +import { isSafePublicUrl } from '@/shared/utils/url'; + +describe('isSafePublicUrl', () => { + it('accepts public http(s) URLs', () => { + expect(isSafePublicUrl('https://example.com/path?q=1')).toBe(true); + expect(isSafePublicUrl('http://example.org')).toBe(true); + }); + + it('rejects non-http protocols', () => { + expect(isSafePublicUrl('ftp://example.com')).toBe(false); + expect(isSafePublicUrl('javascript:alert(1)')).toBe(false); + expect(isSafePublicUrl('file:///etc/passwd')).toBe(false); + }); + + it('rejects localhost and private ranges', () => { + expect(isSafePublicUrl('http://localhost')).toBe(false); + expect(isSafePublicUrl('http://127.0.0.1')).toBe(false); + expect(isSafePublicUrl('http://10.0.0.5')).toBe(false); + expect(isSafePublicUrl('http://192.168.1.1')).toBe(false); + expect(isSafePublicUrl('http://172.16.0.1')).toBe(false); + expect(isSafePublicUrl('http://169.254.0.1')).toBe(false); + }); + + it('rejects malformed input', () => { + expect(isSafePublicUrl('not a url')).toBe(false); + expect(isSafePublicUrl('')).toBe(false); + }); +});