diff --git a/backend/tests/e2e/tier2.spec.ts b/backend/tests/e2e/tier2.spec.ts new file mode 100644 index 0000000..b310695 --- /dev/null +++ b/backend/tests/e2e/tier2.spec.ts @@ -0,0 +1,349 @@ +import { describe, expect, it } from "vitest"; +import { apiFetch, clearCookieJar } from "./setup.js"; + +describe("Tier 2 E2E - Boundaries and Errors", () => { + // --- Feature 1: Authentication --- + describe("Auth Boundary and Errors", () => { + const sessionId = "tier2_auth"; + + it("should return 400 validation error when email is missing", async () => { + clearCookieJar(sessionId); + const res = await apiFetch("/api/auth/login", { + method: "POST", + sessionId, + body: JSON.stringify({ password: "correct-password" }), + }); + + expect(res.$status).toBe(400); + expect(res.error?.code).toBe("VALIDATION_ERROR"); + expect(res.error?.fields?.email).toBeDefined(); + }); + + it("should return 400 validation error when password is missing", async () => { + clearCookieJar(sessionId); + const res = await apiFetch("/api/auth/login", { + method: "POST", + sessionId, + body: JSON.stringify({ email: "owner@example.com" }), + }); + + expect(res.$status).toBe(400); + expect(res.error?.code).toBe("VALIDATION_ERROR"); + expect(res.error?.fields?.password).toBeDefined(); + }); + + it("should return 401 when password is incorrect", async () => { + clearCookieJar(sessionId); + const res = await apiFetch("/api/auth/login", { + method: "POST", + sessionId, + body: JSON.stringify({ email: "owner@example.com", password: "wrong-password" }), + }); + + expect(res.$status).toBe(401); + expect(res.error?.code).toBe("PERMISSION_DENIED"); + expect(res.data).toBeNull(); + }); + + it("should return 401 unauthorized when fetching profile without session", async () => { + clearCookieJar(sessionId); + const res = await apiFetch("/api/auth/me", { + method: "GET", + sessionId, + }); + + expect(res.$status).toBe(401); + expect(res.error?.code).toBe("PERMISSION_DENIED"); + expect(res.data).toBeNull(); + }); + + it("should return 401 when providing invalid 2FA verification code", async () => { + clearCookieJar(sessionId); + const res = await apiFetch("/api/auth/login", { + method: "POST", + sessionId, + body: JSON.stringify({ + email: "owner-2fa@example.com", + password: "2fa-password", + code: "999999", // wrong code + }), + }); + + expect(res.$status).toBe(401); + expect(res.error?.code).toBe("PERMISSION_DENIED"); + expect(res.error?.fields?.code).toBeDefined(); + }); + }); + + // --- Feature 2: Websites CRUD --- + describe("Websites Boundary and Errors", () => { + const sessionId = "tier2_websites"; + + it("should fail validation when name is shorter than 3 characters", async () => { + clearCookieJar(sessionId); + await apiFetch("/api/auth/login", { + method: "POST", + sessionId, + body: JSON.stringify({ email: "owner@example.com", password: "correct-password" }), + }); + + const res = await apiFetch("/api/websites", { + method: "POST", + sessionId, + body: JSON.stringify({ + name: "ab", + domain: "valid.com", + }), + }); + + expect(res.$status).toBe(400); + expect(res.error?.code).toBe("VALIDATION_ERROR"); + expect(res.error?.fields?.name).toBeDefined(); + }); + + it("should fail validation when domain is syntactically invalid", async () => { + const res = await apiFetch("/api/websites", { + method: "POST", + sessionId, + body: JSON.stringify({ + name: "ValidName", + domain: "invaliddomain", // no dot + }), + }); + + expect(res.$status).toBe(400); + expect(res.error?.code).toBe("VALIDATION_ERROR"); + expect(res.error?.fields?.domain).toBeDefined(); + }); + + it("should fail with 409 conflict when creating a duplicate website name", async () => { + // Create first + await apiFetch("/api/websites", { + method: "POST", + sessionId, + body: JSON.stringify({ name: "UniqueSite", domain: "site1.com" }), + }); + + // Create duplicate + const res = await apiFetch("/api/websites", { + method: "POST", + sessionId, + body: JSON.stringify({ name: "UniqueSite", domain: "site2.com" }), + }); + + expect(res.$status).toBe(409); + expect(res.error?.code).toBe("VALIDATION_ERROR"); + expect(res.error?.fields?.name).toBeDefined(); + }); + + it("should return 404 when fetching a non-existent website details", async () => { + const res = await apiFetch("/api/websites/non-existent-id", { + method: "GET", + sessionId, + }); + + expect(res.$status).toBe(404); + expect(res.error?.code).toBe("NOT_FOUND"); + }); + + it("should return 404 when updating a non-existent website", async () => { + const res = await apiFetch("/api/websites/non-existent-id", { + method: "PUT", + sessionId, + body: JSON.stringify({ runtime: "PHP" }), + }); + + expect(res.$status).toBe(404); + expect(res.error?.code).toBe("NOT_FOUND"); + }); + + it("should return 404 when deleting a non-existent website", async () => { + const res = await apiFetch("/api/websites/non-existent-id", { + method: "DELETE", + sessionId, + }); + + expect(res.$status).toBe(404); + expect(res.error?.code).toBe("NOT_FOUND"); + }); + }); + + // --- Feature 3: Domains CRUD --- + describe("Domains Boundary and Errors", () => { + const sessionId = "tier2_domains"; + + it("should fail validation when creating a domain with invalid format", async () => { + clearCookieJar(sessionId); + await apiFetch("/api/auth/login", { + method: "POST", + sessionId, + body: JSON.stringify({ email: "owner@example.com", password: "correct-password" }), + }); + + const res = await apiFetch("/api/domains", { + method: "POST", + sessionId, + body: JSON.stringify({ + name: "nodotdomain", + type: "primary", + }), + }); + + expect(res.$status).toBe(400); + expect(res.error?.code).toBe("VALIDATION_ERROR"); + expect(res.error?.fields?.name).toBeDefined(); + }); + + it("should fail with 409 conflict when domain already exists", async () => { + // Create first + await apiFetch("/api/domains", { + method: "POST", + sessionId, + body: JSON.stringify({ name: "conflict.com", type: "primary" }), + }); + + // Duplicate + const res = await apiFetch("/api/domains", { + method: "POST", + sessionId, + body: JSON.stringify({ name: "conflict.com", type: "alias" }), + }); + + expect(res.$status).toBe(409); + expect(res.error?.code).toBe("VALIDATION_ERROR"); + expect(res.error?.fields?.name).toBeDefined(); + }); + + it("should fail validation when creating a DNS record with TTL below minimum", async () => { + const res = await apiFetch("/api/domains/dom-1/dns", { + method: "POST", + sessionId, + body: JSON.stringify({ + name: "mail", + ttl: 30, // below 60 + type: "A", + record: "1.1.1.1", + }), + }); + + expect(res.$status).toBe(400); + expect(res.error?.code).toBe("VALIDATION_ERROR"); + expect(res.error?.fields?.ttl).toBeDefined(); + }); + + it("should fail validation when creating a DNS record with invalid type", async () => { + const res = await apiFetch("/api/domains/dom-1/dns", { + method: "POST", + sessionId, + body: JSON.stringify({ + name: "mail", + ttl: 3600, + type: "INVALID_TYPE", + record: "1.1.1.1", + }), + }); + + expect(res.$status).toBe(400); + expect(res.error?.code).toBe("VALIDATION_ERROR"); + expect(res.error?.fields?.type).toBeDefined(); + }); + + it("should return 409 CNAME conflict if a CNAME record coexists with other records of same name", async () => { + // Create CNAME record 'web' + await apiFetch("/api/domains/dom-1/dns", { + method: "POST", + sessionId, + body: JSON.stringify({ + name: "web", + ttl: 3600, + type: "A", + record: "192.0.2.1", + }), + }); + + // Try creating a CNAME record with same name 'web' + const res = await apiFetch("/api/domains/dom-1/dns", { + method: "POST", + sessionId, + body: JSON.stringify({ + name: "web", + ttl: 3600, + type: "CNAME", + record: "example.com", + }), + }); + + expect(res.$status).toBe(409); + expect(res.error?.code).toBe("VALIDATION_ERROR"); + expect(res.error?.fields?.name).toBeDefined(); + }); + }); + + // --- Feature 4: Envelope & HTTP Specifications --- + describe("Envelope & HTTP Errors", () => { + const sessionId = "tier2_envelope"; + + it("should return 405 Method Not Allowed when requesting with invalid HTTP method", async () => { + const res = await apiFetch("/api/auth/login", { + method: "GET", + sessionId, + }); + + expect(res.$status).toBe(405); + expect(res.error?.code).toBe("METHOD_NOT_ALLOWED"); + }); + + it("should return 404 Not Found when requesting non-existent path", async () => { + const res = await apiFetch("/api/invalid-route-path", { + method: "GET", + sessionId, + }); + + expect(res.$status).toBe(404); + expect(res.error?.code).toBe("NOT_FOUND"); + }); + + it("should return 429 Too Many Requests when rate limit is exceeded", async () => { + // We send a header or request a endpoint that triggers rate limit + const res = await apiFetch("/api/rate-limited", { + method: "GET", + sessionId, + }); + + expect(res.$status).toBe(429); + expect(res.error?.code).toBe("RATE_LIMIT_ERROR"); + }); + + it("should handle SQL injection attempts safely without crashing", async () => { + clearCookieJar(sessionId); + await apiFetch("/api/auth/login", { + method: "POST", + sessionId, + body: JSON.stringify({ email: "owner@example.com", password: "correct-password" }), + }); + + // Send SQL injection payload + const sqliSearch = "' OR '1'='1' --"; + const res = await apiFetch(`/api/websites?search=${encodeURIComponent(sqliSearch)}`, { + method: "GET", + sessionId, + }); + + expect(res.$status).toBe(200); + expect(res.error).toBeNull(); + expect(res.data).toBeInstanceOf(Array); + expect(res.data?.length).toBe(0); // Safely treated as literal query, returning no matches + }); + + it("should handle simulated 500 internal server errors gracefully", async () => { + const res = await apiFetch("/api/simulate-500", { + method: "GET", + sessionId, + }); + + expect(res.$status).toBe(500); + expect(res.error?.code).toBe("NETWORK_ERROR"); + expect(res.data).toBeNull(); + }); + }); +});