diff --git a/.github/workflows/quality-checks.yml b/.github/workflows/quality-checks.yml index ec411db..4f72d06 100644 --- a/.github/workflows/quality-checks.yml +++ b/.github/workflows/quality-checks.yml @@ -12,6 +12,12 @@ jobs: lint-test-build: name: Lint, Test and Build runs-on: ubuntu-latest + env: + BETTER_AUTH_SECRET: test-secret + NEXT_PUBLIC_APP_URL: http://localhost:3000 + BETTER_AUTH_URL: http://localhost:3000 + NEXT_PUBLIC_CONVEX_URL: http://localhost:3210 + DATABASE_URL: file:./prisma/db.dev.sqlite steps: - name: Checkout uses: actions/checkout@v4 diff --git a/src/app/api/machines/heartbeat/route.test.ts b/src/app/api/machines/heartbeat/route.test.ts new file mode 100644 index 0000000..856c07b --- /dev/null +++ b/src/app/api/machines/heartbeat/route.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it, beforeEach, vi } from "vitest" + +import { api } from "@/convex/_generated/api" + +const mutationMock = vi.fn() + +vi.mock("@/server/convex-client", () => ({ + createConvexClient: () => ({ + mutation: mutationMock, + }), + ConvexConfigurationError: class extends Error {}, +})) + +describe("POST /api/machines/heartbeat", () => { + beforeEach(() => { + mutationMock.mockReset() + }) + + it("accepts a valid payload and forwards it to Convex", async () => { + const payload = { + machineToken: "token-123", + status: "online", + metrics: { cpu: 42 }, + } + mutationMock.mockResolvedValue({ ok: true }) + + const { POST } = await import("./route") + const response = await POST( + new Request("http://localhost/api/machines/heartbeat", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }) + ) + + expect(response.status).toBe(200) + const body = await response.json() + expect(body).toEqual({ ok: true }) + expect(mutationMock).toHaveBeenCalledWith(api.machines.heartbeat, payload) + }) + + it("rejects an invalid payload", async () => { + const { POST } = await import("./route") + const response = await POST( + new Request("http://localhost/api/machines/heartbeat", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({}), + }) + ) + + expect(response.status).toBe(400) + const body = await response.json() + expect(body).toHaveProperty("error", "Payload inválido") + expect(mutationMock).not.toHaveBeenCalled() + }) +}) + diff --git a/src/app/api/machines/inventory/route.test.ts b/src/app/api/machines/inventory/route.test.ts new file mode 100644 index 0000000..69cfe40 --- /dev/null +++ b/src/app/api/machines/inventory/route.test.ts @@ -0,0 +1,99 @@ +import { describe, expect, it, beforeEach, vi } from "vitest" + +import { api } from "@/convex/_generated/api" + +const mutationMock = vi.fn() + +vi.mock("@/server/convex-client", () => ({ + createConvexClient: () => ({ + mutation: mutationMock, + }), + ConvexConfigurationError: class extends Error {}, +})) + +describe("POST /api/machines/inventory", () => { + beforeEach(() => { + mutationMock.mockReset() + }) + + it("accepts the token mode payload", async () => { + const payload = { + machineToken: "token-123", + hostname: "machine", + metrics: { cpu: 50 }, + } + mutationMock.mockResolvedValue({ ok: true, machineId: "machine-123" }) + + const { POST } = await import("./route") + const response = await POST( + new Request("http://localhost/api/machines/inventory", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }) + ) + + expect(response.status).toBe(200) + expect(mutationMock).toHaveBeenCalledWith( + api.machines.heartbeat, + expect.objectContaining({ + machineToken: "token-123", + hostname: "machine", + metrics: { cpu: 50 }, + }) + ) + const body = await response.json() + expect(body).toEqual({ ok: true, machineId: "machine-123" }) + }) + + it("accepts the provisioning mode payload", async () => { + const payload = { + provisioningCode: "a".repeat(32), + hostname: "machine", + os: { name: "Linux" }, + macAddresses: ["00:11:22:33"], + serialNumbers: [], + } + mutationMock.mockResolvedValue({ ok: true, status: "updated", machineId: "machine-987" }) + + const { POST } = await import("./route") + const response = await POST( + new Request("http://localhost/api/machines/inventory", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }) + ) + + expect(response.status).toBe(200) + expect(mutationMock).toHaveBeenCalledWith( + api.machines.upsertInventory, + expect.objectContaining({ + provisioningCode: "a".repeat(32), + hostname: "machine", + os: { name: "Linux" }, + macAddresses: ["00:11:22:33"], + serialNumbers: [], + registeredBy: "agent:inventory", + }) + ) + const body = await response.json() + expect(body).toEqual({ ok: true, machineId: "machine-987", status: "updated" }) + }) + + it("rejects unknown payloads", async () => { + const { POST } = await import("./route") + const response = await POST( + new Request("http://localhost/api/machines/inventory", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ hostname: "machine" }), + }) + ) + + expect(response.status).toBe(400) + const body = await response.json() + expect(body).toEqual({ error: "Formato de payload não suportado" }) + expect(mutationMock).not.toHaveBeenCalled() + }) +}) diff --git a/src/app/api/machines/session/route.test.ts b/src/app/api/machines/session/route.test.ts new file mode 100644 index 0000000..91e2ac4 --- /dev/null +++ b/src/app/api/machines/session/route.test.ts @@ -0,0 +1,107 @@ +import { describe, expect, it, beforeEach, vi } from "vitest" +import { NextRequest } from "next/server" + +import { MACHINE_CTX_COOKIE, serializeMachineCookie } from "@/server/machines/context" + +const mockAssertSession = vi.fn() +vi.mock("@/lib/auth-server", () => ({ + assertAuthenticatedSession: mockAssertSession, +})) + +const mockCreateConvexClient = vi.fn() +vi.mock("@/server/convex-client", () => { + class ConvexConfigurationError extends Error {} + return { + createConvexClient: mockCreateConvexClient, + ConvexConfigurationError, + } +}) + +const mockCookieStore = { + get: vi.fn<() => unknown, []>(), +} + +vi.mock("next/headers", () => ({ + cookies: vi.fn(async () => mockCookieStore), +})) + +describe("GET /api/machines/session", () => { + beforeEach(() => { + vi.clearAllMocks() + mockCookieStore.get.mockReturnValue(undefined) + mockCreateConvexClient.mockReturnValue({ + query: vi.fn(), + mutation: vi.fn(), + }) + }) + + it("returns 403 when the current session is not a machine", async () => { + mockAssertSession.mockResolvedValue({ user: { role: "admin" } }) + + const { GET } = await import("./route") + const response = await GET(new NextRequest("https://example.com/api/machines/session")) + + expect(response.status).toBe(403) + const payload = await response.json() + expect(payload).toEqual({ error: "Sessão de máquina não encontrada." }) + expect(mockCreateConvexClient).not.toHaveBeenCalled() + }) + + it("returns machine context and sets cookie when lookup succeeds", async () => { + mockAssertSession.mockResolvedValue({ + user: { role: "machine", email: "device@example.com" }, + }) + mockCookieStore.get.mockReturnValueOnce(undefined) + + const sampleContext = { + id: "machine-123", + tenantId: "tenant-1", + companyId: "company-1", + companySlug: "acme", + persona: "manager", + assignedUserId: "user-789", + assignedUserEmail: "manager@acme.com", + assignedUserName: "Manager Doe", + assignedUserRole: "MANAGER", + metadata: null, + authEmail: "device@example.com", + } + + let call = 0 + const queryMock = vi.fn(async (_route: unknown, args: unknown) => { + call += 1 + if (call === 1) { + expect(args).toEqual({ authEmail: "device@example.com" }) + return { id: "machine-123" } + } + if (call === 2) { + expect(args).toEqual({ machineId: "machine-123" }) + return sampleContext + } + return null + }) + + mockCreateConvexClient.mockReturnValue({ + query: queryMock, + mutation: vi.fn(), + }) + + const { GET } = await import("./route") + const response = await GET(new NextRequest("https://example.com/api/machines/session")) + + expect(response.status).toBe(200) + const payload = await response.json() + expect(payload.machine.id).toBe(sampleContext.id) + expect(payload.machine.assignedUserEmail).toBe(sampleContext.assignedUserEmail) + + const cookie = response.cookies.get(MACHINE_CTX_COOKIE) + expect(cookie?.value).toBe(serializeMachineCookie({ + machineId: sampleContext.id, + persona: sampleContext.persona, + assignedUserId: sampleContext.assignedUserId, + assignedUserEmail: sampleContext.assignedUserEmail, + assignedUserName: sampleContext.assignedUserName, + assignedUserRole: sampleContext.assignedUserRole, + })) + }) +})