periapsis 1.0.3 → 1.0.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/workflows/periapsis-license-check.yml +23 -3
- package/.github/workflows/prepare-package-release.yml +168 -0
- package/.github/workflows/publish-package.yml +133 -0
- package/README.md +16 -0
- package/bin/periapsis.mjs +12 -14
- package/docs/testing-strategy.md +113 -0
- package/package.json +6 -2
- package/test/cli.test.mjs +13 -50
- package/test/commands.test.mjs +98 -0
- package/test/regression-gate.test.mjs +188 -0
- package/testing/helpers.mjs +121 -0
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
name: Periapsis
|
|
1
|
+
name: Periapsis PR Gate
|
|
2
2
|
|
|
3
3
|
on:
|
|
4
4
|
workflow_dispatch:
|
|
@@ -9,9 +9,29 @@ permissions:
|
|
|
9
9
|
contents: read
|
|
10
10
|
|
|
11
11
|
jobs:
|
|
12
|
-
|
|
12
|
+
test-suite:
|
|
13
13
|
if: github.event_name != 'pull_request' || github.event.pull_request.draft == false
|
|
14
14
|
runs-on: ubuntu-latest
|
|
15
|
+
steps:
|
|
16
|
+
- name: Checkout
|
|
17
|
+
uses: actions/checkout@v4
|
|
18
|
+
|
|
19
|
+
- name: Setup Node
|
|
20
|
+
uses: actions/setup-node@v4
|
|
21
|
+
with:
|
|
22
|
+
node-version: 20
|
|
23
|
+
cache: npm
|
|
24
|
+
|
|
25
|
+
- name: Install dependencies
|
|
26
|
+
run: npm ci
|
|
27
|
+
|
|
28
|
+
- name: Run automated test suite
|
|
29
|
+
run: npm run test:ci
|
|
30
|
+
|
|
31
|
+
policy-gate:
|
|
32
|
+
if: github.event_name != 'pull_request' || github.event.pull_request.draft == false
|
|
33
|
+
needs: test-suite
|
|
34
|
+
runs-on: ubuntu-latest
|
|
15
35
|
steps:
|
|
16
36
|
- name: Checkout
|
|
17
37
|
uses: actions/checkout@v4
|
|
@@ -28,7 +48,7 @@ jobs:
|
|
|
28
48
|
- name: Run Periapsis policy check
|
|
29
49
|
id: periapsis
|
|
30
50
|
continue-on-error: true
|
|
31
|
-
run:
|
|
51
|
+
run: npm run policy:check
|
|
32
52
|
|
|
33
53
|
- name: Enforce zero violations
|
|
34
54
|
if: always()
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
name: Prepare Package Release
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
workflow_dispatch:
|
|
5
|
+
inputs:
|
|
6
|
+
release_type:
|
|
7
|
+
description: Semver increment to prepare
|
|
8
|
+
required: true
|
|
9
|
+
type: choice
|
|
10
|
+
default: patch
|
|
11
|
+
options:
|
|
12
|
+
- patch
|
|
13
|
+
- minor
|
|
14
|
+
- major
|
|
15
|
+
|
|
16
|
+
permissions:
|
|
17
|
+
contents: read
|
|
18
|
+
|
|
19
|
+
concurrency:
|
|
20
|
+
group: prepare-package-release
|
|
21
|
+
cancel-in-progress: false
|
|
22
|
+
|
|
23
|
+
jobs:
|
|
24
|
+
authorize-release:
|
|
25
|
+
name: Authorize Release
|
|
26
|
+
runs-on: ubuntu-latest
|
|
27
|
+
steps:
|
|
28
|
+
- name: Require main branch
|
|
29
|
+
run: |
|
|
30
|
+
if [ "${GITHUB_REF_NAME}" != "main" ]; then
|
|
31
|
+
echo "Manual release preparation is only allowed from main."
|
|
32
|
+
exit 1
|
|
33
|
+
fi
|
|
34
|
+
|
|
35
|
+
- name: Require repository admin permission
|
|
36
|
+
uses: actions/github-script@v7
|
|
37
|
+
with:
|
|
38
|
+
script: |
|
|
39
|
+
const owner = context.repo.owner;
|
|
40
|
+
const repo = context.repo.repo;
|
|
41
|
+
const actor = context.actor;
|
|
42
|
+
|
|
43
|
+
if (owner.toLowerCase() === actor.toLowerCase()) {
|
|
44
|
+
core.info(`Actor ${actor} matches the repository owner and is treated as admin.`);
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const response = await github.rest.repos.getCollaboratorPermissionLevel({
|
|
49
|
+
owner,
|
|
50
|
+
repo,
|
|
51
|
+
username: actor,
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
const { permission, role_name: roleName } = response.data;
|
|
55
|
+
core.info(`Actor ${actor} permission=${permission}, role_name=${roleName}`);
|
|
56
|
+
|
|
57
|
+
if (permission !== "admin" && roleName !== "admin") {
|
|
58
|
+
core.setFailed(`Only repository admins can prepare package releases. ${actor} has ${roleName || permission}.`);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
prepare-release:
|
|
62
|
+
name: Prepare Release Pull Request
|
|
63
|
+
needs: authorize-release
|
|
64
|
+
runs-on: ubuntu-latest
|
|
65
|
+
permissions:
|
|
66
|
+
# Required because this workflow pushes a release branch and opens a PR.
|
|
67
|
+
contents: write
|
|
68
|
+
pull-requests: write
|
|
69
|
+
steps:
|
|
70
|
+
- name: Checkout
|
|
71
|
+
uses: actions/checkout@v4
|
|
72
|
+
with:
|
|
73
|
+
fetch-depth: 0
|
|
74
|
+
|
|
75
|
+
- name: Setup Node
|
|
76
|
+
uses: actions/setup-node@v4
|
|
77
|
+
with:
|
|
78
|
+
node-version: 24
|
|
79
|
+
cache: npm
|
|
80
|
+
|
|
81
|
+
- name: Install dependencies
|
|
82
|
+
run: npm ci
|
|
83
|
+
|
|
84
|
+
- name: Run automated test suite
|
|
85
|
+
run: npm run test:ci
|
|
86
|
+
|
|
87
|
+
- name: Bump package version
|
|
88
|
+
run: npm version "${{ inputs.release_type }}" --no-git-tag-version
|
|
89
|
+
|
|
90
|
+
- name: Read new version
|
|
91
|
+
id: version
|
|
92
|
+
run: |
|
|
93
|
+
VERSION="$(node -e 'process.stdout.write(require("./package.json").version)')"
|
|
94
|
+
echo "value=${VERSION}" >> "$GITHUB_OUTPUT"
|
|
95
|
+
echo "branch=release/v${VERSION}" >> "$GITHUB_OUTPUT"
|
|
96
|
+
|
|
97
|
+
- name: Ensure release branch is not already open
|
|
98
|
+
env:
|
|
99
|
+
RELEASE_BRANCH: ${{ steps.version.outputs.branch }}
|
|
100
|
+
run: |
|
|
101
|
+
if git ls-remote --exit-code --heads origin "${RELEASE_BRANCH}" >/dev/null 2>&1; then
|
|
102
|
+
echo "Release branch ${RELEASE_BRANCH} already exists on origin."
|
|
103
|
+
exit 1
|
|
104
|
+
fi
|
|
105
|
+
|
|
106
|
+
- name: Create release branch
|
|
107
|
+
env:
|
|
108
|
+
RELEASE_BRANCH: ${{ steps.version.outputs.branch }}
|
|
109
|
+
run: git switch -c "${RELEASE_BRANCH}"
|
|
110
|
+
|
|
111
|
+
- name: Configure git author
|
|
112
|
+
run: |
|
|
113
|
+
git config user.name "github-actions[bot]"
|
|
114
|
+
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
|
115
|
+
|
|
116
|
+
- name: Commit release version
|
|
117
|
+
env:
|
|
118
|
+
VERSION: ${{ steps.version.outputs.value }}
|
|
119
|
+
run: |
|
|
120
|
+
git add package.json package-lock.json
|
|
121
|
+
git commit -m "chore(release): v${VERSION}"
|
|
122
|
+
|
|
123
|
+
- name: Push release branch
|
|
124
|
+
env:
|
|
125
|
+
RELEASE_BRANCH: ${{ steps.version.outputs.branch }}
|
|
126
|
+
run: git push --set-upstream origin "${RELEASE_BRANCH}"
|
|
127
|
+
|
|
128
|
+
- name: Open release pull request
|
|
129
|
+
id: pull-request
|
|
130
|
+
uses: actions/github-script@v7
|
|
131
|
+
env:
|
|
132
|
+
RELEASE_BRANCH: ${{ steps.version.outputs.branch }}
|
|
133
|
+
VERSION: ${{ steps.version.outputs.value }}
|
|
134
|
+
with:
|
|
135
|
+
script: |
|
|
136
|
+
const owner = context.repo.owner;
|
|
137
|
+
const repo = context.repo.repo;
|
|
138
|
+
const head = process.env.RELEASE_BRANCH;
|
|
139
|
+
const version = process.env.VERSION;
|
|
140
|
+
|
|
141
|
+
const pr = await github.rest.pulls.create({
|
|
142
|
+
owner,
|
|
143
|
+
repo,
|
|
144
|
+
title: `chore(release): v${version}`,
|
|
145
|
+
head,
|
|
146
|
+
base: "main",
|
|
147
|
+
body: [
|
|
148
|
+
`Prepares \`v${version}\` for npm publication.`,
|
|
149
|
+
"",
|
|
150
|
+
"After this pull request merges into `main`, the publish workflow will release the package to npm.",
|
|
151
|
+
].join("\n"),
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
core.setOutput("url", pr.data.html_url);
|
|
155
|
+
|
|
156
|
+
- name: Write job summary
|
|
157
|
+
env:
|
|
158
|
+
VERSION: ${{ steps.version.outputs.value }}
|
|
159
|
+
RELEASE_BRANCH: ${{ steps.version.outputs.branch }}
|
|
160
|
+
PR_URL: ${{ steps.pull-request.outputs.url }}
|
|
161
|
+
run: |
|
|
162
|
+
{
|
|
163
|
+
echo "## Release PR created"
|
|
164
|
+
echo
|
|
165
|
+
echo "- Version: \`${VERSION}\`"
|
|
166
|
+
echo "- Release branch: \`${RELEASE_BRANCH}\`"
|
|
167
|
+
echo "- Pull request: ${PR_URL}"
|
|
168
|
+
} >> "$GITHUB_STEP_SUMMARY"
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
name: Publish Package
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches:
|
|
6
|
+
- main
|
|
7
|
+
paths:
|
|
8
|
+
- package.json
|
|
9
|
+
- package-lock.json
|
|
10
|
+
workflow_dispatch:
|
|
11
|
+
|
|
12
|
+
permissions:
|
|
13
|
+
contents: write
|
|
14
|
+
id-token: write
|
|
15
|
+
|
|
16
|
+
concurrency:
|
|
17
|
+
group: publish-package
|
|
18
|
+
cancel-in-progress: false
|
|
19
|
+
|
|
20
|
+
jobs:
|
|
21
|
+
publish-package:
|
|
22
|
+
name: Publish Package
|
|
23
|
+
runs-on: ubuntu-latest
|
|
24
|
+
environment:
|
|
25
|
+
# Use the same environment name in npm trusted publisher settings if you
|
|
26
|
+
# want npm to bind publishing to this protected environment.
|
|
27
|
+
name: package-publish
|
|
28
|
+
steps:
|
|
29
|
+
- name: Checkout
|
|
30
|
+
uses: actions/checkout@v4
|
|
31
|
+
with:
|
|
32
|
+
fetch-depth: 0
|
|
33
|
+
|
|
34
|
+
- name: Detect release version
|
|
35
|
+
id: release
|
|
36
|
+
env:
|
|
37
|
+
BEFORE_SHA: ${{ github.event.before }}
|
|
38
|
+
EVENT_NAME: ${{ github.event_name }}
|
|
39
|
+
REF_NAME: ${{ github.ref_name }}
|
|
40
|
+
run: |
|
|
41
|
+
VERSION="$(node -e 'process.stdout.write(require("./package.json").version)')"
|
|
42
|
+
PACKAGE_NAME="$(node -e 'process.stdout.write(require("./package.json").name)')"
|
|
43
|
+
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
|
|
44
|
+
echo "package_name=${PACKAGE_NAME}" >> "$GITHUB_OUTPUT"
|
|
45
|
+
|
|
46
|
+
if [ "${EVENT_NAME}" = "workflow_dispatch" ]; then
|
|
47
|
+
if [ "${REF_NAME}" != "main" ]; then
|
|
48
|
+
echo "Manual publishing is only allowed from main."
|
|
49
|
+
exit 1
|
|
50
|
+
fi
|
|
51
|
+
|
|
52
|
+
echo "should_publish=true" >> "$GITHUB_OUTPUT"
|
|
53
|
+
exit 0
|
|
54
|
+
fi
|
|
55
|
+
|
|
56
|
+
if [ -z "${BEFORE_SHA}" ] || [ "${BEFORE_SHA}" = "0000000000000000000000000000000000000000" ]; then
|
|
57
|
+
echo "should_publish=true" >> "$GITHUB_OUTPUT"
|
|
58
|
+
exit 0
|
|
59
|
+
fi
|
|
60
|
+
|
|
61
|
+
PREVIOUS_VERSION="$(git show "${BEFORE_SHA}:package.json" | node -e 'let data=""; process.stdin.on("data", chunk => data += chunk); process.stdin.on("end", () => process.stdout.write(JSON.parse(data).version));')"
|
|
62
|
+
|
|
63
|
+
if [ "${PREVIOUS_VERSION}" = "${VERSION}" ]; then
|
|
64
|
+
echo "Version is unchanged at ${VERSION}; skipping publish."
|
|
65
|
+
echo "should_publish=false" >> "$GITHUB_OUTPUT"
|
|
66
|
+
else
|
|
67
|
+
echo "Version changed from ${PREVIOUS_VERSION} to ${VERSION}."
|
|
68
|
+
echo "should_publish=true" >> "$GITHUB_OUTPUT"
|
|
69
|
+
fi
|
|
70
|
+
|
|
71
|
+
- name: Setup Node
|
|
72
|
+
if: steps.release.outputs.should_publish == 'true'
|
|
73
|
+
uses: actions/setup-node@v4
|
|
74
|
+
with:
|
|
75
|
+
node-version: 24
|
|
76
|
+
cache: npm
|
|
77
|
+
registry-url: https://registry.npmjs.org
|
|
78
|
+
|
|
79
|
+
- name: Install dependencies
|
|
80
|
+
if: steps.release.outputs.should_publish == 'true'
|
|
81
|
+
run: npm ci
|
|
82
|
+
|
|
83
|
+
- name: Run automated test suite
|
|
84
|
+
if: steps.release.outputs.should_publish == 'true'
|
|
85
|
+
run: npm run test:ci
|
|
86
|
+
|
|
87
|
+
- name: Check whether version already exists on npm
|
|
88
|
+
if: steps.release.outputs.should_publish == 'true'
|
|
89
|
+
id: npm-check
|
|
90
|
+
env:
|
|
91
|
+
PACKAGE_NAME: ${{ steps.release.outputs.package_name }}
|
|
92
|
+
VERSION: ${{ steps.release.outputs.version }}
|
|
93
|
+
run: |
|
|
94
|
+
if npm view "${PACKAGE_NAME}@${VERSION}" version >/dev/null 2>&1; then
|
|
95
|
+
echo "should_publish=false" >> "$GITHUB_OUTPUT"
|
|
96
|
+
echo "Version ${VERSION} is already published to npm."
|
|
97
|
+
else
|
|
98
|
+
echo "should_publish=true" >> "$GITHUB_OUTPUT"
|
|
99
|
+
echo "Version ${VERSION} is not yet published to npm."
|
|
100
|
+
fi
|
|
101
|
+
|
|
102
|
+
- name: Publish to npm with trusted publishing
|
|
103
|
+
if: steps.release.outputs.should_publish == 'true' && steps.npm-check.outputs.should_publish == 'true'
|
|
104
|
+
run: npm publish
|
|
105
|
+
|
|
106
|
+
- name: Tag published version
|
|
107
|
+
if: steps.release.outputs.should_publish == 'true' && steps.npm-check.outputs.should_publish == 'true'
|
|
108
|
+
env:
|
|
109
|
+
VERSION: ${{ steps.release.outputs.version }}
|
|
110
|
+
run: |
|
|
111
|
+
if git rev-parse "v${VERSION}" >/dev/null 2>&1; then
|
|
112
|
+
echo "Tag v${VERSION} already exists locally."
|
|
113
|
+
exit 0
|
|
114
|
+
fi
|
|
115
|
+
|
|
116
|
+
git config user.name "github-actions[bot]"
|
|
117
|
+
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
|
118
|
+
git tag "v${VERSION}"
|
|
119
|
+
git push origin "refs/tags/v${VERSION}"
|
|
120
|
+
|
|
121
|
+
- name: Write job summary
|
|
122
|
+
env:
|
|
123
|
+
VERSION: ${{ steps.release.outputs.version }}
|
|
124
|
+
SHOULD_PUBLISH: ${{ steps.release.outputs.should_publish }}
|
|
125
|
+
NPM_PUBLISH: ${{ steps.npm-check.outputs.should_publish }}
|
|
126
|
+
run: |
|
|
127
|
+
{
|
|
128
|
+
echo "## Publish summary"
|
|
129
|
+
echo
|
|
130
|
+
echo "- Version: \`${VERSION}\`"
|
|
131
|
+
echo "- Trigger requested publish: \`${SHOULD_PUBLISH:-false}\`"
|
|
132
|
+
echo "- npm publish executed: \`${NPM_PUBLISH:-false}\`"
|
|
133
|
+
} >> "$GITHUB_STEP_SUMMARY"
|
package/README.md
CHANGED
|
@@ -17,6 +17,22 @@ npx periapsis init --preset strict
|
|
|
17
17
|
|
|
18
18
|
`init` now asks which dependency types should be checked by default (unless provided via flags).
|
|
19
19
|
|
|
20
|
+
## Testing
|
|
21
|
+
|
|
22
|
+
Run the regression suite:
|
|
23
|
+
|
|
24
|
+
```sh
|
|
25
|
+
npm test
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
Run the repository policy gate locally:
|
|
29
|
+
|
|
30
|
+
```sh
|
|
31
|
+
npm run policy:check
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
Testing layers and PR gate guidance live in `docs/testing-strategy.md`.
|
|
35
|
+
|
|
20
36
|
## Commands
|
|
21
37
|
|
|
22
38
|
- `periapsis`: run SBOM + license gate
|
package/bin/periapsis.mjs
CHANGED
|
@@ -233,11 +233,9 @@ function loadPolicyOrThrow(policyDir) {
|
|
|
233
233
|
return loadPolicyFromNewFiles(policyDir);
|
|
234
234
|
}
|
|
235
235
|
|
|
236
|
-
async function cmdExceptionsAdd(root, policyDirArg) {
|
|
236
|
+
async function cmdExceptionsAdd(root, policyDirArg, args) {
|
|
237
237
|
const policyDir = path.resolve(root, policyDirArg || 'policy');
|
|
238
238
|
const policy = loadPolicyOrThrow(policyDir);
|
|
239
|
-
const args = parseArgs(process.argv.slice(2));
|
|
240
|
-
|
|
241
239
|
const writeExceptions = (record, { editExisting = false } = {}) => {
|
|
242
240
|
const sameScope = policy.exceptions.filter(
|
|
243
241
|
(entry) => entry.package === record.package && scopeKey(entry.scope) === scopeKey(record.scope)
|
|
@@ -384,12 +382,10 @@ async function cmdExceptionsAdd(root, policyDirArg) {
|
|
|
384
382
|
});
|
|
385
383
|
}
|
|
386
384
|
|
|
387
|
-
async function cmdLicensesAllowAdd(root, policyDirArg) {
|
|
385
|
+
async function cmdLicensesAllowAdd(root, policyDirArg, args) {
|
|
388
386
|
const policyDir = path.resolve(root, policyDirArg || 'policy');
|
|
389
387
|
const policy = loadPolicyOrThrow(policyDir);
|
|
390
388
|
const spdx = loadSpdxCatalog(SPDX_PATH);
|
|
391
|
-
const args = parseArgs(process.argv.slice(2));
|
|
392
|
-
|
|
393
389
|
const writeLicenses = (record) => {
|
|
394
390
|
if (policy.licenses.some((entry) => entry.identifier === record.identifier)) {
|
|
395
391
|
console.warn(
|
|
@@ -656,8 +652,8 @@ function cmdCheck(root, args) {
|
|
|
656
652
|
}
|
|
657
653
|
}
|
|
658
654
|
|
|
659
|
-
async function main() {
|
|
660
|
-
const args = parseArgs(
|
|
655
|
+
export async function main(argv = process.argv.slice(2)) {
|
|
656
|
+
const args = parseArgs(argv);
|
|
661
657
|
const root = resolveRoot(args);
|
|
662
658
|
|
|
663
659
|
if (args.help) {
|
|
@@ -682,12 +678,12 @@ async function main() {
|
|
|
682
678
|
}
|
|
683
679
|
|
|
684
680
|
if (cmd1 === 'exceptions' && cmd2 === 'add') {
|
|
685
|
-
await cmdExceptionsAdd(root, args['policy-dir']);
|
|
681
|
+
await cmdExceptionsAdd(root, args['policy-dir'], args);
|
|
686
682
|
return;
|
|
687
683
|
}
|
|
688
684
|
|
|
689
685
|
if (cmd1 === 'licenses' && cmd2 === 'allow' && cmd3 === 'add') {
|
|
690
|
-
await cmdLicensesAllowAdd(root, args['policy-dir']);
|
|
686
|
+
await cmdLicensesAllowAdd(root, args['policy-dir'], args);
|
|
691
687
|
return;
|
|
692
688
|
}
|
|
693
689
|
|
|
@@ -700,7 +696,9 @@ async function main() {
|
|
|
700
696
|
cmdCheck(root, args);
|
|
701
697
|
}
|
|
702
698
|
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
699
|
+
if (process.argv[1] && path.resolve(process.argv[1]) === __filename) {
|
|
700
|
+
main().catch((err) => {
|
|
701
|
+
console.error(err.message);
|
|
702
|
+
process.exit(1);
|
|
703
|
+
});
|
|
704
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
# Testing Strategy
|
|
2
|
+
|
|
3
|
+
Periapsis now has an in-repo regression strategy designed to make dependency, policy, and vulnerability response work safer to ship. The goal is to catch behavior changes before they merge and to keep policy governance reviewable over time.
|
|
4
|
+
|
|
5
|
+
## Goals
|
|
6
|
+
|
|
7
|
+
- Prevent regressions in CLI behavior, policy evaluation, and dependency-scope handling.
|
|
8
|
+
- Keep tests self-contained in this repository with no live registry or SaaS dependency.
|
|
9
|
+
- Make policy behavior explicit for common governance modes:
|
|
10
|
+
- permissive-only / open license defaults
|
|
11
|
+
- weak copyleft allowed
|
|
12
|
+
- all dependencies versus runtime-only dependencies
|
|
13
|
+
- expiring exceptions and follow-up approvals
|
|
14
|
+
- Turn the suite into a required PR gate through GitHub Actions branch protection.
|
|
15
|
+
|
|
16
|
+
## Test Layers
|
|
17
|
+
|
|
18
|
+
### 1. Core policy unit tests
|
|
19
|
+
|
|
20
|
+
File: `test/policy.test.mjs`
|
|
21
|
+
|
|
22
|
+
Covers pure library logic such as:
|
|
23
|
+
|
|
24
|
+
- SPDX expression parsing
|
|
25
|
+
- category mapping
|
|
26
|
+
- dependency-type parsing
|
|
27
|
+
- exception range matching
|
|
28
|
+
- active versus expired policy records
|
|
29
|
+
|
|
30
|
+
These are the fastest tests and should grow whenever policy logic changes.
|
|
31
|
+
|
|
32
|
+
### 2. CLI workflow tests
|
|
33
|
+
|
|
34
|
+
Files:
|
|
35
|
+
|
|
36
|
+
- `test/cli.test.mjs`
|
|
37
|
+
- `test/commands.test.mjs`
|
|
38
|
+
|
|
39
|
+
Covers command behavior and file-writing flows such as:
|
|
40
|
+
|
|
41
|
+
- `licenses allow add`
|
|
42
|
+
- `exceptions add`
|
|
43
|
+
- `init`
|
|
44
|
+
- `policy migrate`
|
|
45
|
+
- policy-driven dependency-type defaults
|
|
46
|
+
|
|
47
|
+
These protect user-facing commands from accidental flag or file-format regressions.
|
|
48
|
+
|
|
49
|
+
### 3. Fixture-driven regression gate tests
|
|
50
|
+
|
|
51
|
+
File: `test/regression-gate.test.mjs`
|
|
52
|
+
|
|
53
|
+
These tests model governed dependency scenarios end to end using temporary fixture projects:
|
|
54
|
+
|
|
55
|
+
- strict runtime-only policy should ignore disallowed dev dependencies
|
|
56
|
+
- all-dependency policy should fail on the same dev dependency
|
|
57
|
+
- standard policy should allow weak copyleft
|
|
58
|
+
- expired exceptions should fail
|
|
59
|
+
- active follow-up exceptions should pass
|
|
60
|
+
|
|
61
|
+
This is the highest-value layer for future updates to license logic, policy schemas, or dependency traversal.
|
|
62
|
+
|
|
63
|
+
## NPM Scripts
|
|
64
|
+
|
|
65
|
+
Use the following scripts locally and in CI:
|
|
66
|
+
|
|
67
|
+
- `npm test`: full Node test suite
|
|
68
|
+
- `npm run test:unit`: core policy engine tests
|
|
69
|
+
- `npm run test:cli`: CLI and fixture-driven regression tests
|
|
70
|
+
- `npm run policy:check`: run Periapsis against this repository and write `sbom-violations.json`
|
|
71
|
+
- `npm run test:ci`: CI entrypoint, currently equivalent to `npm test`
|
|
72
|
+
|
|
73
|
+
## CI Gate
|
|
74
|
+
|
|
75
|
+
The GitHub Action should run on `pull_request` and enforce two checks:
|
|
76
|
+
|
|
77
|
+
1. `test-suite`
|
|
78
|
+
Runs the full automated test suite.
|
|
79
|
+
|
|
80
|
+
2. `policy-gate`
|
|
81
|
+
Runs Periapsis against the repository itself and uploads `sbom-violations.json`.
|
|
82
|
+
|
|
83
|
+
Recommended branch protection:
|
|
84
|
+
|
|
85
|
+
1. Require status checks to pass before merging.
|
|
86
|
+
2. Mark `test-suite` as required.
|
|
87
|
+
3. Mark `policy-gate` as required.
|
|
88
|
+
4. Optionally require the branch to be up to date before merging.
|
|
89
|
+
|
|
90
|
+
## Dependency Separation
|
|
91
|
+
|
|
92
|
+
Periapsis runtime code should stay in `dependencies`. Any future tooling used only for verification should stay in `devDependencies` so the shipping package remains small and auditable.
|
|
93
|
+
|
|
94
|
+
Suggested future additions, if wanted:
|
|
95
|
+
|
|
96
|
+
- coverage reporting
|
|
97
|
+
- linting
|
|
98
|
+
- JSON schema fixture validation
|
|
99
|
+
- scheduled policy-audit workflow for upcoming exception expirations
|
|
100
|
+
|
|
101
|
+
Those can be added later without changing the runtime surface area of the CLI.
|
|
102
|
+
|
|
103
|
+
## Policy Maintenance Rules
|
|
104
|
+
|
|
105
|
+
When updating dependencies or handling a vulnerability:
|
|
106
|
+
|
|
107
|
+
1. Change the dependency.
|
|
108
|
+
2. Run `npm test`.
|
|
109
|
+
3. Run `npm run policy:check`.
|
|
110
|
+
4. If policy changes are required, update only the governed files in `policy/`.
|
|
111
|
+
5. Prefer adding follow-up license or exception records instead of mutating history in place.
|
|
112
|
+
|
|
113
|
+
That keeps remediation work auditable and lowers the risk of silently loosening policy.
|
package/package.json
CHANGED
|
@@ -1,10 +1,14 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "periapsis",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.5",
|
|
4
4
|
"private": false,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"scripts": {
|
|
7
|
-
"test": "node --test"
|
|
7
|
+
"test": "node --test",
|
|
8
|
+
"test:unit": "node --test test/policy.test.mjs",
|
|
9
|
+
"test:cli": "node --test test/cli.test.mjs test/commands.test.mjs test/regression-gate.test.mjs",
|
|
10
|
+
"test:ci": "npm run test",
|
|
11
|
+
"policy:check": "node ./bin/periapsis.mjs --violations-out sbom-violations.json"
|
|
8
12
|
},
|
|
9
13
|
"bin": {
|
|
10
14
|
"periapsis": "bin/periapsis.mjs"
|
package/test/cli.test.mjs
CHANGED
|
@@ -1,55 +1,18 @@
|
|
|
1
1
|
import test from 'node:test';
|
|
2
2
|
import assert from 'node:assert/strict';
|
|
3
3
|
import fs from 'fs';
|
|
4
|
-
import os from 'os';
|
|
5
4
|
import path from 'path';
|
|
6
|
-
import {
|
|
7
|
-
import { fileURLToPath } from 'url';
|
|
8
|
-
|
|
9
|
-
const __filename = fileURLToPath(import.meta.url);
|
|
10
|
-
const __dirname = path.dirname(__filename);
|
|
11
|
-
const BIN = path.resolve(__dirname, '..', 'bin', 'periapsis.mjs');
|
|
5
|
+
import { createTempDir, runCli, writePolicyBundle } from '../testing/helpers.mjs';
|
|
12
6
|
|
|
13
7
|
function setupTempProject() {
|
|
14
|
-
const dir =
|
|
15
|
-
|
|
16
|
-
fs.writeFileSync(
|
|
17
|
-
path.join(dir, 'policy', 'policy.json'),
|
|
18
|
-
JSON.stringify(
|
|
19
|
-
{
|
|
20
|
-
allowedCategories: ['Permissive Licenses'],
|
|
21
|
-
failOnUnknownLicense: true,
|
|
22
|
-
timezone: 'UTC',
|
|
23
|
-
dependencyTypes: ['dependencies', 'devDependencies', 'peerDependencies', 'optionalDependencies', 'bundledDependencies']
|
|
24
|
-
},
|
|
25
|
-
null,
|
|
26
|
-
2
|
|
27
|
-
) + '\n'
|
|
28
|
-
);
|
|
29
|
-
fs.writeFileSync(path.join(dir, 'policy', 'licenses.json'), '[]\n');
|
|
30
|
-
fs.writeFileSync(path.join(dir, 'policy', 'exceptions.json'), '[]\n');
|
|
8
|
+
const dir = createTempDir('periapsis-test-');
|
|
9
|
+
writePolicyBundle(dir);
|
|
31
10
|
return dir;
|
|
32
11
|
}
|
|
33
12
|
|
|
34
|
-
|
|
35
|
-
try {
|
|
36
|
-
const out = execFileSync('node', [BIN, ...args], {
|
|
37
|
-
cwd,
|
|
38
|
-
encoding: 'utf8'
|
|
39
|
-
});
|
|
40
|
-
if (expectFail) {
|
|
41
|
-
assert.fail(`Expected command to fail: ${args.join(' ')}`);
|
|
42
|
-
}
|
|
43
|
-
return out;
|
|
44
|
-
} catch (err) {
|
|
45
|
-
if (!expectFail) throw err;
|
|
46
|
-
return `${err.stdout || ''}${err.stderr || ''}`;
|
|
47
|
-
}
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
test('licenses allow add supports non-interactive mode', () => {
|
|
13
|
+
test('licenses allow add supports non-interactive mode', async () => {
|
|
51
14
|
const cwd = setupTempProject();
|
|
52
|
-
runCli(cwd, [
|
|
15
|
+
await runCli(cwd, [
|
|
53
16
|
'licenses',
|
|
54
17
|
'allow',
|
|
55
18
|
'add',
|
|
@@ -72,9 +35,9 @@ test('licenses allow add supports non-interactive mode', () => {
|
|
|
72
35
|
assert.equal(licenses[0].approvedBy[0], 'security');
|
|
73
36
|
});
|
|
74
37
|
|
|
75
|
-
test('exceptions add supports non-interactive mode', () => {
|
|
38
|
+
test('exceptions add supports non-interactive mode', async () => {
|
|
76
39
|
const cwd = setupTempProject();
|
|
77
|
-
runCli(cwd, [
|
|
40
|
+
await runCli(cwd, [
|
|
78
41
|
'exceptions',
|
|
79
42
|
'add',
|
|
80
43
|
'--non-interactive',
|
|
@@ -100,9 +63,9 @@ test('exceptions add supports non-interactive mode', () => {
|
|
|
100
63
|
assert.equal(exceptions[0].scope.type, 'exact');
|
|
101
64
|
});
|
|
102
65
|
|
|
103
|
-
test('non-interactive mode enforces required fields', () => {
|
|
66
|
+
test('non-interactive mode enforces required fields', async () => {
|
|
104
67
|
const cwd = setupTempProject();
|
|
105
|
-
const output = runCli(
|
|
68
|
+
const output = await runCli(
|
|
106
69
|
cwd,
|
|
107
70
|
['licenses', 'allow', 'add', '--non-interactive', '--identifier', 'MIT'],
|
|
108
71
|
{ expectFail: true }
|
|
@@ -111,7 +74,7 @@ test('non-interactive mode enforces required fields', () => {
|
|
|
111
74
|
assert.match(output, /--approved-by is required/);
|
|
112
75
|
});
|
|
113
76
|
|
|
114
|
-
test('checker respects --dep-types filter', () => {
|
|
77
|
+
test('checker respects --dep-types filter', async () => {
|
|
115
78
|
const cwd = setupTempProject();
|
|
116
79
|
fs.writeFileSync(
|
|
117
80
|
path.join(cwd, 'package.json'),
|
|
@@ -150,7 +113,7 @@ test('checker respects --dep-types filter', () => {
|
|
|
150
113
|
fs.mkdirSync(path.join(cwd, 'node_modules', 'a'), { recursive: true });
|
|
151
114
|
fs.mkdirSync(path.join(cwd, 'node_modules', 'b'), { recursive: true });
|
|
152
115
|
|
|
153
|
-
runCli(cwd, [
|
|
116
|
+
await runCli(cwd, [
|
|
154
117
|
'licenses',
|
|
155
118
|
'allow',
|
|
156
119
|
'add',
|
|
@@ -167,8 +130,8 @@ test('checker respects --dep-types filter', () => {
|
|
|
167
130
|
'JIRA-300'
|
|
168
131
|
]);
|
|
169
132
|
|
|
170
|
-
runCli(cwd, ['--dep-types', 'dependencies', '--quiet']);
|
|
171
|
-
const failOutput = runCli(cwd, ['--dep-types', 'devDependencies'], {
|
|
133
|
+
await runCli(cwd, ['--dep-types', 'dependencies', '--quiet']);
|
|
134
|
+
const failOutput = await runCli(cwd, ['--dep-types', 'devDependencies'], {
|
|
172
135
|
expectFail: true
|
|
173
136
|
});
|
|
174
137
|
assert.match(failOutput, /license-not-allowed/);
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import test from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import fs from 'fs';
|
|
4
|
+
import path from 'path';
|
|
5
|
+
import { createTempDir, runCli, writeJson, writePolicyBundle } from '../testing/helpers.mjs';
|
|
6
|
+
|
|
7
|
+
test('init writes strict runtime-only policy when requested', async () => {
|
|
8
|
+
const cwd = createTempDir('periapsis-init-');
|
|
9
|
+
|
|
10
|
+
await runCli(cwd, ['init', '--preset', 'strict', '--production-only']);
|
|
11
|
+
|
|
12
|
+
const policy = JSON.parse(fs.readFileSync(path.join(cwd, 'policy', 'policy.json'), 'utf8'));
|
|
13
|
+
assert.deepEqual(policy.allowedCategories, ['Permissive Licenses']);
|
|
14
|
+
assert.deepEqual(policy.dependencyTypes, ['dependencies']);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
test('init writes standard policy with explicit dependency types', async () => {
|
|
18
|
+
const cwd = createTempDir('periapsis-init-deps-');
|
|
19
|
+
|
|
20
|
+
await runCli(cwd, [
|
|
21
|
+
'init',
|
|
22
|
+
'--preset',
|
|
23
|
+
'standard',
|
|
24
|
+
'--dep-types',
|
|
25
|
+
'dependencies,peerDependencies'
|
|
26
|
+
]);
|
|
27
|
+
|
|
28
|
+
const policy = JSON.parse(fs.readFileSync(path.join(cwd, 'policy', 'policy.json'), 'utf8'));
|
|
29
|
+
assert.deepEqual(policy.allowedCategories, [
|
|
30
|
+
'Permissive Licenses',
|
|
31
|
+
'Weak Copyleft Licenses'
|
|
32
|
+
]);
|
|
33
|
+
assert.deepEqual(policy.dependencyTypes, ['dependencies', 'peerDependencies']);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test('policy migrate converts legacy config to governed policy files', async () => {
|
|
37
|
+
const cwd = createTempDir('periapsis-migrate-');
|
|
38
|
+
writeJson(path.join(cwd, 'allowedConfig.json'), {
|
|
39
|
+
allowedLicenses: ['MIT'],
|
|
40
|
+
allowedCategories: ['B'],
|
|
41
|
+
exceptions: ['left-pad@1.3.0', 'uuid']
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
await runCli(cwd, ['policy', 'migrate', '--from', 'allowedConfig.json']);
|
|
45
|
+
|
|
46
|
+
const policy = JSON.parse(fs.readFileSync(path.join(cwd, 'policy', 'policy.json'), 'utf8'));
|
|
47
|
+
const licenses = JSON.parse(fs.readFileSync(path.join(cwd, 'policy', 'licenses.json'), 'utf8'));
|
|
48
|
+
const exceptions = JSON.parse(fs.readFileSync(path.join(cwd, 'policy', 'exceptions.json'), 'utf8'));
|
|
49
|
+
|
|
50
|
+
assert.deepEqual(policy.allowedCategories, ['Weak Copyleft Licenses']);
|
|
51
|
+
assert.equal(licenses.length, 1);
|
|
52
|
+
assert.equal(licenses[0].identifier, 'MIT');
|
|
53
|
+
assert.equal(exceptions.length, 2);
|
|
54
|
+
assert.deepEqual(exceptions[0].scope, { type: 'exact', version: '1.3.0' });
|
|
55
|
+
assert.deepEqual(exceptions[1].scope, { type: 'any' });
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test('checker honors policy dependencyTypes when no CLI override is provided', async () => {
|
|
59
|
+
const cwd = createTempDir('periapsis-policy-scope-');
|
|
60
|
+
writePolicyBundle(cwd, {
|
|
61
|
+
settings: {
|
|
62
|
+
allowedCategories: ['Permissive Licenses'],
|
|
63
|
+
failOnUnknownLicense: true,
|
|
64
|
+
timezone: 'UTC',
|
|
65
|
+
dependencyTypes: ['dependencies']
|
|
66
|
+
},
|
|
67
|
+
licenses: [],
|
|
68
|
+
exceptions: []
|
|
69
|
+
});
|
|
70
|
+
writeJson(path.join(cwd, 'package.json'), {
|
|
71
|
+
name: 'scope-app',
|
|
72
|
+
version: '1.0.0',
|
|
73
|
+
dependencies: { a: '1.0.0' },
|
|
74
|
+
devDependencies: { b: '1.0.0' }
|
|
75
|
+
});
|
|
76
|
+
writeJson(path.join(cwd, 'package-lock.json'), {
|
|
77
|
+
name: 'scope-app',
|
|
78
|
+
version: '1.0.0',
|
|
79
|
+
lockfileVersion: 3,
|
|
80
|
+
packages: {
|
|
81
|
+
'': {
|
|
82
|
+
name: 'scope-app',
|
|
83
|
+
version: '1.0.0',
|
|
84
|
+
dependencies: { a: '1.0.0' },
|
|
85
|
+
devDependencies: { b: '1.0.0' }
|
|
86
|
+
},
|
|
87
|
+
'node_modules/a': { version: '1.0.0', license: 'MIT' },
|
|
88
|
+
'node_modules/b': { version: '1.0.0', license: 'GPL-3.0', dev: true }
|
|
89
|
+
}
|
|
90
|
+
});
|
|
91
|
+
fs.mkdirSync(path.join(cwd, 'node_modules', 'a'), { recursive: true });
|
|
92
|
+
fs.mkdirSync(path.join(cwd, 'node_modules', 'b'), { recursive: true });
|
|
93
|
+
|
|
94
|
+
await runCli(cwd, ['--quiet']);
|
|
95
|
+
|
|
96
|
+
const sbom = JSON.parse(fs.readFileSync(path.join(cwd, 'sbom-licenses.json'), 'utf8'));
|
|
97
|
+
assert.deepEqual(sbom.map((entry) => entry.name), ['a']);
|
|
98
|
+
});
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
import test from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import fs from 'fs';
|
|
4
|
+
import path from 'path';
|
|
5
|
+
import { createFixtureProject, runCli } from '../testing/helpers.mjs';
|
|
6
|
+
|
|
7
|
+
function readViolations(root) {
|
|
8
|
+
return JSON.parse(fs.readFileSync(path.join(root, 'violations.json'), 'utf8'));
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
test('runtime-only strict policy ignores disallowed dev dependency', async () => {
|
|
12
|
+
const cwd = createFixtureProject({
|
|
13
|
+
prefix: 'periapsis-runtime-only-',
|
|
14
|
+
packageJson: {
|
|
15
|
+
name: 'runtime-only-app',
|
|
16
|
+
version: '1.0.0',
|
|
17
|
+
dependencies: { runtimeok: '1.0.0' },
|
|
18
|
+
devDependencies: { buildgpl: '1.0.0' }
|
|
19
|
+
},
|
|
20
|
+
lockPackages: {
|
|
21
|
+
'node_modules/runtimeok': { version: '1.0.0', license: 'MIT' },
|
|
22
|
+
'node_modules/buildgpl': { version: '1.0.0', license: 'GPL-3.0', dev: true }
|
|
23
|
+
},
|
|
24
|
+
policy: {
|
|
25
|
+
settings: {
|
|
26
|
+
allowedCategories: ['Permissive Licenses'],
|
|
27
|
+
failOnUnknownLicense: true,
|
|
28
|
+
timezone: 'UTC',
|
|
29
|
+
dependencyTypes: ['dependencies']
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
await runCli(cwd, ['--violations-out', 'violations.json', '--quiet']);
|
|
35
|
+
|
|
36
|
+
assert.deepEqual(readViolations(cwd), []);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test('all-dependency policy blocks disallowed dev dependency regressions', async () => {
|
|
40
|
+
const cwd = createFixtureProject({
|
|
41
|
+
prefix: 'periapsis-all-deps-',
|
|
42
|
+
packageJson: {
|
|
43
|
+
name: 'all-deps-app',
|
|
44
|
+
version: '1.0.0',
|
|
45
|
+
dependencies: { runtimeok: '1.0.0' },
|
|
46
|
+
devDependencies: { buildgpl: '1.0.0' }
|
|
47
|
+
},
|
|
48
|
+
lockPackages: {
|
|
49
|
+
'node_modules/runtimeok': { version: '1.0.0', license: 'MIT' },
|
|
50
|
+
'node_modules/buildgpl': { version: '1.0.0', license: 'GPL-3.0', dev: true }
|
|
51
|
+
},
|
|
52
|
+
policy: {
|
|
53
|
+
settings: {
|
|
54
|
+
allowedCategories: ['Permissive Licenses'],
|
|
55
|
+
failOnUnknownLicense: true,
|
|
56
|
+
timezone: 'UTC',
|
|
57
|
+
dependencyTypes: ['dependencies', 'devDependencies']
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
await runCli(cwd, ['--violations-out', 'violations.json'], {
|
|
63
|
+
expectFail: true
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
const violations = readViolations(cwd);
|
|
67
|
+
assert.equal(violations.length, 1);
|
|
68
|
+
assert.equal(violations[0].name, 'buildgpl');
|
|
69
|
+
assert.equal(violations[0].reasonType, 'license-not-allowed');
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test('standard policy allows weak copyleft licenses', async () => {
|
|
73
|
+
const cwd = createFixtureProject({
|
|
74
|
+
prefix: 'periapsis-weak-copyleft-',
|
|
75
|
+
packageJson: {
|
|
76
|
+
name: 'weak-copyleft-app',
|
|
77
|
+
version: '1.0.0',
|
|
78
|
+
dependencies: { liblgpl: '1.0.0' }
|
|
79
|
+
},
|
|
80
|
+
lockPackages: {
|
|
81
|
+
'node_modules/liblgpl': { version: '1.0.0', license: 'LGPL-2.1-only' }
|
|
82
|
+
},
|
|
83
|
+
policy: {
|
|
84
|
+
settings: {
|
|
85
|
+
allowedCategories: ['Permissive Licenses', 'Weak Copyleft Licenses'],
|
|
86
|
+
failOnUnknownLicense: true,
|
|
87
|
+
timezone: 'UTC',
|
|
88
|
+
dependencyTypes: ['dependencies']
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
await runCli(cwd, ['--violations-out', 'violations.json', '--quiet']);
|
|
94
|
+
|
|
95
|
+
assert.deepEqual(readViolations(cwd), []);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test('expired exception is surfaced as a policy regression', async () => {
|
|
99
|
+
const cwd = createFixtureProject({
|
|
100
|
+
prefix: 'periapsis-expired-exception-',
|
|
101
|
+
packageJson: {
|
|
102
|
+
name: 'expired-exception-app',
|
|
103
|
+
version: '1.0.0',
|
|
104
|
+
dependencies: { vendorpkg: '1.4.0' }
|
|
105
|
+
},
|
|
106
|
+
lockPackages: {
|
|
107
|
+
'node_modules/vendorpkg': { version: '1.4.0', license: 'LicenseRef-Vendor' }
|
|
108
|
+
},
|
|
109
|
+
policy: {
|
|
110
|
+
settings: {
|
|
111
|
+
allowedCategories: ['Permissive Licenses'],
|
|
112
|
+
failOnUnknownLicense: true,
|
|
113
|
+
timezone: 'UTC',
|
|
114
|
+
dependencyTypes: ['dependencies']
|
|
115
|
+
},
|
|
116
|
+
exceptions: [
|
|
117
|
+
{
|
|
118
|
+
package: 'vendorpkg',
|
|
119
|
+
scope: { type: 'range', range: '^1.2.0' },
|
|
120
|
+
detectedLicenses: ['LicenseRef-Vendor'],
|
|
121
|
+
reason: 'Temporary approval',
|
|
122
|
+
notes: null,
|
|
123
|
+
approvedBy: ['legal'],
|
|
124
|
+
approvedAt: '2025-01-01T00:00:00Z',
|
|
125
|
+
expiresAt: '2025-12-31T00:00:00Z',
|
|
126
|
+
evidenceRef: 'JIRA-EX-1'
|
|
127
|
+
}
|
|
128
|
+
]
|
|
129
|
+
}
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
await runCli(cwd, ['--violations-out', 'violations.json'], {
|
|
133
|
+
expectFail: true
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
const violations = readViolations(cwd);
|
|
137
|
+
assert.equal(violations[0].reasonType, 'expired-exception');
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
test('active follow-up exception keeps previously-expired package compliant', async () => {
|
|
141
|
+
const cwd = createFixtureProject({
|
|
142
|
+
prefix: 'periapsis-followup-exception-',
|
|
143
|
+
packageJson: {
|
|
144
|
+
name: 'followup-exception-app',
|
|
145
|
+
version: '1.0.0',
|
|
146
|
+
dependencies: { vendorpkg: '1.4.0' }
|
|
147
|
+
},
|
|
148
|
+
lockPackages: {
|
|
149
|
+
'node_modules/vendorpkg': { version: '1.4.0', license: 'LicenseRef-Vendor' }
|
|
150
|
+
},
|
|
151
|
+
policy: {
|
|
152
|
+
settings: {
|
|
153
|
+
allowedCategories: ['Permissive Licenses'],
|
|
154
|
+
failOnUnknownLicense: true,
|
|
155
|
+
timezone: 'UTC',
|
|
156
|
+
dependencyTypes: ['dependencies']
|
|
157
|
+
},
|
|
158
|
+
exceptions: [
|
|
159
|
+
{
|
|
160
|
+
package: 'vendorpkg',
|
|
161
|
+
scope: { type: 'range', range: '^1.2.0' },
|
|
162
|
+
detectedLicenses: ['LicenseRef-Vendor'],
|
|
163
|
+
reason: 'Temporary approval',
|
|
164
|
+
notes: null,
|
|
165
|
+
approvedBy: ['legal'],
|
|
166
|
+
approvedAt: '2025-01-01T00:00:00Z',
|
|
167
|
+
expiresAt: '2025-12-31T00:00:00Z',
|
|
168
|
+
evidenceRef: 'JIRA-EX-1'
|
|
169
|
+
},
|
|
170
|
+
{
|
|
171
|
+
package: 'vendorpkg',
|
|
172
|
+
scope: { type: 'range', range: '^1.2.0' },
|
|
173
|
+
detectedLicenses: ['LicenseRef-Vendor'],
|
|
174
|
+
reason: 'Renewed approval',
|
|
175
|
+
notes: null,
|
|
176
|
+
approvedBy: ['legal', 'security'],
|
|
177
|
+
approvedAt: '2026-01-15T00:00:00Z',
|
|
178
|
+
expiresAt: '2026-12-31T00:00:00Z',
|
|
179
|
+
evidenceRef: 'JIRA-EX-2'
|
|
180
|
+
}
|
|
181
|
+
]
|
|
182
|
+
}
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
await runCli(cwd, ['--violations-out', 'violations.json', '--quiet']);
|
|
186
|
+
|
|
187
|
+
assert.deepEqual(readViolations(cwd), []);
|
|
188
|
+
});
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import os from 'os';
|
|
3
|
+
import path from 'path';
|
|
4
|
+
import { fileURLToPath } from 'url';
|
|
5
|
+
import { main } from '../bin/periapsis.mjs';
|
|
6
|
+
|
|
7
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
8
|
+
const __dirname = path.dirname(__filename);
|
|
9
|
+
|
|
10
|
+
export const REPO_ROOT = path.resolve(__dirname, '..');
|
|
11
|
+
export const BIN = path.join(REPO_ROOT, 'bin', 'periapsis.mjs');
|
|
12
|
+
|
|
13
|
+
export function createTempDir(prefix = 'periapsis-test-') {
|
|
14
|
+
return fs.mkdtempSync(path.join(os.tmpdir(), prefix));
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function writeJson(filePath, value) {
|
|
18
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
19
|
+
fs.writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function writePolicyBundle(
|
|
23
|
+
root,
|
|
24
|
+
{
|
|
25
|
+
settings = {
|
|
26
|
+
allowedCategories: ['Permissive Licenses'],
|
|
27
|
+
failOnUnknownLicense: true,
|
|
28
|
+
timezone: 'UTC',
|
|
29
|
+
dependencyTypes: [
|
|
30
|
+
'dependencies',
|
|
31
|
+
'devDependencies',
|
|
32
|
+
'peerDependencies',
|
|
33
|
+
'optionalDependencies',
|
|
34
|
+
'bundledDependencies'
|
|
35
|
+
]
|
|
36
|
+
},
|
|
37
|
+
licenses = [],
|
|
38
|
+
exceptions = []
|
|
39
|
+
} = {}
|
|
40
|
+
) {
|
|
41
|
+
writeJson(path.join(root, 'policy', 'policy.json'), settings);
|
|
42
|
+
writeJson(path.join(root, 'policy', 'licenses.json'), licenses);
|
|
43
|
+
writeJson(path.join(root, 'policy', 'exceptions.json'), exceptions);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function writeProject(root, { packageJson, lockPackages }) {
|
|
47
|
+
writeJson(path.join(root, 'package.json'), packageJson);
|
|
48
|
+
writeJson(path.join(root, 'package-lock.json'), {
|
|
49
|
+
name: packageJson.name || 'fixture-app',
|
|
50
|
+
version: packageJson.version || '1.0.0',
|
|
51
|
+
lockfileVersion: 3,
|
|
52
|
+
packages: {
|
|
53
|
+
'': {
|
|
54
|
+
name: packageJson.name || 'fixture-app',
|
|
55
|
+
version: packageJson.version || '1.0.0',
|
|
56
|
+
dependencies: packageJson.dependencies || {},
|
|
57
|
+
devDependencies: packageJson.devDependencies || {},
|
|
58
|
+
peerDependencies: packageJson.peerDependencies || {},
|
|
59
|
+
optionalDependencies: packageJson.optionalDependencies || {},
|
|
60
|
+
bundledDependencies: packageJson.bundledDependencies || packageJson.bundleDependencies || []
|
|
61
|
+
},
|
|
62
|
+
...lockPackages
|
|
63
|
+
}
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
for (const pkgPath of Object.keys(lockPackages)) {
|
|
67
|
+
if (!pkgPath.startsWith('node_modules/')) continue;
|
|
68
|
+
fs.mkdirSync(path.join(root, pkgPath), { recursive: true });
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function createFixtureProject({
|
|
73
|
+
packageJson,
|
|
74
|
+
lockPackages,
|
|
75
|
+
policy,
|
|
76
|
+
prefix
|
|
77
|
+
}) {
|
|
78
|
+
const root = createTempDir(prefix);
|
|
79
|
+
writePolicyBundle(root, policy);
|
|
80
|
+
writeProject(root, { packageJson, lockPackages });
|
|
81
|
+
return root;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export async function runCli(cwd, args, { expectFail = false } = {}) {
|
|
85
|
+
const originalCwd = process.cwd();
|
|
86
|
+
const originalExitCode = process.exitCode;
|
|
87
|
+
const captured = [];
|
|
88
|
+
const originalLog = console.log;
|
|
89
|
+
const originalWarn = console.warn;
|
|
90
|
+
const originalError = console.error;
|
|
91
|
+
|
|
92
|
+
console.log = (...parts) => captured.push(`${parts.join(' ')}\n`);
|
|
93
|
+
console.warn = (...parts) => captured.push(`${parts.join(' ')}\n`);
|
|
94
|
+
console.error = (...parts) => captured.push(`${parts.join(' ')}\n`);
|
|
95
|
+
|
|
96
|
+
try {
|
|
97
|
+
process.chdir(cwd);
|
|
98
|
+
process.exitCode = 0;
|
|
99
|
+
|
|
100
|
+
await main(args);
|
|
101
|
+
|
|
102
|
+
const output = captured.join('');
|
|
103
|
+
if (!expectFail && process.exitCode && process.exitCode !== 0) {
|
|
104
|
+
throw new Error(output || `Command failed with exit code ${process.exitCode}`);
|
|
105
|
+
}
|
|
106
|
+
if (expectFail && process.exitCode !== 1) {
|
|
107
|
+
throw new Error(`Expected command to fail: ${args.join(' ')}`);
|
|
108
|
+
}
|
|
109
|
+
return output;
|
|
110
|
+
} catch (err) {
|
|
111
|
+
const output = `${captured.join('')}${err.message ? `${err.message}\n` : ''}`;
|
|
112
|
+
if (!expectFail) throw err;
|
|
113
|
+
return output;
|
|
114
|
+
} finally {
|
|
115
|
+
process.chdir(originalCwd);
|
|
116
|
+
process.exitCode = originalExitCode;
|
|
117
|
+
console.log = originalLog;
|
|
118
|
+
console.warn = originalWarn;
|
|
119
|
+
console.error = originalError;
|
|
120
|
+
}
|
|
121
|
+
}
|