324 lines
10 KiB
TypeScript
324 lines
10 KiB
TypeScript
import { describe, expect, test } from "bun:test";
|
|||
|
|
import { generateToken } from "../../auth";
|
||
|
|
import {
|
||
|
|
ALL_FEATURES,
|
||
|
|
createUser,
|
||
|
|
deleteUser,
|
||
|
|
getUserById,
|
||
|
|
setUserClientAccessScope,
|
||
|
|
setUserFeaturePermission,
|
||
|
|
} from "../../users";
|
||
|
|
import { handleAuthRoutes } from "./auth-routes";
|
||
|
|
|
||
|
|
const PASSWORD = "Aa1!AuthRoutesFeaturePermsTest_2026";
|
||
|
|
|
||
|
|
const mockServer = {
|
||
|
|
requestIP: () => ({ address: "127.0.0.1" }),
|
||
|
|
};
|
||
|
|
|
||
|
|
async function createTokenFor(role: "admin" | "operator" | "viewer") {
|
||
|
|
const username = `me_${role.slice(0, 3)}_${Date.now().toString(36)}_${Math.floor(Math.random() * 1e6).toString(36)}`;
|
||
|
|
const created = await createUser(username, PASSWORD, role, "test");
|
||
|
|
expect(created.success).toBe(true);
|
||
|
|
expect(typeof created.userId).toBe("number");
|
||
|
|
const user = getUserById(created.userId!);
|
||
|
|
expect(user).not.toBeNull();
|
||
|
|
const token = await generateToken(user!);
|
||
|
|
return { userId: created.userId!, token, username, role };
|
||
|
|
}
|
||
|
|
|
||
|
|
describe("/api/auth/me featurePermissions", () => {
|
||
|
|
test("admin gets every feature allowed in /api/auth/me", async () => {
|
||
|
|
const auth = await createTokenFor("admin");
|
||
|
|
try {
|
||
|
|
const url = new URL("https://localhost/api/auth/me");
|
||
|
|
const res = await handleAuthRoutes(
|
||
|
|
new Request(url, {
|
||
|
|
method: "GET",
|
||
|
|
headers: { Authorization: `Bearer ${auth.token}` },
|
||
|
|
}),
|
||
|
|
url,
|
||
|
|
mockServer,
|
||
|
|
);
|
||
|
|
expect(res).not.toBeNull();
|
||
|
|
expect(res!.status).toBe(200);
|
||
|
|
const body = (await res!.json()) as any;
|
||
|
|
expect(body.username).toBe(auth.username);
|
||
|
|
expect(body.role).toBe("admin");
|
||
|
|
expect(body.featurePermissions).toBeDefined();
|
||
|
|
for (const f of ALL_FEATURES) {
|
||
|
|
expect(body.featurePermissions[f]).toBe(true);
|
||
|
|
}
|
||
|
|
} finally {
|
||
|
|
deleteUser(auth.userId);
|
||
|
|
}
|
||
|
|
});
|
||
|
|
|
||
|
|
test("operator gets every feature allowed by default in /api/auth/me", async () => {
|
||
|
|
const auth = await createTokenFor("operator");
|
||
|
|
try {
|
||
|
|
const url = new URL("https://localhost/api/auth/me");
|
||
|
|
const res = await handleAuthRoutes(
|
||
|
|
new Request(url, {
|
||
|
|
method: "GET",
|
||
|
|
headers: { Authorization: `Bearer ${auth.token}` },
|
||
|
|
}),
|
||
|
|
url,
|
||
|
|
mockServer,
|
||
|
|
);
|
||
|
|
const body = (await res!.json()) as any;
|
||
|
|
expect(body.role).toBe("operator");
|
||
|
|
for (const f of ALL_FEATURES) {
|
||
|
|
expect(body.featurePermissions[f]).toBe(true);
|
||
|
|
}
|
||
|
|
} finally {
|
||
|
|
deleteUser(auth.userId);
|
||
|
|
}
|
||
|
|
});
|
||
|
|
|
||
|
|
test("operator-denied features are reflected in /api/auth/me", async () => {
|
||
|
|
const auth = await createTokenFor("operator");
|
||
|
|
try {
|
||
|
|
setUserFeaturePermission(auth.userId, "webcam", false);
|
||
|
|
setUserFeaturePermission(auth.userId, "file_browser", false);
|
||
|
|
|
||
|
|
const url = new URL("https://localhost/api/auth/me");
|
||
|
|
const res = await handleAuthRoutes(
|
||
|
|
new Request(url, {
|
||
|
|
method: "GET",
|
||
|
|
headers: { Authorization: `Bearer ${auth.token}` },
|
||
|
|
}),
|
||
|
|
url,
|
||
|
|
mockServer,
|
||
|
|
);
|
||
|
|
const body = (await res!.json()) as any;
|
||
|
|
expect(body.featurePermissions.webcam).toBe(false);
|
||
|
|
expect(body.featurePermissions.file_browser).toBe(false);
|
||
|
|
expect(body.featurePermissions.console).toBe(true);
|
||
|
|
expect(body.featurePermissions.hvnc).toBe(true);
|
||
|
|
} finally {
|
||
|
|
deleteUser(auth.userId);
|
||
|
|
}
|
||
|
|
});
|
||
|
|
|
||
|
|
test("viewer gets every feature denied in /api/auth/me", async () => {
|
||
|
|
const auth = await createTokenFor("viewer");
|
||
|
|
try {
|
||
|
|
const url = new URL("https://localhost/api/auth/me");
|
||
|
|
const res = await handleAuthRoutes(
|
||
|
|
new Request(url, {
|
||
|
|
method: "GET",
|
||
|
|
headers: { Authorization: `Bearer ${auth.token}` },
|
||
|
|
}),
|
||
|
|
url,
|
||
|
|
mockServer,
|
||
|
|
);
|
||
|
|
const body = (await res!.json()) as any;
|
||
|
|
expect(body.role).toBe("viewer");
|
||
|
|
for (const f of ALL_FEATURES) {
|
||
|
|
expect(body.featurePermissions[f]).toBe(false);
|
||
|
|
}
|
||
|
|
} finally {
|
||
|
|
deleteUser(auth.userId);
|
||
|
|
}
|
||
|
|
});
|
||
|
|
|
||
|
|
test("/api/auth/me requires authentication", async () => {
|
||
|
|
const url = new URL("https://localhost/api/auth/me");
|
||
|
|
const res = await handleAuthRoutes(
|
||
|
|
new Request(url, { method: "GET" }),
|
||
|
|
url,
|
||
|
|
mockServer,
|
||
|
|
);
|
||
|
|
expect(res).not.toBeNull();
|
||
|
|
expect(res!.status).toBe(401);
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
describe("/api/auth/feature-check", () => {
|
||
|
|
test("operator with allowed feature and accessible client returns allowed=true", async () => {
|
||
|
|
const auth = await createTokenFor("operator");
|
||
|
|
try {
|
||
|
|
setUserClientAccessScope(auth.userId, "all");
|
||
|
|
|
||
|
|
const url = new URL("https://localhost/api/auth/feature-check?feature=console&clientId=client-x");
|
||
|
|
const res = await handleAuthRoutes(
|
||
|
|
new Request(url, {
|
||
|
|
method: "GET",
|
||
|
|
headers: { Authorization: `Bearer ${auth.token}` },
|
||
|
|
}),
|
||
|
|
url,
|
||
|
|
mockServer,
|
||
|
|
);
|
||
|
|
expect(res!.status).toBe(200);
|
||
|
|
const body = (await res!.json()) as any;
|
||
|
|
expect(body.allowed).toBe(true);
|
||
|
|
} finally {
|
||
|
|
deleteUser(auth.userId);
|
||
|
|
}
|
||
|
|
});
|
||
|
|
|
||
|
|
test("operator with revoked feature returns allowed=false with reason 'feature'", async () => {
|
||
|
|
const auth = await createTokenFor("operator");
|
||
|
|
try {
|
||
|
|
setUserClientAccessScope(auth.userId, "all");
|
||
|
|
setUserFeaturePermission(auth.userId, "webcam", false);
|
||
|
|
|
||
|
|
const url = new URL("https://localhost/api/auth/feature-check?feature=webcam&clientId=client-x");
|
||
|
|
const res = await handleAuthRoutes(
|
||
|
|
new Request(url, {
|
||
|
|
method: "GET",
|
||
|
|
headers: { Authorization: `Bearer ${auth.token}` },
|
||
|
|
}),
|
||
|
|
url,
|
||
|
|
mockServer,
|
||
|
|
);
|
||
|
|
expect(res!.status).toBe(200);
|
||
|
|
const body = (await res!.json()) as any;
|
||
|
|
expect(body.allowed).toBe(false);
|
||
|
|
expect(body.denied).toContain("feature");
|
||
|
|
expect(body.denied).not.toContain("client");
|
||
|
|
} finally {
|
||
|
|
deleteUser(auth.userId);
|
||
|
|
}
|
||
|
|
});
|
||
|
|
|
||
|
|
test("operator without client access returns allowed=false with reason 'client'", async () => {
|
||
|
|
const auth = await createTokenFor("operator");
|
||
|
|
try {
|
||
|
|
// default client_scope=none
|
||
|
|
|
||
|
|
const url = new URL("https://localhost/api/auth/feature-check?feature=console&clientId=client-x");
|
||
|
|
const res = await handleAuthRoutes(
|
||
|
|
new Request(url, {
|
||
|
|
method: "GET",
|
||
|
|
headers: { Authorization: `Bearer ${auth.token}` },
|
||
|
|
}),
|
||
|
|
url,
|
||
|
|
mockServer,
|
||
|
|
);
|
||
|
|
expect(res!.status).toBe(200);
|
||
|
|
const body = (await res!.json()) as any;
|
||
|
|
expect(body.allowed).toBe(false);
|
||
|
|
expect(body.denied).toContain("client");
|
||
|
|
expect(body.denied).not.toContain("feature");
|
||
|
|
} finally {
|
||
|
|
deleteUser(auth.userId);
|
||
|
|
}
|
||
|
|
});
|
||
|
|
|
||
|
|
test("both feature and client denied returns both reasons", async () => {
|
||
|
|
const auth = await createTokenFor("operator");
|
||
|
|
try {
|
||
|
|
setUserFeaturePermission(auth.userId, "hvnc", false);
|
||
|
|
// client_scope stays "none" => no access
|
||
|
|
|
||
|
|
const url = new URL("https://localhost/api/auth/feature-check?feature=hvnc&clientId=client-x");
|
||
|
|
const res = await handleAuthRoutes(
|
||
|
|
new Request(url, {
|
||
|
|
method: "GET",
|
||
|
|
headers: { Authorization: `Bearer ${auth.token}` },
|
||
|
|
}),
|
||
|
|
url,
|
||
|
|
mockServer,
|
||
|
|
);
|
||
|
|
const body = (await res!.json()) as any;
|
||
|
|
expect(body.allowed).toBe(false);
|
||
|
|
expect(body.denied).toContain("feature");
|
||
|
|
expect(body.denied).toContain("client");
|
||
|
|
} finally {
|
||
|
|
deleteUser(auth.userId);
|
||
|
|
}
|
||
|
|
});
|
||
|
|
|
||
|
|
test("viewer is denied every feature", async () => {
|
||
|
|
const auth = await createTokenFor("viewer");
|
||
|
|
try {
|
||
|
|
const url = new URL("https://localhost/api/auth/feature-check?feature=console&clientId=client-x");
|
||
|
|
const res = await handleAuthRoutes(
|
||
|
|
new Request(url, {
|
||
|
|
method: "GET",
|
||
|
|
headers: { Authorization: `Bearer ${auth.token}` },
|
||
|
|
}),
|
||
|
|
url,
|
||
|
|
mockServer,
|
||
|
|
);
|
||
|
|
const body = (await res!.json()) as any;
|
||
|
|
expect(body.allowed).toBe(false);
|
||
|
|
expect(body.denied).toContain("feature");
|
||
|
|
} finally {
|
||
|
|
deleteUser(auth.userId);
|
||
|
|
}
|
||
|
|
});
|
||
|
|
|
||
|
|
test("admin is allowed every feature, even with no client_scope", async () => {
|
||
|
|
const auth = await createTokenFor("admin");
|
||
|
|
try {
|
||
|
|
const url = new URL("https://localhost/api/auth/feature-check?feature=webcam&clientId=client-x");
|
||
|
|
const res = await handleAuthRoutes(
|
||
|
|
new Request(url, {
|
||
|
|
method: "GET",
|
||
|
|
headers: { Authorization: `Bearer ${auth.token}` },
|
||
|
|
}),
|
||
|
|
url,
|
||
|
|
mockServer,
|
||
|
|
);
|
||
|
|
const body = (await res!.json()) as any;
|
||
|
|
expect(body.allowed).toBe(true);
|
||
|
|
} finally {
|
||
|
|
deleteUser(auth.userId);
|
||
|
|
}
|
||
|
|
});
|
||
|
|
|
||
|
|
test("invalid feature name returns 400", async () => {
|
||
|
|
const auth = await createTokenFor("operator");
|
||
|
|
try {
|
||
|
|
const url = new URL("https://localhost/api/auth/feature-check?feature=not_a_feature&clientId=client-x");
|
||
|
|
const res = await handleAuthRoutes(
|
||
|
|
new Request(url, {
|
||
|
|
method: "GET",
|
||
|
|
headers: { Authorization: `Bearer ${auth.token}` },
|
||
|
|
}),
|
||
|
|
url,
|
||
|
|
mockServer,
|
||
|
|
);
|
||
|
|
expect(res!.status).toBe(400);
|
||
|
|
} finally {
|
||
|
|
deleteUser(auth.userId);
|
||
|
|
}
|
||
|
|
});
|
||
|
|
|
||
|
|
test("unauthenticated request returns 401", async () => {
|
||
|
|
const url = new URL("https://localhost/api/auth/feature-check?feature=console&clientId=client-x");
|
||
|
|
const res = await handleAuthRoutes(
|
||
|
|
new Request(url, { method: "GET" }),
|
||
|
|
url,
|
||
|
|
mockServer,
|
||
|
|
);
|
||
|
|
expect(res).not.toBeNull();
|
||
|
|
expect(res!.status).toBe(401);
|
||
|
|
});
|
||
|
|
|
||
|
|
test("clientId is optional — feature-only check works without clientId", async () => {
|
||
|
|
const auth = await createTokenFor("operator");
|
||
|
|
try {
|
||
|
|
setUserFeaturePermission(auth.userId, "webcam", false);
|
||
|
|
|
||
|
|
const url = new URL("https://localhost/api/auth/feature-check?feature=webcam");
|
||
|
|
const res = await handleAuthRoutes(
|
||
|
|
new Request(url, {
|
||
|
|
method: "GET",
|
||
|
|
headers: { Authorization: `Bearer ${auth.token}` },
|
||
|
|
}),
|
||
|
|
url,
|
||
|
|
mockServer,
|
||
|
|
);
|
||
|
|
const body = (await res!.json()) as any;
|
||
|
|
expect(body.allowed).toBe(false);
|
||
|
|
expect(body.denied).toEqual(["feature"]);
|
||
|
|
} finally {
|
||
|
|
deleteUser(auth.userId);
|
||
|
|
}
|
||
|
|
});
|
||
|
|
});
|