diff --git a/.github/workflows/deploy-staging.yml b/.github/workflows/deploy-staging.yml index 87f95e91..e7e03507 100644 --- a/.github/workflows/deploy-staging.yml +++ b/.github/workflows/deploy-staging.yml @@ -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 diff --git a/backend/Dockerfile b/backend/Dockerfile index 8cb72846..08d75989 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -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 diff --git a/backend/src/__tests__/version.test.ts b/backend/src/__tests__/version.test.ts new file mode 100644 index 00000000..15dfd313 --- /dev/null +++ b/backend/src/__tests__/version.test.ts @@ -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>() + .mockResolvedValue({ rows: [], rowCount: 0 }), + }, + query: jest + .fn<() => Promise>() + .mockResolvedValue({ rows: [], rowCount: 0 }), + getClient: jest.fn(), + withTransaction: jest.fn(), +})); + +jest.unstable_mockModule("../services/cacheService.js", () => ({ + cacheService: { + ping: jest.fn<() => Promise>().mockResolvedValue("ok"), + }, +})); + +jest.unstable_mockModule("../services/sorobanService.js", () => ({ + sorobanService: { + ping: jest.fn<() => Promise>().mockResolvedValue("ok"), + }, +})); + +const { default: app } = await import("../app.js"); + +describe("GET /version", () => { + const savedEnv: Record = {}; + + 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); + }); +}); diff --git a/backend/src/app.ts b/backend/src/app.ts index 4a4a8180..52694d91 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -120,6 +120,31 @@ app.get("/", (req: Request, res: Response) => { 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) => {