Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -48,3 +48,20 @@ jobs:

- name: Test
run: bun run test

dependency-audit:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5

- name: Install bun
uses: oven-sh/setup-bun@v2
with:
bun-version: "1.x"

- name: Install dependencies
run: bun install --frozen-lockfile

- name: Fail on high or critical vulnerabilities
run: npm audit --audit-level=high
continue-on-error: true
4 changes: 4 additions & 0 deletions .github/workflows/security-audit.yml
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ jobs:

- name: Install bun
uses: oven-sh/setup-bun@v2
with:
bun-version: "1.x"

- name: Install dependencies
run: bun install --frozen-lockfile
Expand Down Expand Up @@ -159,6 +161,8 @@ jobs:

- name: Install bun
uses: oven-sh/setup-bun@v2
with:
bun-version: "1.x"

- name: Install dependencies
run: bun install --frozen-lockfile
Expand Down
4 changes: 4 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,10 @@
"version": "1.0.0",
"private": true,
"license": "Apache-2.0",
"engines": {
"node": ">=20.0.0",
"bun": ">=1.0.0"
},
"scripts": {
"dev": "node --watch -r ts-node/register src/index.ts",
"dev:no-watch": "ts-node src/index.ts",
Expand Down
79 changes: 69 additions & 10 deletions src/__tests__/admin-response-shape.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,31 @@ jest.mock("../lib/registry", () => ({
updateImpactScore: jest.fn(),
getTotalProjects: jest.fn(),
}));
jest.mock("../lib/apiKeyRoles", () => ({
getApiKeyRole: jest.fn((key: string) => {
if (key === "test-key") return "admin:write";
return undefined;
}),
hasRolePermission: jest.fn((userRole: any, requiredRole: any) => {
if (!userRole) return false;
if (userRole === "admin:write")
return requiredRole === "admin:read" || requiredRole === "admin:write";
return userRole === requiredRole;
}),
}));
jest.mock("../routes/iot");
jest.mock("../lib/scoring");
jest.mock("../lib/scoreService", () => ({
updateScoreForProject: jest.fn(),
resetIdempotencyState: jest.fn(),
}));
jest.mock("../config", () => ({
config: {
ADMIN_API_KEY: "test-key",
},
}));

import { updateScoreForProject } from "../lib/scoreService";

function buildApp(): Express {
const app = express();
Expand All @@ -21,6 +44,8 @@ function buildApp(): Express {
return app;
}

const AUTH_HEADER = { Authorization: "Bearer test-key" };

describe("admin /update-scores response shape", () => {
let app: Express;

Expand All @@ -42,37 +67,64 @@ describe("admin /update-scores response shape", () => {
});
(registry.updateImpactScore as jest.Mock).mockResolvedValue("tx-hash");
(registry.getTotalProjects as jest.Mock).mockResolvedValue(2);
(updateScoreForProject as jest.Mock).mockImplementation(async (projectId: number) => ({
status: "success",
projectId,
creditQuality: 85,
greenImpact: 70,
txHash: "tx-hash",
}));
});

afterEach(() => {
delete process.env.ADMIN_API_KEY;
});

it("response has updated field (number)", async () => {
const res = await request(app).post("/api/admin/update-scores").send({}).expect(200);
const res = await request(app)
.post("/api/admin/update-scores")
.set(AUTH_HEADER)
.send({})
.expect(200);
expect(res.body).toHaveProperty("updated");
expect(typeof res.body.updated).toBe("number");
});

it("response has results field (array)", async () => {
const res = await request(app).post("/api/admin/update-scores").send({}).expect(200);
const res = await request(app)
.post("/api/admin/update-scores")
.set(AUTH_HEADER)
.send({})
.expect(200);
expect(res.body).toHaveProperty("results");
expect(Array.isArray(res.body.results)).toBe(true);
});

it("response has errors field (array)", async () => {
const res = await request(app).post("/api/admin/update-scores").send({}).expect(200);
const res = await request(app)
.post("/api/admin/update-scores")
.set(AUTH_HEADER)
.send({})
.expect(200);
expect(res.body).toHaveProperty("errors");
expect(Array.isArray(res.body.errors)).toBe(true);
});

it("response shape matches { updated, results, errors }", async () => {
const res = await request(app).post("/api/admin/update-scores").send({}).expect(200);
expect(Object.keys(res.body).sort()).toEqual(["errors", "results", "updated"]);
const res = await request(app)
.post("/api/admin/update-scores")
.set(AUTH_HEADER)
.send({})
.expect(200);
expect(Object.keys(res.body).sort()).toEqual(["errors", "results", "skipped", "updated"]);
});

it("results entries have correct shape", async () => {
const res = await request(app).post("/api/admin/update-scores").send({}).expect(200);
const res = await request(app)
.post("/api/admin/update-scores")
.set(AUTH_HEADER)
.send({})
.expect(200);
for (const entry of res.body.results) {
expect(entry).toHaveProperty("project_id");
expect(entry).toHaveProperty("tx_hash");
Expand All @@ -86,18 +138,25 @@ describe("admin /update-scores response shape", () => {
});

it("errors entries have correct shape", async () => {
(registry.updateImpactScore as jest.Mock)
.mockResolvedValueOnce("tx-hash-1")
.mockRejectedValueOnce(new Error("RPC error"));
(updateScoreForProject as jest.Mock)
.mockResolvedValueOnce({
status: "success",
projectId: 1,
creditQuality: 85,
greenImpact: 70,
txHash: "tx-hash-1",
})
.mockResolvedValueOnce({ status: "error", projectId: 2, error: "RPC error" });
const res = await request(app)
.post("/api/admin/update-scores")
.set(AUTH_HEADER)
.send({ project_ids: [1, 2] })
.expect(200);
expect(res.body.errors).toHaveLength(1);
const entry = res.body.errors[0];
expect(entry).toHaveProperty("project_id");
expect(entry).toHaveProperty("error");
expect(typeof entry.project_id).toBe("number");
expect(typeof entry.error).toBe("string");
expect(typeof entry.error).toBe("object");
});
});
25 changes: 25 additions & 0 deletions src/__tests__/admin-validation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,14 +12,32 @@ jest.mock("../lib/registry", () => ({
updateImpactScore: jest.fn(),
getTotalProjects: jest.fn(),
}));
jest.mock("../lib/apiKeyRoles", () => ({
getApiKeyRole: jest.fn((key: string) => {
if (key === "test-key") return "admin:write";
return undefined;
}),
hasRolePermission: jest.fn((userRole: any, requiredRole: any) => {
if (!userRole) return false;
if (userRole === "admin:write")
return requiredRole === "admin:read" || requiredRole === "admin:write";
return userRole === requiredRole;
}),
}));
jest.mock("../routes/iot");
jest.mock("../lib/scoring");
jest.mock("../lib/scoreService", () => ({
updateScoreForProject: jest.fn(),
resetIdempotencyState: jest.fn(),
}));
jest.mock("../config", () => ({
config: {
ADMIN_API_KEY: "test-key",
},
}));

import { updateScoreForProject } from "../lib/scoreService";

function buildApp(): Express {
const app = express();
app.use(express.json());
Expand Down Expand Up @@ -52,6 +70,13 @@ describe("admin /update-scores input validation", () => {
});
(registry.updateImpactScore as jest.Mock).mockResolvedValue("tx-hash");
(registry.getTotalProjects as jest.Mock).mockResolvedValue(2);
(updateScoreForProject as jest.Mock).mockImplementation(async (projectId: number) => ({
status: "success",
projectId,
creditQuality: 85,
greenImpact: 70,
txHash: "tx-hash",
}));
});

it("returns 400 { error, message } when project_ids is not an array", async () => {
Expand Down
93 changes: 78 additions & 15 deletions src/__tests__/admin.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,11 +21,29 @@ jest.mock("../lib/registry", () => {
});
jest.mock("../routes/iot");
jest.mock("../lib/scoring");
jest.mock("../lib/apiKeyRoles", () => ({
getApiKeyRole: jest.fn((key: string) => {
if (key === "test-key") return "admin:write";
return undefined;
}),
hasRolePermission: jest.fn((userRole: any, requiredRole: any) => {
if (!userRole) return false;
if (userRole === "admin:write")
return requiredRole === "admin:read" || requiredRole === "admin:write";
return userRole === requiredRole;
}),
}));
import { updateScoreForProject } from "../lib/scoreService";

jest.mock("../config", () => ({
config: {
ADMIN_API_KEY: "test-key",
},
}));
jest.mock("../lib/scoreService", () => ({
updateScoreForProject: jest.fn(),
resetIdempotencyState: jest.fn(),
}));

function buildApp(): Express {
const app = express();
Expand Down Expand Up @@ -58,6 +76,13 @@ describe("admin routes", () => {
});
(registry.updateImpactScore as jest.Mock).mockResolvedValue("tx-hash");
(registry.getTotalProjects as jest.Mock).mockResolvedValue(2);
(updateScoreForProject as jest.Mock).mockImplementation(async (projectId: number) => ({
status: "success",
projectId,
creditQuality: 85,
greenImpact: 70,
txHash: "tx-hash",
}));
});

// ── Auth middleware ──────────────────────────────────────────────────────
Expand Down Expand Up @@ -268,13 +293,27 @@ describe("admin routes", () => {
});

it("defers score when RPC is degraded", async () => {
const RpcDegradedError = (
registry as unknown as { RpcDegradedError: new (msg?: string) => Error }
).RpcDegradedError;
(registry.updateImpactScore as jest.Mock)
.mockResolvedValueOnce("tx-1")
.mockRejectedValueOnce(new RpcDegradedError("RPC is degraded"))
.mockResolvedValueOnce("tx-3");
(updateScoreForProject as jest.Mock)
.mockResolvedValueOnce({
status: "success",
projectId: 1,
creditQuality: 85,
greenImpact: 70,
txHash: "tx-1",
})
.mockResolvedValueOnce({
status: "deferred",
projectId: 2,
creditQuality: 85,
greenImpact: 70,
})
.mockResolvedValueOnce({
status: "success",
projectId: 3,
creditQuality: 85,
greenImpact: 70,
txHash: "tx-3",
});

const res = await request(app)
.post("/api/admin/update-scores")
Expand All @@ -293,10 +332,22 @@ describe("admin routes", () => {
});

it("isolates per-project errors without aborting the batch", async () => {
(registry.updateImpactScore as jest.Mock)
.mockResolvedValueOnce("tx-1")
.mockRejectedValueOnce(new Error("RPC timeout"))
.mockResolvedValueOnce("tx-3");
(updateScoreForProject as jest.Mock)
.mockResolvedValueOnce({
status: "success",
projectId: 1,
creditQuality: 85,
greenImpact: 70,
txHash: "tx-1",
})
.mockResolvedValueOnce({ status: "error", projectId: 2, error: "RPC timeout" })
.mockResolvedValueOnce({
status: "success",
projectId: 3,
creditQuality: 85,
greenImpact: 70,
txHash: "tx-3",
});

const res = await request(app)
.post("/api/admin/update-scores")
Expand All @@ -314,10 +365,22 @@ describe("admin routes", () => {
});

it("isolates a single failing project: only the failing id appears in errors, the rest in results", async () => {
(registry.updateImpactScore as jest.Mock)
.mockResolvedValueOnce("tx-1")
.mockRejectedValueOnce(new Error("project 2 blew up"))
.mockResolvedValueOnce("tx-3");
(updateScoreForProject as jest.Mock)
.mockResolvedValueOnce({
status: "success",
projectId: 1,
creditQuality: 85,
greenImpact: 70,
txHash: "tx-1",
})
.mockResolvedValueOnce({ status: "error", projectId: 2, error: "project 2 blew up" })
.mockResolvedValueOnce({
status: "success",
projectId: 3,
creditQuality: 85,
greenImpact: 70,
txHash: "tx-3",
});

const res = await request(app)
.post("/api/admin/update-scores")
Expand Down
Loading
Loading