Skip to content
Merged
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
73 changes: 73 additions & 0 deletions .github/workflows/analysis.yml
Original file line number Diff line number Diff line change
Expand Up @@ -42,3 +42,76 @@ jobs:
uses: github/codeql-action/upload-sarif@v4
with:
sarif_file: "trivy-results.sarif"

api-tests:
name: API Tests
runs-on: ubuntu-26.04
timeout-minutes: 10
permissions:
contents: read
services:
mongo:
image: ghcr.io/bcgov/nr-containers/mongo:7.0.43
options: >-
--health-cmd "mongosh --quiet --eval 'db.adminCommand({ ping: 1 })'"
--health-interval 10s
--health-timeout 5s
--health-retries 5
ports:
- 27017:27017
defaults:
run:
working-directory: api
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v7
with:
node-version: "24.x"
- run: npm ci
- run: npm test
env:
MONGO_URI: mongodb://localhost:27017/pubcode-test

unit-tests:
name: Unit Tests (${{ matrix.dir }})
runs-on: ubuntu-26.04
timeout-minutes: 10
permissions:
contents: read
strategy:
fail-fast: false
matrix:
dir: [crawler, schema/script, utilities/remove-deleted-pubcode]
defaults:
run:
working-directory: ${{ matrix.dir }}
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v7
with:
node-version: "24.x"
- run: npm ci
- run: npm test

# This job is the merge gate for this workflow. Every job in this
# workflow must be listed in 'needs', or its failure won't block the PR.
results:
name: Analysis Results
needs: [trivy, api-tests, unit-tests]
if: always()
runs-on: ubuntu-26.04
timeout-minutes: 1
steps:
- name: Log Job Results Context
run: |
echo "=== Upstream Job Statuses ==="
echo '${{ toJson(needs) }}'

- name: Evaluate Overall Status
if: contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled')
run: |
echo "At least one required job failed or was cancelled."
exit 1

- name: Success Message
run: echo "All required jobs passed or were intentionally skipped."
36 changes: 16 additions & 20 deletions .github/workflows/pr-open.yml
Original file line number Diff line number Diff line change
Expand Up @@ -76,29 +76,25 @@ jobs:
with:
target: ${{ github.event.number }}

utility-tests:
name: Utility Tests
runs-on: ubuntu-26.04
permissions:
contents: read
defaults:
run:
working-directory: utilities/remove-deleted-pubcode
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v7
with:
node-version: "24.x"
- run: npm ci
- run: npm test

# This job is the merge gate for this workflow. Every job in this
# workflow must be listed in 'needs', or its failure won't block the PR.
results:
name: Results
name: PR Results
needs: [builds, deploys, tests]
if: always()
needs: [builds, deploys, tests, utility-tests]
runs-on: ubuntu-26.04
timeout-minutes: 1
steps:
- name: Fail if any dependent job failed or was cancelled
- name: Log Job Results Context
run: |
echo "=== Upstream Job Statuses ==="
echo '${{ toJson(needs) }}'

- name: Evaluate Overall Status
if: contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled')
run: exit 1
run: |
echo "At least one required job failed or was cancelled."
exit 1

- name: Success Message
run: echo "All required jobs passed or were intentionally skipped."
2 changes: 1 addition & 1 deletion .github/workflows/pr-validate.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ permissions: {}

jobs:
validate:
name: Validate PR
name: Validate Results
if: (! github.event.pull_request.draft)
runs-on: ubuntu-26.04
permissions:
Expand Down
4 changes: 3 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,13 @@ Repository facts for automated coding assistants. Teams may edit or remove this
## Layout
- `api/` (Express, Mongoose), `frontend/` (Vite, React), `crawler/` (GitHub crawl of `bcgovpubcode.yml`), `database/` (MongoDB image), `schema/` (JSON Schema)
- Helm chart: `charts/pubcode` (`charts/pubcode/values.yaml`)
- Workflows: `.github/workflows/` (PR: `pr-open.yml`, `pr-close.yml`, `pr-validate.yml`; merge: `merge.yml`; crawler and schema jobs: `scheduled.yml`; reusable: `.deploy.yml`, `.tests.yml`)
- Workflows: `.github/workflows/` (PR: `pr-open.yml`, `analysis.yml`, `pr-close.yml`, `pr-validate.yml`; merge: `merge.yml`; crawler and schema jobs: `scheduled.yml`; reusable: `.deploy.yml`, `.tests.yml`)

## Build, test, deploy
- Local stack: `docker compose up` (MongoDB, API on port 3005, Caddy frontend on port 3002). Caddy serves `frontend/dist`, so run `cd frontend && npm ci && npm run build` first. API: `cd api && npm ci && npm run dev`. Crawler: `cd crawler && npm ci && npm run dev`
- Frontend (`cd frontend`, Node 24): `npm run dev` (Vite port 3001 in `frontend/vite.config.js`), `npm run build`, `npm run lint`. E2E: `npx cypress run --config baseUrl=http://localhost:3001` with `npm run dev` up. `frontend/cypress.config.js` sets `baseUrl` to `http://localhost:5173`. CI (`.github/workflows/.tests.yml`) overrides `baseUrl` to the deployed route
- Tests (`node --test`): `npm test` in `crawler/`, `schema/script/`, `utilities/remove-deleted-pubcode/` and `api/`. The API tests need MongoDB, e.g. `MONGO_URI=mongodb://localhost:27017/pubcode-test npm test`. CI runs them in `analysis.yml`
- Merge gates: `PR Results` (`pr-open.yml`) and `Analysis Results` (`analysis.yml`) fail if any job in their workflow failed or was cancelled, so add new jobs to their `needs`. `Validate Results` is the only job in `pr-validate.yml`
- Images `api`, `database`, and `frontend` are built in GitHub Actions by `bcgov/actions/builder` (`pr-open.yml`). OpenShift Helm deploys run from GitHub Actions (`pr-open.yml` for pull requests; `merge.yml` deploys test, then prod), not a workstation

## Shared actions
Expand Down
3 changes: 2 additions & 1 deletion api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,8 @@
},
"scripts": {
"start": "node src/server.js",
"dev": "nodemon src/server.js"
"dev": "nodemon src/server.js",
"test": "node --test"
},
"author": "Public Code API",
"license": "Apache-2.0",
Expand Down
145 changes: 145 additions & 0 deletions api/test/pub-code-api.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
"use strict";
// Integration tests: the API against a real MongoDB given by MONGO_URI.
const { test, before, after, beforeEach } = require("node:test");
const assert = require("node:assert/strict");

const MONGO_URI = process.env.MONGO_URI;
if (!MONGO_URI) {
throw new Error("MONGO_URI is required, e.g. mongodb://localhost:27017/pubcode-test");
}
process.env.API_KEY = "test-api-key";
process.env.LOG_LEVEL = "error";
// The email service refuses to load without configuration; no email is sent while EMAIL_RECIPIENTS is unset.
process.env.CHES_TOKEN_URL = "http://localhost/token";
process.env.CHES_CLIENT_ID = "test";
process.env.CHES_CLIENT_SECRET = "test";
process.env.CHES_API_URL = "http://localhost/ches";
delete process.env.EMAIL_RECIPIENTS;

const mongoose = require("mongoose");
const app = require("../src/app");
const pubcodeEntity = require("../src/entities/pub-code-entity");
const cacheService = require("../src/services/cache-service");

let server;
let baseUrl;

const pubcode = (repoName, overrides = {}) => ({
repo_name: repoName,
version: 1,
product_information: { product_name: `Product ${repoName}`, ministry: ["Forests"] },
data_management_roles: { product_owner: "Owner" },
product_technology_information: { backend_language: ["JavaScript"] },
github_info: { default_branch: "main", stars: 1 },
...overrides,
});

function request(method, path, { body, apiKey } = {}) {
const headers = { "Content-Type": "application/json" };
if (apiKey) {
headers["X-API-KEY"] = apiKey;
}
return fetch(`${baseUrl}${path}`, {
method,
headers,
body: body === undefined ? undefined : JSON.stringify(body),
});
}

// bulk-load answers before it has written to the database, so wait for the write.
async function waitFor(condition, timeoutMs = 5000) {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
if (await condition()) {
return;
}
await new Promise((resolve) => setTimeout(resolve, 50));
}
throw new Error("Timed out waiting for the database");
}

before(async () => {
await mongoose.connect(MONGO_URI, { serverSelectionTimeoutMS: 10000 });
await new Promise((resolve) => {
server = app.listen(0, resolve);
});
baseUrl = `http://127.0.0.1:${server.address().port}`;
});

after(async () => {
await new Promise((resolve) => server.close(resolve));
await mongoose.connection.dropDatabase();
await mongoose.disconnect();
});

beforeEach(async () => {
await pubcodeEntity.deleteMany({});
});

test("bulk-load and delete reject requests without the API key", async () => {
assert.equal((await request("POST", "/api/pub-code/bulk-load", { body: [pubcode("a")] })).status, 401);
assert.equal((await request("POST", "/api/pub-code/bulk-load", { body: [pubcode("a")], apiKey: "wrong" })).status, 401);
assert.equal((await request("DELETE", "/api/pub-code/a")).status, 401);
assert.equal(await pubcodeEntity.countDocuments(), 0);
});

test("bulk-load rejects a body that is not a non-empty array", async () => {
for (const body of [[], { repo_name: "a" }]) {
const response = await request("POST", "/api/pub-code/bulk-load", { body, apiKey: "test-api-key" });
assert.equal(response.status, 400);
}
});

test("bulk-load stores valid entries, skips invalid ones, and the list flattens them", async () => {
const invalid = pubcode("invalid");
delete invalid.product_information;

const response = await request("POST", "/api/pub-code/bulk-load", {
body: [pubcode("repo-a"), invalid],
apiKey: "test-api-key",
});
assert.equal(response.status, 200);
await waitFor(async () => (await pubcodeEntity.countDocuments({ repo_name: "repo-a" })) === 1);
assert.equal(await pubcodeEntity.countDocuments({ repo_name: "invalid" }), 0);

await cacheService.loadAllPubCodes();
const list = await (await request("GET", "/api/pub-code")).json();
assert.deepEqual(list, [
{
repo_name: "repo-a",
product_name: "Product repo-a",
ministry: ["Forests"],
backend_language: ["JavaScript"],
product_owner: "Owner",
default_branch: "main",
stars: 1,
},
]);
});

test("bulk-load replaces an existing entry instead of duplicating it", async () => {
await pubcodeEntity.create(pubcode("repo-a"));

await request("POST", "/api/pub-code/bulk-load", {
body: [pubcode("repo-a", { product_information: { product_name: "Renamed" } })],
apiKey: "test-api-key",
});

await waitFor(async () => (await pubcodeEntity.findOne({ repo_name: "repo-a" }))?.product_information?.product_name === "Renamed");
assert.equal(await pubcodeEntity.countDocuments({ repo_name: "repo-a" }), 1);
});

test("delete soft-deletes an entry, hides it from the list, and a later bulk-load restores it", async () => {
await pubcodeEntity.create([pubcode("repo-a"), pubcode("repo-b")]);

assert.equal((await request("DELETE", "/api/pub-code/missing", { apiKey: "test-api-key" })).status, 404);
assert.equal((await request("DELETE", "/api/pub-code/repo-a", { apiKey: "test-api-key" })).status, 200);
assert.equal((await pubcodeEntity.findOne({ repo_name: "repo-a" })).is_deleted, true);

await cacheService.loadAllPubCodes();
const visible = (await (await request("GET", "/api/pub-code")).json()).map((entry) => entry.repo_name);
assert.deepEqual(visible, ["repo-b"]);

await request("POST", "/api/pub-code/bulk-load", { body: [pubcode("repo-a")], apiKey: "test-api-key" });
await waitFor(async () => (await pubcodeEntity.findOne({ repo_name: "repo-a" }))?.is_deleted === false);
});
3 changes: 2 additions & 1 deletion crawler/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "nodemon --watch src ./src/main.js"
"dev": "nodemon --watch src ./src/main.js",
"test": "node --test"
},
"dependencies": {
"async-retry": "^1.3.3",
Expand Down
Loading
Loading