fix: restore permissions export compatibility and add regressions

This commit is contained in:
2026-02-27 14:54:26 -06:00
parent 5a6970a4c5
commit cb8c6b3958
8 changed files with 617 additions and 10 deletions
+1
View File
@@ -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
+5 -4
View File
@@ -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*$/);
});
+4 -4
View File
@@ -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
command: "PORT=4173 ORIGIN=http://localhost:4173 node build/index.js",
port: 4173,
},
testDir: 'e2e'
testDir: "e2e",
});
@@ -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" } },
);
});
});
+61
View File
@@ -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");
});
});
+168
View File
@@ -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<string, (payload?: any) => 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<string, (payload?: any) => 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");
});
});
+73
View File
@@ -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);
});
});
+2
View File
@@ -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.