Skip to content
Open
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
3 changes: 3 additions & 0 deletions .github/workflows/deploy-staging.yml
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,9 @@ jobs:
ghcr.io/${{ env.OWNER_LC }}/remitlend-backend:staging-${{ github.sha }}
ghcr.io/${{ env.OWNER_LC }}/remitlend-backend:staging-latest
target: production
build-args: |
GIT_SHA=${{ github.sha }}
BUILD_TIME=${{ github.event.head_commit.timestamp }}

- name: Build and push frontend
uses: docker/build-push-action@v4
Expand Down
8 changes: 8 additions & 0 deletions backend/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,14 @@ FROM node:22-alpine AS production

WORKDIR /app

# Build-time metadata injected by the CI pipeline via --build-arg.
# These are baked into the image so GET /version works without any
# runtime environment variable configuration.
ARG GIT_SHA=""
ARG BUILD_TIME=""
ENV GIT_SHA=$GIT_SHA \
BUILD_TIME=$BUILD_TIME

# Install only production dependencies
COPY package*.json ./
RUN npm ci --omit=dev
Expand Down
119 changes: 119 additions & 0 deletions backend/src/__tests__/version.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
import { jest } from "@jest/globals";
import request from "supertest";

// Must mock all app-level dependencies before importing app.

jest.unstable_mockModule("../db/connection.js", () => ({
default: {
query: jest
.fn<() => Promise<any>>()
.mockResolvedValue({ rows: [], rowCount: 0 }),
},
query: jest
.fn<() => Promise<any>>()
.mockResolvedValue({ rows: [], rowCount: 0 }),
getClient: jest.fn(),
withTransaction: jest.fn(),
}));

jest.unstable_mockModule("../services/cacheService.js", () => ({
cacheService: {
ping: jest.fn<() => Promise<string>>().mockResolvedValue("ok"),
},
}));

jest.unstable_mockModule("../services/sorobanService.js", () => ({
sorobanService: {
ping: jest.fn<() => Promise<string>>().mockResolvedValue("ok"),
},
}));

const { default: app } = await import("../app.js");

describe("GET /version", () => {
const savedEnv: Record<string, string | undefined> = {};

beforeEach(() => {
// Snapshot and clear the build-time env vars so each test starts clean.
for (const key of [
"GIT_SHA",
"BUILD_TIME",
"LOAN_MANAGER_CONTRACT_ID",
"LENDING_POOL_CONTRACT_ID",
"REMITTANCE_NFT_CONTRACT_ID",
"MULTISIG_GOVERNANCE_CONTRACT_ID",
]) {
savedEnv[key] = process.env[key];
delete process.env[key];
}
});

afterEach(() => {
// Restore original env.
for (const [key, value] of Object.entries(savedEnv)) {
if (value === undefined) {
delete process.env[key];
} else {
process.env[key] = value;
}
}
});

it("returns 200", async () => {
const res = await request(app).get("/version");
expect(res.status).toBe(200);
});

it("response shape contains all required fields", async () => {
const res = await request(app).get("/version");
expect(res.body).toHaveProperty("gitSha");
expect(res.body).toHaveProperty("builtAt");
expect(res.body).toHaveProperty("nodeVersion");
expect(res.body).toHaveProperty("contracts");
expect(res.body.contracts).toHaveProperty("loanManager");
expect(res.body.contracts).toHaveProperty("lendingPool");
expect(res.body.contracts).toHaveProperty("remittanceNft");
expect(res.body.contracts).toHaveProperty("multisigGovernance");
});

it("falls back to 'unknown' when GIT_SHA and BUILD_TIME are not set", async () => {
const res = await request(app).get("/version");
expect(res.body.gitSha).toBe("unknown");
expect(res.body.builtAt).toBe("unknown");
});

it("reflects GIT_SHA and BUILD_TIME env vars when set", async () => {
process.env.GIT_SHA = "abc1234def5678";
process.env.BUILD_TIME = "2025-06-01T12:00:00Z";

const res = await request(app).get("/version");
expect(res.body.gitSha).toBe("abc1234def5678");
expect(res.body.builtAt).toBe("2025-06-01T12:00:00Z");
});

it("reflects contract IDs from environment variables", async () => {
process.env.LOAN_MANAGER_CONTRACT_ID = "CLOAN";
process.env.LENDING_POOL_CONTRACT_ID = "CPOOL";
process.env.REMITTANCE_NFT_CONTRACT_ID = "CNFT";
process.env.MULTISIG_GOVERNANCE_CONTRACT_ID = "CGOV";

const res = await request(app).get("/version");
expect(res.body.contracts.loanManager).toBe("CLOAN");
expect(res.body.contracts.lendingPool).toBe("CPOOL");
expect(res.body.contracts.remittanceNft).toBe("CNFT");
expect(res.body.contracts.multisigGovernance).toBe("CGOV");
});

it("contract IDs fall back to 'unknown' when env vars are absent", async () => {
const res = await request(app).get("/version");
expect(res.body.contracts.loanManager).toBe("unknown");
expect(res.body.contracts.lendingPool).toBe("unknown");
expect(res.body.contracts.remittanceNft).toBe("unknown");
expect(res.body.contracts.multisigGovernance).toBe("unknown");
});

it("nodeVersion matches the running Node.js process", async () => {
const res = await request(app).get("/version");
expect(res.body.nodeVersion).toBe(process.version);
});
});
25 changes: 25 additions & 0 deletions backend/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@
import { errorHandler } from "./middleware/errorHandler.js";
import { requestLogger } from "./middleware/requestLogger.js";
import { requestIdMiddleware } from "./middleware/requestId.js";
import { asyncHandler } from "./utils/asyncHandler.js";

Check failure on line 31 in backend/src/app.ts

View workflow job for this annotation

GitHub Actions / backend

Replace `⏎··metricsHandler,⏎··metricsMiddleware,⏎` with `·metricsHandler,·metricsMiddleware·`
import { AppError } from "./errors/AppError.js";
const app = express();

Expand Down Expand Up @@ -120,9 +120,34 @@
res.send("RemitLend Backend is running");
});

/**
* GET /version
*
* Read-only endpoint for operators and runbooks.
* Returns build metadata and on-chain contract IDs so that the exact
* backend version deployed can be determined without shelling into the container.
*
* Environment variables (injected at Docker build time via ARG/ENV):
* GIT_SHA — full git commit SHA of the build (falls back to "unknown")
* BUILD_TIME — ISO-8601 UTC timestamp of the build (falls back to "unknown")
*/
app.get("/version", (_req: Request, res: Response) => {
res.json({
gitSha: process.env.GIT_SHA ?? "unknown",
builtAt: process.env.BUILD_TIME ?? "unknown",
nodeVersion: process.version,
contracts: {
loanManager: process.env.LOAN_MANAGER_CONTRACT_ID ?? "unknown",
lendingPool: process.env.LENDING_POOL_CONTRACT_ID ?? "unknown",
remittanceNft: process.env.REMITTANCE_NFT_CONTRACT_ID ?? "unknown",
multisigGovernance: process.env.MULTISIG_GOVERNANCE_CONTRACT_ID ?? "unknown",
},
});
});

app.get(
"/health",
asyncHandler(async (_req: Request, res: Response) => {

Check failure on line 150 in backend/src/app.ts

View workflow job for this annotation

GitHub Actions / backend

Insert `⏎·······`
const [databaseStatus, redisStatus, sorobanStatus] =
await Promise.allSettled([
pool
Expand Down
Loading