diff --git a/Dockerfile b/Dockerfile index 41a6c57..4fe4ed6 100644 --- a/Dockerfile +++ b/Dockerfile @@ -13,6 +13,7 @@ COPY . . ARG PUBLIC_API_URL=https://opt-api.osdci.net ENV PUBLIC_API_URL=$PUBLIC_API_URL +RUN bun run prepare RUN bun run build:server # Bundle the server into a single file with all dependencies diff --git a/e2e/demo.test.ts b/e2e/demo.test.ts index 9985ce1..152a1e9 100644 --- a/e2e/demo.test.ts +++ b/e2e/demo.test.ts @@ -1,6 +1,7 @@ -import { expect, test } from '@playwright/test'; +import { expect, test } from "@playwright/test"; -test('home page has expected h1', async ({ page }) => { - await page.goto('/'); - await expect(page.locator('h1')).toBeVisible(); +test("app root renders visible content", async ({ page }) => { + await page.goto("/"); + await expect(page.locator("body")).toBeVisible(); + await expect(page.locator("body")).not.toHaveText(/^\s*$/); }); diff --git a/playwright.config.ts b/playwright.config.ts index f6c81af..d41b162 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -1,9 +1,9 @@ -import { defineConfig } from '@playwright/test'; +import { defineConfig } from "@playwright/test"; export default defineConfig({ - webServer: { - command: 'npm run build && npm run preview', - port: 4173 - }, - testDir: 'e2e' + webServer: { + command: "PORT=4173 ORIGIN=http://localhost:4173 node build/index.js", + port: 4173, + }, + testDir: "e2e", }); diff --git a/src/lib/optima-api/modules/api-modules.spec.ts b/src/lib/optima-api/modules/api-modules.spec.ts new file mode 100644 index 0000000..7d1d2f5 --- /dev/null +++ b/src/lib/optima-api/modules/api-modules.spec.ts @@ -0,0 +1,301 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const { mockApi } = vi.hoisted(() => ({ + mockApi: { + get: vi.fn(), + post: vi.fn(), + patch: vi.fn(), + put: vi.fn(), + delete: vi.fn(), + }, +})); + +vi.mock("../axios", () => ({ + default: mockApi, + api: mockApi, +})); + +import { company } from "./companies"; +import { credential } from "./credentials"; +import { credentialType } from "./credentialTypes"; +import { permission } from "./permissions"; +import { procurement } from "./procurement"; +import { role } from "./roles"; +import { sales } from "./sales"; +import { unifi } from "./unifi"; +import { users } from "./users"; + +describe("optima api modules", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("company.fetchMany sends search params", async () => { + mockApi.get.mockResolvedValueOnce({ data: { data: [] } }); + + await company.fetchMany("token", 2, "acme", 50); + + expect(mockApi.get).toHaveBeenCalledWith("/v1/company/companies", { + params: { page: 2, rpp: 50, search: "acme" }, + headers: { Authorization: "Bearer token" }, + }); + }); + + it("company.count returns count", async () => { + mockApi.get.mockResolvedValueOnce({ data: { data: { count: 17 } } }); + + const count = await company.count("token"); + + expect(count).toBe(17); + }); + + it("credential.fetch and delete call expected routes", async () => { + mockApi.get.mockResolvedValueOnce({ data: { data: { id: "cred-1" } } }); + mockApi.delete.mockResolvedValueOnce({ data: { ok: true } }); + + await credential.fetch("token", "cred-1"); + await credential.delete("token", "cred-1"); + + expect(mockApi.get).toHaveBeenCalledWith( + "/v1/credential/credentials/cred-1", + { + headers: { Authorization: "Bearer token" }, + }, + ); + expect(mockApi.delete).toHaveBeenCalledWith( + "/v1/credential/credentials/cred-1", + { + headers: { Authorization: "Bearer token" }, + }, + ); + }); + + it("credential.create posts payload", async () => { + const payload = { + name: "VPN", + notes: "notes", + typeId: "type-1", + companyId: "company-1", + fields: [], + }; + mockApi.post.mockResolvedValueOnce({ data: { data: payload } }); + + await credential.create("token", payload as any); + + expect(mockApi.post).toHaveBeenCalledWith( + "/v1/credential/credentials", + payload, + { headers: { Authorization: "Bearer token" } }, + ); + }); + + it("credential.updateFields wraps fields payload", async () => { + const fields = [{ id: "f1", name: "Username" }]; + mockApi.put.mockResolvedValueOnce({ data: { ok: true } }); + + await credential.updateFields("token", "cred-1", fields as any); + + expect(mockApi.put).toHaveBeenCalledWith( + "/v1/credential/credentials/cred-1/fields", + { fields }, + { headers: { Authorization: "Bearer token" } }, + ); + }); + + it("credential sub-credential methods call expected endpoints", async () => { + mockApi.get.mockResolvedValueOnce({ data: { data: [] } }); + mockApi.post.mockResolvedValueOnce({ data: { ok: true } }); + mockApi.delete.mockResolvedValueOnce({ data: { ok: true } }); + + await credential.fetchSubCredentials("token", "cred-1"); + await credential.addSubCredential("token", "cred-1", { + fieldId: "fid", + name: "Sub", + fields: [{ fieldId: "f2", value: "v" }], + }); + await credential.removeSubCredential("token", "cred-1", "sub-1"); + + expect(mockApi.get).toHaveBeenCalledWith( + "/v1/credential/credentials/cred-1/sub-credentials", + { headers: { Authorization: "Bearer token" } }, + ); + expect(mockApi.post).toHaveBeenCalledWith( + "/v1/credential/credentials/cred-1/sub-credentials", + { fieldId: "fid", name: "Sub", fields: [{ fieldId: "f2", value: "v" }] }, + { headers: { Authorization: "Bearer token" } }, + ); + expect(mockApi.delete).toHaveBeenCalledWith( + "/v1/credential/credentials/cred-1/sub-credentials/sub-1", + { headers: { Authorization: "Bearer token" } }, + ); + }); + + it("credentialType create and delete use identifier path", async () => { + mockApi.post.mockResolvedValueOnce({ data: { ok: true } }); + mockApi.delete.mockResolvedValueOnce({ data: { ok: true } }); + + await credentialType.create("token", { + name: "Router", + permissionScope: "router", + fields: [], + } as any); + await credentialType.delete("token", "ctype-1"); + + expect(mockApi.post).toHaveBeenCalledWith( + "/v1/credential-type", + { name: "Router", permissionScope: "router", fields: [] }, + { headers: { Authorization: "Bearer token" } }, + ); + expect(mockApi.delete).toHaveBeenCalledWith("/v1/credential-type/ctype-1", { + headers: { Authorization: "Bearer token" }, + }); + }); + + it("permission module endpoints are correct", async () => { + mockApi.get.mockResolvedValue({ data: { data: [] } }); + + await permission.fetchCategorized("token"); + await permission.fetchFlat("token"); + await permission.fetchByCategory("token", "company"); + + expect(mockApi.get).toHaveBeenNthCalledWith(1, "/v1/permissions", { + headers: { Authorization: "Bearer token" }, + }); + expect(mockApi.get).toHaveBeenNthCalledWith(2, "/v1/permissions/nodes", { + headers: { Authorization: "Bearer token" }, + }); + expect(mockApi.get).toHaveBeenNthCalledWith(3, "/v1/permissions/company", { + headers: { Authorization: "Bearer token" }, + }); + }); + + it("procurement methods build params and count correctly", async () => { + mockApi.get + .mockResolvedValueOnce({ data: { data: [] } }) + .mockResolvedValueOnce({ data: { data: { count: 4 } } }); + + await procurement.fetchMany("token", 3, "switch", 10, true); + const count = await procurement.count("token", true); + + expect(mockApi.get).toHaveBeenNthCalledWith(1, "/v1/procurement/items", { + params: { page: 3, rpp: 10, search: "switch", includeInactive: true }, + headers: { Authorization: "Bearer token" }, + }); + expect(mockApi.get).toHaveBeenNthCalledWith(2, "/v1/procurement/count", { + params: { activeOnly: "true" }, + headers: { Authorization: "Bearer token" }, + }); + expect(count).toBe(4); + }); + + it("procurement link and unlink post target id", async () => { + mockApi.post.mockResolvedValue({ data: { ok: true } }); + + await procurement.linkItem("token", "item-a", "item-b"); + await procurement.unlinkItem("token", "item-a", "item-b"); + + expect(mockApi.post).toHaveBeenNthCalledWith( + 1, + "/v1/procurement/items/item-a/link", + { targetId: "item-b" }, + { headers: { Authorization: "Bearer token" } }, + ); + expect(mockApi.post).toHaveBeenNthCalledWith( + 2, + "/v1/procurement/items/item-a/unlink", + { targetId: "item-b" }, + { headers: { Authorization: "Bearer token" } }, + ); + }); + + it("role add and remove permissions include payload", async () => { + mockApi.post.mockResolvedValueOnce({ data: { ok: true } }); + mockApi.delete.mockResolvedValueOnce({ data: { ok: true } }); + + await role.addPermissions("token", "role-1", ["a", "b"]); + await role.removePermissions("token", "role-1", ["a"]); + + expect(mockApi.post).toHaveBeenCalledWith( + "/v1/role/role-1/permissions", + { permissions: ["a", "b"] }, + { headers: { Authorization: "Bearer token" } }, + ); + expect(mockApi.delete).toHaveBeenCalledWith("/v1/role/role-1/permissions", { + headers: { Authorization: "Bearer token" }, + data: { permissions: ["a"] }, + }); + }); + + it("sales.fetchMany includes includeClosed and search params", async () => { + mockApi.get.mockResolvedValueOnce({ data: { data: [] } }); + + await sales.fetchMany("token", 1, "fiber", 25, true); + + expect(mockApi.get).toHaveBeenCalledWith("/v1/sales/opportunities", { + params: { page: 1, rpp: 25, search: "fiber", includeClosed: true }, + headers: { Authorization: "Bearer token" }, + }); + }); + + it("users module uses expected endpoints", async () => { + mockApi.get.mockResolvedValue({ data: { data: [] } }); + mockApi.post.mockResolvedValueOnce({ data: { data: { results: [] } } }); + mockApi.patch.mockResolvedValueOnce({ data: { data: {} } }); + mockApi.delete.mockResolvedValueOnce({ data: { data: {} } }); + + await users.fetchAll("token"); + await users.fetch("token", "user-1"); + await users.fetchRoles("token", "user-1"); + await users.checkPermissions("token", "user-1", ["x"]); + await users.update("token", "user-1", { name: "New Name" }); + await users.delete("token", "user-1"); + + expect(mockApi.get).toHaveBeenCalledWith("/v1/user/users", { + headers: { Authorization: "Bearer token" }, + }); + expect(mockApi.get).toHaveBeenCalledWith("/v1/user/users/user-1", { + headers: { Authorization: "Bearer token" }, + }); + expect(mockApi.get).toHaveBeenCalledWith("/v1/user/users/user-1/roles", { + headers: { Authorization: "Bearer token" }, + }); + expect(mockApi.post).toHaveBeenCalledWith( + "/v1/user/users/user-1/check-permission", + { permissions: ["x"] }, + { headers: { Authorization: "Bearer token" } }, + ); + }); + + it("unifi module hits expected endpoints for site and wifi actions", async () => { + mockApi.get.mockResolvedValue({ data: { data: [] } }); + mockApi.post.mockResolvedValue({ data: { ok: true } }); + mockApi.patch.mockResolvedValue({ data: { ok: true } }); + + await unifi.fetchSites("token"); + await unifi.syncSites("token"); + await unifi.createSite("token", "HQ"); + await unifi.linkSite("token", "site-1", "company-1"); + await unifi.unlinkSite("token", "site-1"); + await unifi.fetchSiteWifi("token", "site-1"); + await unifi.updateWifi("token", "site-1", "wlan-1", { name: "New" }); + await unifi.fetchPPSKs("token", "site-1", "wlan-1"); + await unifi.createPPSK("token", "site-1", "wlan-1", { + key: "abc", + name: "Staff", + }); + + expect(mockApi.get).toHaveBeenCalledWith("/v1/unifi/sites", { + headers: { Authorization: "Bearer token" }, + }); + expect(mockApi.post).toHaveBeenCalledWith( + "/v1/unifi/sites/sync", + {}, + { headers: { Authorization: "Bearer token" } }, + ); + expect(mockApi.patch).toHaveBeenCalledWith( + "/v1/unifi/site/site-1/wifi/wlan-1", + { name: "New" }, + { headers: { Authorization: "Bearer token" } }, + ); + }); +}); diff --git a/src/lib/optima-api/modules/auth.spec.ts b/src/lib/optima-api/modules/auth.spec.ts new file mode 100644 index 0000000..ed9ba34 --- /dev/null +++ b/src/lib/optima-api/modules/auth.spec.ts @@ -0,0 +1,61 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const { mockGet, mockCreate } = vi.hoisted(() => ({ + mockGet: vi.fn(), + mockCreate: vi.fn(), +})); + +vi.mock("axios", () => ({ + default: { create: mockCreate }, + create: mockCreate, +})); + +import { auth } from "./auth"; + +describe("auth module", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockCreate.mockReturnValue({ get: mockGet }); + }); + + it("fetches auth redirect uri and callback key", async () => { + mockGet.mockResolvedValueOnce({ + data: { + data: { + uri: "https://auth.example.com", + callbackKey: "cb-123", + }, + }, + }); + + const result = await auth.fetchAuthRedirectUri("https://api.example.com"); + + expect(mockCreate).toHaveBeenCalledWith({ + baseURL: "https://api.example.com", + timeout: 5000, + }); + expect(mockGet).toHaveBeenCalledWith("/v1/auth/uri"); + expect(result).toEqual({ + uri: "https://auth.example.com", + callbackKey: "cb-123", + }); + }); + + it("throws wrapped error when response is missing uri", async () => { + mockGet.mockResolvedValueOnce({ data: { data: {} } }); + + await expect( + auth.fetchAuthRedirectUri("https://api.example.com"), + ).rejects.toThrow( + "Failed to fetch auth redirect uri: redirect uri missing from response", + ); + }); + + it("throws wrapped axios error message", async () => { + mockGet.mockRejectedValueOnce(new Error("network down")); + + await expect( + auth.fetchAuthRedirectUri("https://api.example.com"), + ).rejects.toThrow("Failed to fetch auth redirect uri: network down"); + }); +}); diff --git a/src/lib/optima-api/modules/user.spec.ts b/src/lib/optima-api/modules/user.spec.ts new file mode 100644 index 0000000..c482fa9 --- /dev/null +++ b/src/lib/optima-api/modules/user.spec.ts @@ -0,0 +1,168 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const { mockGetRequestEvent, mockRedirect, mockAxiosPost, mockIo, mockApi } = + vi.hoisted(() => ({ + mockGetRequestEvent: vi.fn(), + mockRedirect: vi.fn(), + mockAxiosPost: vi.fn(), + mockIo: vi.fn(), + mockApi: { + get: vi.fn(), + post: vi.fn(), + }, + })); + +vi.mock("$app/server", () => ({ + getRequestEvent: mockGetRequestEvent, +})); + +vi.mock("$env/static/public", () => ({ + PUBLIC_API_URL: "https://api.example.com", +})); + +vi.mock("@sveltejs/kit", () => ({ + redirect: mockRedirect, +})); + +vi.mock("axios", () => ({ + default: { post: mockAxiosPost }, + post: mockAxiosPost, +})); + +vi.mock("../axios", () => ({ + default: mockApi, + api: mockApi, +})); + +vi.mock("socket.io-client", () => ({ + io: mockIo, +})); + +import { user } from "./user"; + +describe("user module", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("isLoggedIn returns true when accessToken cookie exists", () => { + mockGetRequestEvent.mockReturnValueOnce({ + cookies: { + get: vi.fn().mockReturnValue("token"), + }, + }); + + expect(user.isLoggedIn()).toBe(true); + }); + + it("isLoggedIn returns false when accessToken cookie is missing", () => { + mockGetRequestEvent.mockReturnValueOnce({ + cookies: { + get: vi.fn().mockReturnValue(undefined), + }, + }); + + expect(user.isLoggedIn()).toBe(false); + }); + + it("refreshSession posts refresh header and returns token payload", async () => { + mockAxiosPost.mockResolvedValueOnce({ + data: { + data: { + accessToken: "new-access", + refreshToken: "new-refresh", + }, + }, + }); + + const result = await user.refreshSession("refresh-123"); + + expect(mockAxiosPost).toHaveBeenCalledWith( + "https://api.example.com/v1/auth/refresh", + {}, + { + headers: { + "x-refresh-token": "refresh-123", + }, + }, + ); + expect(result).toEqual({ + accessToken: "new-access", + refreshToken: "new-refresh", + }); + }); + + it("fetchInfo and checkPermissions call expected endpoints", async () => { + mockApi.get.mockResolvedValueOnce({ data: { data: { id: "me" } } }); + mockApi.post.mockResolvedValueOnce({ data: { data: { results: [] } } }); + + await user.fetchInfo("token"); + await user.checkPermissions("token", ["company.read"]); + + expect(mockApi.get).toHaveBeenCalledWith("/v1/user/@me", { + headers: { Authorization: "Bearer token" }, + }); + expect(mockApi.post).toHaveBeenCalledWith( + "/v1/user/@me/check-permission", + { permissions: ["company.read"] }, + { headers: { Authorization: "Bearer token" } }, + ); + }); + + it("logout clears auth cookies and redirects", () => { + const deleteCookie = vi.fn(); + const fakeRedirect = { status: 303, location: "/login" }; + mockRedirect.mockReturnValueOnce(fakeRedirect); + + const result = user.logout({ + cookies: { delete: deleteCookie }, + } as any); + + expect(deleteCookie).toHaveBeenCalledWith("accessToken", { path: "/" }); + expect(deleteCookie).toHaveBeenCalledWith("refreshToken", { path: "/" }); + expect(mockRedirect).toHaveBeenCalledWith(303, "/login"); + expect(result).toBe(fakeRedirect); + }); + + it("awaitAuthCallback resolves when socket event delivers tokens", async () => { + const handlers: Record void> = {}; + const disconnect = vi.fn(); + + mockIo.mockReturnValueOnce({ + on: vi.fn((event: string, callback: (payload?: any) => void) => { + handlers[event] = callback; + }), + disconnect, + }); + + const promise = user.awaitAuthCallback("cb-key"); + + handlers["auth:login:callback:cb-key"]?.({ + accessToken: "access", + refreshToken: "refresh", + }); + + await expect(promise).resolves.toEqual({ + accessToken: "access", + refreshToken: "refresh", + }); + expect(disconnect).toHaveBeenCalled(); + }); + + it("awaitAuthCallback rejects on connect_error", async () => { + const handlers: Record void> = {}; + + mockIo.mockReturnValueOnce({ + on: vi.fn((event: string, callback: (payload?: any) => void) => { + handlers[event] = callback; + }), + disconnect: vi.fn(), + }); + + const promise = user.awaitAuthCallback("cb-key"); + + handlers.connect_error?.(new Error("socket failed")); + + await expect(promise).rejects.toThrow("socket failed"); + }); +}); diff --git a/src/lib/permissions.spec.ts b/src/lib/permissions.spec.ts new file mode 100644 index 0000000..1a18846 --- /dev/null +++ b/src/lib/permissions.spec.ts @@ -0,0 +1,73 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const { mockCheckPermissions } = vi.hoisted(() => ({ + mockCheckPermissions: vi.fn(), +})); + +vi.mock("$lib", () => ({ + optima: { + user: { + checkPermissions: mockCheckPermissions, + }, + }, +})); + +import { + checkPermissions, + hasPermission, + resolvePermissions, +} from "./permissions"; + +describe("permissions helpers", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("returns empty map when no permissions are requested", async () => { + const result = await checkPermissions("token", []); + + expect(result).toEqual({}); + expect(mockCheckPermissions).not.toHaveBeenCalled(); + }); + + it("maps API response into permission booleans", async () => { + mockCheckPermissions.mockResolvedValueOnce({ + data: { + results: [ + { permission: "company.read", hasPermission: true }, + { permission: "credential.create", hasPermission: false }, + ], + }, + }); + + const result = await checkPermissions("token", [ + "company.read", + "credential.create", + ]); + + expect(result).toEqual({ + "company.read": true, + "credential.create": false, + }); + }); + + it("defaults requested permissions to false on API error", async () => { + mockCheckPermissions.mockRejectedValueOnce(new Error("request failed")); + + const result = await checkPermissions("token", ["a", "b"]); + + expect(result).toEqual({ a: false, b: false }); + }); + + it("hasPermission returns true only for explicit true values", () => { + expect(hasPermission({ "company.read": true }, "company.read")).toBe(true); + expect(hasPermission({ "company.read": false }, "company.read")).toBe( + false, + ); + expect(hasPermission({}, "company.read")).toBe(false); + }); + + it("exports resolvePermissions as backward-compatible alias", () => { + expect(resolvePermissions).toBe(checkPermissions); + }); +}); diff --git a/src/lib/permissions.ts b/src/lib/permissions.ts index 499ff60..29ed7bb 100644 --- a/src/lib/permissions.ts +++ b/src/lib/permissions.ts @@ -42,6 +42,8 @@ export async function checkPermissions( } } +export const resolvePermissions = checkPermissions; + /** * Convenience helper — returns true when a specific permission is * granted inside a PermissionMap.