diff --git a/backend/tests/e2e/tier1.spec.ts b/backend/tests/e2e/tier1.spec.ts new file mode 100644 index 0000000..d8047a6 --- /dev/null +++ b/backend/tests/e2e/tier1.spec.ts @@ -0,0 +1,464 @@ +import { describe, expect, it } from "vitest"; +import { apiFetch, clearCookieJar } from "./setup.js"; +import type { User, Website, Domain, DnsRecord } from "../../../src/api/contracts/models.js"; + +describe("Tier 1 E2E - Happy Paths", () => { + // --- Feature 1: Authentication --- + describe("Auth Happy Paths", () => { + it("should successfully log in standard credentials", async () => { + const sessionId = "tier1_auth_1"; + clearCookieJar(sessionId); + + const loginRes = await apiFetch<{ user: User; challenge: string | null; sessionExpiresAt: string }>( + "/api/auth/login", + { + method: "POST", + sessionId, + body: JSON.stringify({ + email: "owner@example.com", + password: "correct-password", + }), + } + ); + + expect(loginRes.$status).toBe(200); + expect(loginRes.error).toBeNull(); + expect(loginRes.data).toBeDefined(); + expect(loginRes.data?.user.email).toBe("owner@example.com"); + expect(loginRes.data?.user.role).toBe("owner"); + expect(loginRes.data?.challenge).toBeNull(); + expect(loginRes.data?.sessionExpiresAt).toBeDefined(); + }); + + it("should trigger a 2FA challenge when logging in with 2FA email", async () => { + const sessionId = "tier1_auth_2"; + clearCookieJar(sessionId); + + const loginRes = await apiFetch<{ user: User | null; challenge: string | null }>( + "/api/auth/login", + { + method: "POST", + sessionId, + body: JSON.stringify({ + email: "owner-2fa@example.com", + password: "2fa-password", + }), + } + ); + + expect(loginRes.$status).toBe(200); + expect(loginRes.error).toBeNull(); + expect(loginRes.data?.challenge).toBe("totp"); + expect(loginRes.data?.user).toBeNull(); + }); + + it("should complete login when providing valid 2FA code", async () => { + const sessionId = "tier1_auth_3"; + clearCookieJar(sessionId); + + const loginRes = await apiFetch<{ user: User; challenge: string | null }>( + "/api/auth/login", + { + method: "POST", + sessionId, + body: JSON.stringify({ + email: "owner-2fa@example.com", + password: "2fa-password", + code: "123456", + }), + } + ); + + expect(loginRes.$status).toBe(200); + expect(loginRes.error).toBeNull(); + expect(loginRes.data?.user).toBeDefined(); + expect(loginRes.data?.user.twoFactorEnabled).toBe(true); + }); + + it("should successfully fetch profile details via /api/auth/me", async () => { + const sessionId = "tier1_auth_4"; + clearCookieJar(sessionId); + + // Login first + await apiFetch("/api/auth/login", { + method: "POST", + sessionId, + body: JSON.stringify({ email: "owner@example.com", password: "correct-password" }), + }); + + // Get profile + const meRes = await apiFetch("/api/auth/me", { + method: "GET", + sessionId, + }); + + expect(meRes.$status).toBe(200); + expect(meRes.error).toBeNull(); + expect(meRes.data?.email).toBe("owner@example.com"); + expect(meRes.data?.role).toBe("owner"); + }); + + it("should successfully log out and invalidate session cookie", async () => { + const sessionId = "tier1_auth_5"; + clearCookieJar(sessionId); + + // Login + await apiFetch("/api/auth/login", { + method: "POST", + sessionId, + body: JSON.stringify({ email: "owner@example.com", password: "correct-password" }), + }); + + // Logout + const logoutRes = await apiFetch<{ ok: boolean }>("/api/auth/logout", { + method: "POST", + sessionId, + }); + + expect(logoutRes.$status).toBe(200); + expect(logoutRes.error).toBeNull(); + expect(logoutRes.data?.ok).toBe(true); + + // Subsequent /api/auth/me should fail + const meRes = await apiFetch("/api/auth/me", { + method: "GET", + sessionId, + }); + expect(meRes.$status).toBe(401); + expect(meRes.error?.code).toBe("PERMISSION_DENIED"); + }); + }); + + // --- Feature 2: Websites CRUD --- + describe("Websites Happy Paths", () => { + const sessionId = "tier1_websites"; + + it("should successfully list websites", async () => { + clearCookieJar(sessionId); + await apiFetch("/api/auth/login", { + method: "POST", + sessionId, + body: JSON.stringify({ email: "owner@example.com", password: "correct-password" }), + }); + + const listRes = await apiFetch("/api/websites", { + method: "GET", + sessionId, + }); + + expect(listRes.$status).toBe(200); + expect(listRes.error).toBeNull(); + expect(listRes.data).toBeInstanceOf(Array); + expect(listRes.data!.length).toBeGreaterThan(0); + }); + + it("should successfully create a new website", async () => { + const createRes = await apiFetch("/api/websites", { + method: "POST", + sessionId, + body: JSON.stringify({ + name: "Test Happy Site", + domain: "happy.com", + runtime: "Node.js", + environment: "production", + }), + }); + + expect(createRes.$status).toBe(201); + expect(createRes.error).toBeNull(); + expect(createRes.data?.id).toBeDefined(); + expect(createRes.data?.name).toBe("Test Happy Site"); + expect(createRes.data?.domain).toBe("happy.com"); + }); + + it("should successfully retrieve website details by ID", async () => { + // List to get the ID we just created + const listRes = await apiFetch("/api/websites", { + method: "GET", + sessionId, + }); + const created = listRes.data!.find((w) => w.name === "Test Happy Site"); + expect(created).toBeDefined(); + + const getRes = await apiFetch(`/api/websites/${created!.id}`, { + method: "GET", + sessionId, + }); + + expect(getRes.$status).toBe(200); + expect(getRes.error).toBeNull(); + expect(getRes.data?.name).toBe("Test Happy Site"); + }); + + it("should successfully update website settings", async () => { + const listRes = await apiFetch("/api/websites", { + method: "GET", + sessionId, + }); + const created = listRes.data!.find((w) => w.name === "Test Happy Site"); + + const updateRes = await apiFetch(`/api/websites/${created!.id}`, { + method: "PUT", + sessionId, + body: JSON.stringify({ + runtime: "Docker", + environment: "staging", + }), + }); + + expect(updateRes.$status).toBe(200); + expect(updateRes.error).toBeNull(); + expect(updateRes.data?.runtime).toBe("Docker"); + expect(updateRes.data?.environment).toBe("staging"); + }); + + it("should successfully delete a website", async () => { + const listRes = await apiFetch("/api/websites", { + method: "GET", + sessionId, + }); + const created = listRes.data!.find((w) => w.name === "Test Happy Site"); + + const deleteRes = await apiFetch<{ deleted: string }>(`/api/websites/${created!.id}`, { + method: "DELETE", + sessionId, + }); + + expect(deleteRes.$status).toBe(200); + expect(deleteRes.error).toBeNull(); + expect(deleteRes.data?.deleted).toBe(created!.id); + + // Confirm deletion + const confirmRes = await apiFetch(`/api/websites/${created!.id}`, { + method: "GET", + sessionId, + }); + expect(confirmRes.$status).toBe(404); + }); + }); + + // --- Feature 3: Domains CRUD --- + describe("Domains Happy Paths", () => { + const sessionId = "tier1_domains"; + + it("should successfully list domains", async () => { + clearCookieJar(sessionId); + await apiFetch("/api/auth/login", { + method: "POST", + sessionId, + body: JSON.stringify({ email: "owner@example.com", password: "correct-password" }), + }); + + const listRes = await apiFetch("/api/domains", { + method: "GET", + sessionId, + }); + + expect(listRes.$status).toBe(200); + expect(listRes.error).toBeNull(); + expect(listRes.data).toBeInstanceOf(Array); + expect(listRes.data!.length).toBeGreaterThan(0); + }); + + it("should successfully create a new domain", async () => { + const createRes = await apiFetch("/api/domains", { + method: "POST", + sessionId, + body: JSON.stringify({ + name: "happydomain.com", + type: "primary", + documentRoot: "/home/coaxys/happydomain.com", + }), + }); + + expect(createRes.$status).toBe(201); + expect(createRes.error).toBeNull(); + expect(createRes.data?.id).toBeDefined(); + expect(createRes.data?.name).toBe("happydomain.com"); + }); + + it("should successfully update domain properties", async () => { + const listRes = await apiFetch("/api/domains", { + method: "GET", + sessionId, + }); + const created = listRes.data!.find((d) => d.name === "happydomain.com"); + + const updateRes = await apiFetch(`/api/domains/${created!.id}`, { + method: "PUT", + sessionId, + body: JSON.stringify({ + documentRoot: "/home/coaxys/happydomain.com/public", + dnssec: true, + }), + }); + + expect(updateRes.$status).toBe(200); + expect(updateRes.error).toBeNull(); + expect(updateRes.data?.documentRoot).toBe("/home/coaxys/happydomain.com/public"); + expect(updateRes.data?.dnssec).toBe(true); + }); + + it("should successfully list DNS records for a domain", async () => { + const listRes = await apiFetch("/api/domains", { + method: "GET", + sessionId, + }); + const domain = listRes.data!.find((d) => d.name === "happydomain.com"); + + const dnsRes = await apiFetch(`/api/domains/${domain!.id}/dns`, { + method: "GET", + sessionId, + }); + + expect(dnsRes.$status).toBe(200); + expect(dnsRes.error).toBeNull(); + expect(dnsRes.data).toBeInstanceOf(Array); + }); + + it("should successfully create, update, and delete a DNS record on a domain", async () => { + const listRes = await apiFetch("/api/domains", { + method: "GET", + sessionId, + }); + const domain = listRes.data!.find((d) => d.name === "happydomain.com"); + + // 1. Create + const createDns = await apiFetch(`/api/domains/${domain!.id}/dns`, { + method: "POST", + sessionId, + body: JSON.stringify({ + name: "web", + ttl: 3600, + type: "A", + record: "192.0.2.55", + }), + }); + expect(createDns.$status).toBe(201); + expect(createDns.data?.id).toBeDefined(); + expect(createDns.data?.record).toBe("192.0.2.55"); + + // 2. Update + const updateDns = await apiFetch( + `/api/domains/${domain!.id}/dns/${createDns.data!.id}`, + { + method: "PUT", + sessionId, + body: JSON.stringify({ + record: "192.0.2.56", + ttl: 7200, + }), + } + ); + expect(updateDns.$status).toBe(200); + expect(updateDns.data?.record).toBe("192.0.2.56"); + expect(updateDns.data?.ttl).toBe(7200); + + // 3. Delete + const deleteDns = await apiFetch<{ deleted: string }>( + `/api/domains/${domain!.id}/dns/${createDns.data!.id}`, + { + method: "DELETE", + sessionId, + } + ); + expect(deleteDns.$status).toBe(200); + expect(deleteDns.data?.deleted).toBe(createDns.data!.id); + }); + + it("should successfully delete a domain", async () => { + const listRes = await apiFetch("/api/domains", { + method: "GET", + sessionId, + }); + const created = listRes.data!.find((d) => d.name === "happydomain.com"); + + const deleteRes = await apiFetch<{ deleted: string }>(`/api/domains/${created!.id}`, { + method: "DELETE", + sessionId, + }); + + expect(deleteRes.$status).toBe(200); + expect(deleteRes.error).toBeNull(); + + // Confirm deletion + const confirmRes = await apiFetch(`/api/domains/${created!.id}`, { + method: "GET", + sessionId, + }); + expect(confirmRes.$status).toBe(404); + }); + }); + + // --- Feature 4: Envelope & Metadata Validation --- + describe("Envelope & Metadata Happy Paths", () => { + const sessionId = "tier1_envelope"; + + it("should conform to strict envelope formatting on success", 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/auth/me", { method: "GET", sessionId }); + + // Envelope layout validation + expect(res).toBeDefined(); + expect(res).toHaveProperty("data"); + expect(res).toHaveProperty("meta"); + expect(res).toHaveProperty("error"); + expect(res.error).toBeNull(); + expect(res.data).not.toBeNull(); + expect(typeof res.meta.requestId).toBe("string"); + expect(res.meta.requestId.length).toBeGreaterThan(0); + }); + + it("should conform to strict envelope formatting on failure", async () => { + // Bad credentials login + const res = await apiFetch("/api/auth/login", { + method: "POST", + sessionId, + body: JSON.stringify({ email: "owner@example.com", password: "wrong-password" }), + }); + + // Envelope layout validation + expect(res).toBeDefined(); + expect(res).toHaveProperty("data"); + expect(res).toHaveProperty("meta"); + expect(res).toHaveProperty("error"); + expect(res.data).toBeNull(); + expect(res.error).not.toBeNull(); + expect(typeof res.error!.code).toBe("string"); + expect(typeof res.error!.message).toBe("string"); + expect(typeof res.meta.requestId).toBe("string"); + }); + + it("should provide pagination metadata on list endpoints", async () => { + const res = await apiFetch("/api/websites?page=1&pageSize=1", { + method: "GET", + sessionId, + }); + + expect(res.meta.pagination).toBeDefined(); + expect(res.meta.pagination?.page).toBe(1); + expect(res.meta.pagination?.pageSize).toBe(1); + expect(typeof res.meta.pagination?.total).toBe("number"); + expect(typeof res.meta.pagination?.pageCount).toBe("number"); + }); + + it("should include rate limit metadata when appropriate", async () => { + // Perform request to rate limited simulator or trigger rate limit headers + const res = await apiFetch("/api/websites", { + method: "GET", + sessionId, + headers: { "x-test-rate-limit": "true" }, + }); + + expect(res.$status).toBe(429); + expect(res.error?.code).toBe("RATE_LIMIT_ERROR"); + expect((res.meta as any).rateLimit).toBeDefined(); + }); + }); +});