feat(integrations): add PatchMon integration and widget (#6119)
Co-authored-by: Thomas <thomas@ajnart.fr>
This commit is contained in:
1 parent
174c5f98f8
commit
33395ae226
41 files changed
+2982
-17
No files matched your search
@@ -0,0 +1,200 @@
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
|
||||
import type { Session } from "@homarr/auth";
|
||||
import { createId } from "@homarr/common";
|
||||
import type { Database } from "@homarr/db";
|
||||
import { boards, integrationItems, integrations, integrationUserPermissions, items, users } from "@homarr/db/schema";
|
||||
import { createDb } from "@homarr/db/test";
|
||||
import type { PatchMonStats } from "@homarr/integrations";
|
||||
|
||||
import { patchmonRouter } from "../../widgets/patchmon";
|
||||
|
||||
const samplePatchMonStats = {
|
||||
totalHosts: 42,
|
||||
hostsNeedingUpdates: 15,
|
||||
securityUpdates: 23,
|
||||
upToDateHosts: 27,
|
||||
hostsWithSecurityUpdates: 8,
|
||||
recentUpdates24h: 34,
|
||||
totalOutdatedPackages: 156,
|
||||
totalRepos: 12,
|
||||
lastUpdated: "2025-10-11T12:34:56.789Z",
|
||||
osDistribution: [],
|
||||
} satisfies PatchMonStats;
|
||||
|
||||
vi.mock("@homarr/request-handler/patchmon", () => ({
|
||||
patchmonStatsRequestHandler: {
|
||||
handler: () => ({
|
||||
getDataAsync: async () => ({
|
||||
data: samplePatchMonStats,
|
||||
timestamp: new Date(),
|
||||
}),
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
const createSession = (userId: string): Session => ({
|
||||
user: {
|
||||
id: userId,
|
||||
permissions: [],
|
||||
colorScheme: "light",
|
||||
},
|
||||
expires: new Date().toISOString(),
|
||||
});
|
||||
|
||||
const createCaller = (db: Database, session: Session | null) =>
|
||||
patchmonRouter.createCaller({
|
||||
db,
|
||||
deviceType: undefined,
|
||||
session,
|
||||
});
|
||||
|
||||
const createPatchMonIntegrationOnBoardAsync = async (
|
||||
db: Database,
|
||||
options: {
|
||||
isPublic: boolean;
|
||||
boardCreatorId: string;
|
||||
},
|
||||
) => {
|
||||
const integrationId = createId();
|
||||
const boardId = createId();
|
||||
const itemId = createId();
|
||||
|
||||
await db.insert(integrations).values({
|
||||
id: integrationId,
|
||||
kind: "patchmon",
|
||||
name: "PatchMon",
|
||||
url: "https://patchmon.example.com",
|
||||
});
|
||||
|
||||
await db.insert(boards).values({
|
||||
id: boardId,
|
||||
name: "patchmon-board",
|
||||
creatorId: options.boardCreatorId,
|
||||
isPublic: options.isPublic,
|
||||
});
|
||||
|
||||
await db.insert(items).values({
|
||||
id: itemId,
|
||||
kind: "patchmon",
|
||||
boardId,
|
||||
options: "{}",
|
||||
});
|
||||
|
||||
await db.insert(integrationItems).values({
|
||||
integrationId,
|
||||
itemId,
|
||||
});
|
||||
|
||||
return { integrationId, boardId, itemId };
|
||||
};
|
||||
|
||||
describe("patchmonRouter.getStats access control", () => {
|
||||
test("should throw UNAUTHORIZED for unauthenticated users even when integration is on a public board", async () => {
|
||||
const db = createDb();
|
||||
const otherUserId = createId();
|
||||
await db.insert(users).values({ id: otherUserId });
|
||||
const { integrationId } = await createPatchMonIntegrationOnBoardAsync(db, {
|
||||
isPublic: true,
|
||||
boardCreatorId: otherUserId,
|
||||
});
|
||||
const caller = createCaller(db, null);
|
||||
|
||||
await expect(caller.getStats({ integrationId })).rejects.toThrow(
|
||||
new TRPCError({ code: "UNAUTHORIZED" }),
|
||||
);
|
||||
});
|
||||
|
||||
test("should throw FORBIDDEN for authenticated users without board or integration access", async () => {
|
||||
const db = createDb();
|
||||
const boardOwnerId = createId();
|
||||
const sessionUserId = createId();
|
||||
await db.insert(users).values([{ id: boardOwnerId }, { id: sessionUserId }]);
|
||||
const { integrationId } = await createPatchMonIntegrationOnBoardAsync(db, {
|
||||
isPublic: false,
|
||||
boardCreatorId: boardOwnerId,
|
||||
});
|
||||
const caller = createCaller(db, createSession(sessionUserId));
|
||||
|
||||
await expect(caller.getStats({ integrationId })).rejects.toThrow(
|
||||
new TRPCError({
|
||||
code: "FORBIDDEN",
|
||||
message: "User does not have permission to query at least one of the specified integration",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
test("should return stats for authenticated users when integration is on a public board", async () => {
|
||||
const db = createDb();
|
||||
const boardOwnerId = createId();
|
||||
const sessionUserId = createId();
|
||||
await db.insert(users).values([{ id: boardOwnerId }, { id: sessionUserId }]);
|
||||
const { integrationId } = await createPatchMonIntegrationOnBoardAsync(db, {
|
||||
isPublic: true,
|
||||
boardCreatorId: boardOwnerId,
|
||||
});
|
||||
const caller = createCaller(db, createSession(sessionUserId));
|
||||
|
||||
const result = await caller.getStats({ integrationId });
|
||||
|
||||
expect(result).toStrictEqual(samplePatchMonStats);
|
||||
});
|
||||
|
||||
test("should return stats for authenticated users with integration use permission and no board placement", async () => {
|
||||
const db = createDb();
|
||||
const sessionUserId = createId();
|
||||
const integrationId = createId();
|
||||
await db.insert(users).values({ id: sessionUserId });
|
||||
await db.insert(integrations).values({
|
||||
id: integrationId,
|
||||
kind: "patchmon",
|
||||
name: "PatchMon",
|
||||
url: "https://patchmon.example.com",
|
||||
});
|
||||
await db.insert(integrationUserPermissions).values({
|
||||
integrationId,
|
||||
userId: sessionUserId,
|
||||
permission: "use",
|
||||
});
|
||||
const caller = createCaller(db, createSession(sessionUserId));
|
||||
|
||||
const result = await caller.getStats({ integrationId });
|
||||
|
||||
expect(result).toStrictEqual(samplePatchMonStats);
|
||||
});
|
||||
|
||||
test("should throw NOT_FOUND when integration id does not exist", async () => {
|
||||
const db = createDb();
|
||||
const sessionUserId = createId();
|
||||
await db.insert(users).values({ id: sessionUserId });
|
||||
const caller = createCaller(db, createSession(sessionUserId));
|
||||
|
||||
await expect(caller.getStats({ integrationId: createId() })).rejects.toMatchObject({
|
||||
code: "NOT_FOUND",
|
||||
});
|
||||
});
|
||||
|
||||
test("should throw NOT_FOUND when integration is not a PatchMon integration", async () => {
|
||||
const db = createDb();
|
||||
const sessionUserId = createId();
|
||||
const integrationId = createId();
|
||||
await db.insert(users).values({ id: sessionUserId });
|
||||
await db.insert(integrations).values({
|
||||
id: integrationId,
|
||||
kind: "sonarr",
|
||||
name: "Sonarr",
|
||||
url: "https://sonarr.example.com",
|
||||
});
|
||||
await db.insert(integrationUserPermissions).values({
|
||||
integrationId,
|
||||
userId: sessionUserId,
|
||||
permission: "use",
|
||||
});
|
||||
const caller = createCaller(db, createSession(sessionUserId));
|
||||
|
||||
await expect(caller.getStats({ integrationId })).rejects.toMatchObject({
|
||||
code: "NOT_FOUND",
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user