test: add vitest unit tests and Gitea Actions CI workflow
Covers secrets encryption, Nixpacks runtime-version detection, the admin-session middleware, and the installations state machine. CI runs the same build+test steps on every push via .gitea/workflows/ci.yml. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
17
.gitea/workflows/ci.yml
Normal file
17
.gitea/workflows/ci.yml
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
name: CI
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
pull_request:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
test:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 20
|
||||||
|
- run: npm ci
|
||||||
|
- run: npm run build --workspace apps/server
|
||||||
|
- run: npm test
|
||||||
@@ -5,7 +5,8 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "tsx src/index.ts",
|
"dev": "tsx src/index.ts",
|
||||||
"build": "tsc -p tsconfig.json",
|
"build": "tsc -p tsconfig.json",
|
||||||
"start": "node dist/index.js"
|
"start": "node dist/index.js",
|
||||||
|
"test": "vitest run"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"better-sqlite3": "11.10.0",
|
"better-sqlite3": "11.10.0",
|
||||||
@@ -22,6 +23,7 @@
|
|||||||
"@types/http-proxy": "1.17.16",
|
"@types/http-proxy": "1.17.16",
|
||||||
"@types/node": "24.9.1",
|
"@types/node": "24.9.1",
|
||||||
"tsx": "4.20.6",
|
"tsx": "4.20.6",
|
||||||
"typescript": "5.9.3"
|
"typescript": "5.9.3",
|
||||||
|
"vitest": "^5.0.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
41
apps/server/src/build/nixpacks.test.ts
Normal file
41
apps/server/src/build/nixpacks.test.ts
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import fs from "node:fs";
|
||||||
|
import os from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
import { detectNodeVersion } from "./nixpacks.js";
|
||||||
|
|
||||||
|
function mkTmpRepo(): string {
|
||||||
|
return fs.mkdtempSync(path.join(os.tmpdir(), "nixpacks-test-"));
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("detectNodeVersion", () => {
|
||||||
|
it("reads the major version from .nvmrc", () => {
|
||||||
|
const dir = mkTmpRepo();
|
||||||
|
fs.writeFileSync(path.join(dir, ".nvmrc"), "20.11.0\n");
|
||||||
|
expect(detectNodeVersion(dir)).toBe("20");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("prefers .nvmrc over package.json when both are present", () => {
|
||||||
|
const dir = mkTmpRepo();
|
||||||
|
fs.writeFileSync(path.join(dir, ".nvmrc"), "18\n");
|
||||||
|
fs.writeFileSync(path.join(dir, "package.json"), JSON.stringify({ engines: { node: ">=22" } }));
|
||||||
|
expect(detectNodeVersion(dir)).toBe("18");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to package.json engines.node", () => {
|
||||||
|
const dir = mkTmpRepo();
|
||||||
|
fs.writeFileSync(path.join(dir, "package.json"), JSON.stringify({ engines: { node: ">=20.0.0" } }));
|
||||||
|
expect(detectNodeVersion(dir)).toBe("20");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns undefined when there is no version hint", () => {
|
||||||
|
const dir = mkTmpRepo();
|
||||||
|
expect(detectNodeVersion(dir)).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns undefined for malformed package.json instead of throwing", () => {
|
||||||
|
const dir = mkTmpRepo();
|
||||||
|
fs.writeFileSync(path.join(dir, "package.json"), "{not valid json");
|
||||||
|
expect(detectNodeVersion(dir)).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
25
apps/server/src/crypto/secrets.test.ts
Normal file
25
apps/server/src/crypto/secrets.test.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import { describe, expect, it, beforeAll } from "vitest";
|
||||||
|
import { encryptSecret, decryptSecret, generateBearerToken } from "./secrets.js";
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
process.env.MASTER_KEY = "test-master-key-at-least-32-characters-long";
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("secrets", () => {
|
||||||
|
it("round-trips an encrypted value", () => {
|
||||||
|
const encrypted = encryptSecret("hello world");
|
||||||
|
expect(encrypted).not.toContain("hello world");
|
||||||
|
expect(decryptSecret(encrypted)).toBe("hello world");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fails to decrypt with a tampered ciphertext", () => {
|
||||||
|
const encrypted = encryptSecret("hello world");
|
||||||
|
const tampered = Buffer.from(encrypted, "base64");
|
||||||
|
tampered[tampered.length - 1] ^= 0xff;
|
||||||
|
expect(() => decryptSecret(tampered.toString("base64"))).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("generates distinct bearer tokens", () => {
|
||||||
|
expect(generateBearerToken()).not.toBe(generateBearerToken());
|
||||||
|
});
|
||||||
|
});
|
||||||
61
apps/server/src/db/installations.test.ts
Normal file
61
apps/server/src/db/installations.test.ts
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import fs from "node:fs";
|
||||||
|
import os from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
// db/index.ts opens the sqlite file as a side effect of being imported, using config
|
||||||
|
// derived from DATA_DIR/DB_PATH — so these must be set before the dynamic import below.
|
||||||
|
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "mcpmgr-db-test-"));
|
||||||
|
process.env.DATA_DIR = tmpDir;
|
||||||
|
process.env.DB_PATH = path.join(tmpDir, "db.sqlite");
|
||||||
|
process.env.MASTER_KEY = "test-master-key-at-least-32-characters-long";
|
||||||
|
|
||||||
|
const { createInstallation, updateInstallation, getInstallationById, slugify } = await import("./installations.js");
|
||||||
|
|
||||||
|
describe("slugify", () => {
|
||||||
|
it("normalizes names into a URL/container-safe slug", () => {
|
||||||
|
expect(slugify("My Cool MCP!")).toBe("my-cool-mcp");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("installations repository", () => {
|
||||||
|
it("creates a draft installation with a derived container name", () => {
|
||||||
|
const installation = createInstallation({
|
||||||
|
name: "Test MCP",
|
||||||
|
slug: "test-mcp",
|
||||||
|
repoUrl: "https://example.com/repo.git",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(installation.status).toBe("draft");
|
||||||
|
expect(installation.containerName).toBe("mcp-test-mcp");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("persists status transitions", () => {
|
||||||
|
const installation = createInstallation({
|
||||||
|
name: "State MCP",
|
||||||
|
slug: "state-mcp",
|
||||||
|
repoUrl: "https://example.com/repo.git",
|
||||||
|
});
|
||||||
|
|
||||||
|
updateInstallation(installation.id, { status: "building" });
|
||||||
|
updateInstallation(installation.id, { status: "built", imageTag: "mcpmgr/state-mcp:latest" });
|
||||||
|
const final = updateInstallation(installation.id, { status: "running", bearerToken: "secret-token" });
|
||||||
|
|
||||||
|
expect(final.status).toBe("running");
|
||||||
|
expect(final.imageTag).toBe("mcpmgr/state-mcp:latest");
|
||||||
|
expect(getInstallationById(installation.id)?.status).toBe("running");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("round-trips encrypted env vars", () => {
|
||||||
|
const installation = createInstallation({
|
||||||
|
name: "Env MCP",
|
||||||
|
slug: "env-mcp",
|
||||||
|
repoUrl: "https://example.com/repo.git",
|
||||||
|
});
|
||||||
|
|
||||||
|
const updated = updateInstallation(installation.id, { env: { FOO: "bar", API_KEY: "shh" } });
|
||||||
|
|
||||||
|
expect(updated.env).toEqual({ FOO: "bar", API_KEY: "shh" });
|
||||||
|
expect(getInstallationById(installation.id)?.env).toEqual({ FOO: "bar", API_KEY: "shh" });
|
||||||
|
});
|
||||||
|
});
|
||||||
35
apps/server/src/middleware/requireAuth.test.ts
Normal file
35
apps/server/src/middleware/requireAuth.test.ts
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import type { Request, Response } from "express";
|
||||||
|
import { requireAuth } from "./requireAuth.js";
|
||||||
|
|
||||||
|
function mockRes() {
|
||||||
|
const res = {} as Response;
|
||||||
|
res.status = vi.fn().mockReturnValue(res);
|
||||||
|
res.json = vi.fn().mockReturnValue(res);
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("requireAuth", () => {
|
||||||
|
it("calls next when the session is authenticated", () => {
|
||||||
|
const req = { session: { authenticated: true } } as unknown as Request;
|
||||||
|
const res = mockRes();
|
||||||
|
const next = vi.fn();
|
||||||
|
|
||||||
|
requireAuth(req, res, next);
|
||||||
|
|
||||||
|
expect(next).toHaveBeenCalledOnce();
|
||||||
|
expect(res.status).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects with 401 when the session is not authenticated", () => {
|
||||||
|
const req = { session: {} } as unknown as Request;
|
||||||
|
const res = mockRes();
|
||||||
|
const next = vi.fn();
|
||||||
|
|
||||||
|
requireAuth(req, res, next);
|
||||||
|
|
||||||
|
expect(next).not.toHaveBeenCalled();
|
||||||
|
expect(res.status).toHaveBeenCalledWith(401);
|
||||||
|
expect(res.json).toHaveBeenCalledWith({ error: "authentication required" });
|
||||||
|
});
|
||||||
|
});
|
||||||
1007
package-lock.json
generated
1007
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -5,6 +5,7 @@
|
|||||||
"apps/*"
|
"apps/*"
|
||||||
],
|
],
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev:server": "npm run dev --workspace apps/server"
|
"dev:server": "npm run dev --workspace apps/server",
|
||||||
|
"test": "npm run test --workspace apps/server"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user