forked from
npmx.dev/npmx.dev
[READ-ONLY]
a fast, modern browser for the npm registry
1import { describe, expect, it } from 'vitest'
2import type { Packument, PackageVersionInfo } from '#shared/types'
3import { transformPackument } from '~/composables/npm/usePackage'
4import { detectPublishSecurityDowngradeForVersion } from '~/utils/publish-security'
5
6function createVersion(version: string, hasAttestations = false): Packument['versions'][string] {
7 return {
8 _id: `foo@${version}`,
9 _npmVersion: '10.0.0',
10 name: 'foo',
11 version,
12 dist: {
13 shasum: version,
14 tarball: `https://registry.npmjs.org/foo/-/foo-${version}.tgz`,
15 signatures: [],
16 ...(hasAttestations
17 ? {
18 attestations: {
19 url: `https://example.test/${version}`,
20 provenance: { predicateType: 'https://slsa.dev/provenance/v1' },
21 },
22 }
23 : {}),
24 },
25 }
26}
27
28function createTrustedPublisherVersion(version: string) {
29 return {
30 ...createVersion(version, false),
31 _npmUser: {
32 name: 'github-actions',
33 email: 'noreply@github.com',
34 trustedPublisher: {
35 id: 'github',
36 },
37 },
38 }
39}
40
41function createTrustedPublisherWithAttestationsVersion(version: string) {
42 return {
43 ...createVersion(version, true),
44 _npmUser: {
45 name: 'github-actions',
46 email: 'noreply@github.com',
47 trustedPublisher: {
48 id: 'github',
49 },
50 },
51 }
52}
53
54function createPackument(
55 versions: Packument['versions'],
56 time: Packument['time'],
57 latest: string,
58): Packument {
59 return {
60 '_id': 'foo',
61 '_rev': '1',
62 'name': 'foo',
63 'dist-tags': { latest },
64 time,
65 versions,
66 }
67}
68
69function toVersionInfos(packument: ReturnType<typeof transformPackument>): PackageVersionInfo[] {
70 return (
71 packument.securityVersions ??
72 Object.entries(packument.versions).map(([version, metadata]) => ({
73 version,
74 time: packument.time[version],
75 hasProvenance: !!metadata.hasProvenance,
76 trustLevel: metadata.trustLevel,
77 deprecated: metadata.deprecated,
78 }))
79 )
80}
81
82describe('transformPackument', () => {
83 it('includes requested old version and preserves provenance on it', () => {
84 const packument = createPackument(
85 {
86 '1.0.0': createVersion('1.0.0', true),
87 '1.0.1': createVersion('1.0.1'),
88 '1.0.2': createVersion('1.0.2'),
89 '1.0.3': createVersion('1.0.3'),
90 '1.0.4': createVersion('1.0.4'),
91 '1.0.5': createVersion('1.0.5'),
92 '1.0.6': createVersion('1.0.6'),
93 '1.0.7': createVersion('1.0.7'),
94 },
95 {
96 'created': '2026-01-01T00:00:00.000Z',
97 'modified': '2026-01-08T00:00:00.000Z',
98 '1.0.0': '2026-01-01T00:00:00.000Z',
99 '1.0.1': '2026-01-02T00:00:00.000Z',
100 '1.0.2': '2026-01-03T00:00:00.000Z',
101 '1.0.3': '2026-01-04T00:00:00.000Z',
102 '1.0.4': '2026-01-05T00:00:00.000Z',
103 '1.0.5': '2026-01-06T00:00:00.000Z',
104 '1.0.6': '2026-01-07T00:00:00.000Z',
105 '1.0.7': '2026-01-08T00:00:00.000Z',
106 },
107 '1.0.7',
108 )
109
110 const transformed = transformPackument(packument, '1.0.0')
111
112 expect(transformed.versions['1.0.0']?.hasProvenance).toBe(true)
113 expect(transformed.versions['1.0.1']).toBeUndefined()
114 expect(transformed.versions['1.0.2']).toBeUndefined()
115 expect(transformed.securityVersions).toHaveLength(8)
116 })
117
118 it('omits securityVersions when all versions have the same trust level', () => {
119 const packument = createPackument(
120 {
121 '1.0.0': createVersion('1.0.0'),
122 '1.0.1': createVersion('1.0.1'),
123 '1.0.2': createVersion('1.0.2'),
124 },
125 {
126 'created': '2026-01-01T00:00:00.000Z',
127 'modified': '2026-01-03T00:00:00.000Z',
128 '1.0.0': '2026-01-01T00:00:00.000Z',
129 '1.0.1': '2026-01-02T00:00:00.000Z',
130 '1.0.2': '2026-01-03T00:00:00.000Z',
131 },
132 '1.0.2',
133 )
134
135 const transformed = transformPackument(packument, '1.0.2')
136
137 // All versions have trustLevel 'none', so no mixed trust — omit the array
138 expect(transformed.securityVersions).toBeUndefined()
139 })
140
141 it('includes securityVersions when package has mixed trust levels', () => {
142 const packument = createPackument(
143 {
144 '1.0.0': createVersion('1.0.0', true),
145 '1.0.1': createVersion('1.0.1'),
146 },
147 {
148 'created': '2026-01-01T00:00:00.000Z',
149 'modified': '2026-01-02T00:00:00.000Z',
150 '1.0.0': '2026-01-01T00:00:00.000Z',
151 '1.0.1': '2026-01-02T00:00:00.000Z',
152 },
153 '1.0.1',
154 )
155
156 const transformed = transformPackument(packument, '1.0.1')
157
158 expect(transformed.securityVersions).toHaveLength(2)
159 })
160
161 it('works with downgrade detection for viewed version', () => {
162 const packument = createPackument(
163 {
164 '1.0.0': createVersion('1.0.0', true),
165 '1.0.1': createVersion('1.0.1'),
166 '1.0.2': createVersion('1.0.2', true),
167 },
168 {
169 'created': '2026-01-01T00:00:00.000Z',
170 'modified': '2026-01-03T00:00:00.000Z',
171 '1.0.0': '2026-01-01T00:00:00.000Z',
172 '1.0.1': '2026-01-02T00:00:00.000Z',
173 '1.0.2': '2026-01-03T00:00:00.000Z',
174 },
175 '1.0.2',
176 )
177
178 const transformed = transformPackument(packument, '1.0.1')
179 const infos = toVersionInfos(transformed)
180
181 expect(detectPublishSecurityDowngradeForVersion(infos, '1.0.2')).toBeNull()
182 expect(detectPublishSecurityDowngradeForVersion(infos, '1.0.1')).toEqual({
183 downgradedVersion: '1.0.1',
184 downgradedPublishedAt: '2026-01-02T00:00:00.000Z',
185 downgradedTrustLevel: 'none',
186 trustedVersion: '1.0.0',
187 trustedPublishedAt: '2026-01-01T00:00:00.000Z',
188 trustedTrustLevel: 'provenance',
189 })
190 })
191
192 it('treats trustedPublisher as trust evidence for downgrade checks', () => {
193 const packument = createPackument(
194 {
195 '1.0.0': createTrustedPublisherVersion('1.0.0'),
196 '1.0.1': createVersion('1.0.1'),
197 '1.0.2': createVersion('1.0.2'),
198 },
199 {
200 'created': '2026-01-01T00:00:00.000Z',
201 'modified': '2026-01-03T00:00:00.000Z',
202 '1.0.0': '2026-01-01T00:00:00.000Z',
203 '1.0.1': '2026-01-02T00:00:00.000Z',
204 '1.0.2': '2026-01-03T00:00:00.000Z',
205 },
206 '1.0.2',
207 )
208
209 const transformed = transformPackument(packument, '1.0.1')
210 const infos = toVersionInfos(transformed)
211
212 expect(infos.find(v => v.version === '1.0.0')?.hasProvenance).toBe(true)
213 expect(detectPublishSecurityDowngradeForVersion(infos, '1.0.1')?.trustedVersion).toBe('1.0.0')
214 })
215
216 it('prefers trustedPublisher trust level when both trustedPublisher and attestations exist', () => {
217 const packument = createPackument(
218 {
219 '1.0.0': createTrustedPublisherWithAttestationsVersion('1.0.0'),
220 '1.0.1': createTrustedPublisherVersion('1.0.1'),
221 },
222 {
223 'created': '2026-01-01T00:00:00.000Z',
224 'modified': '2026-01-02T00:00:00.000Z',
225 '1.0.0': '2026-01-01T00:00:00.000Z',
226 '1.0.1': '2026-01-02T00:00:00.000Z',
227 },
228 '1.0.1',
229 )
230
231 const transformed = transformPackument(packument, '1.0.1')
232
233 expect(transformed.versions['1.0.0']?.trustLevel).toBe('trustedPublisher')
234 })
235
236 // https://github.com/npmx-dev/npmx.dev/issues/1292
237 it('does not flag false downgrade when trusted publisher version also has attestations', () => {
238 // Trusted publishing automatically generates provenance attestations,
239 // so a version with both should be classified as trustedPublisher, not provenance.
240 const packument = createPackument(
241 {
242 '7.0.0': createTrustedPublisherWithAttestationsVersion('7.0.0'),
243 '7.0.1': createTrustedPublisherWithAttestationsVersion('7.0.1'),
244 },
245 {
246 'created': '2026-01-01T00:00:00.000Z',
247 'modified': '2026-01-02T00:00:00.000Z',
248 '7.0.0': '2026-01-01T00:00:00.000Z',
249 '7.0.1': '2026-01-02T00:00:00.000Z',
250 },
251 '7.0.1',
252 )
253
254 const transformed = transformPackument(packument, '7.0.1')
255 const infos = toVersionInfos(transformed)
256
257 // Both versions should be trustedPublisher — no downgrade
258 expect(infos.find(v => v.version === '7.0.0')?.trustLevel).toBe('trustedPublisher')
259 expect(infos.find(v => v.version === '7.0.1')?.trustLevel).toBe('trustedPublisher')
260 expect(detectPublishSecurityDowngradeForVersion(infos, '7.0.1')).toBeNull()
261 })
262
263 it('flags non-direct downgrade chain until trust is restored', () => {
264 const packument = createPackument(
265 {
266 '2.1.0': createVersion('2.1.0', true),
267 '2.1.1': createVersion('2.1.1'),
268 '2.2.0': createVersion('2.2.0'),
269 '2.3.0': createVersion('2.3.0'),
270 '2.4.0': createVersion('2.4.0', true),
271 },
272 {
273 'created': '2026-01-01T00:00:00.000Z',
274 'modified': '2026-01-05T00:00:00.000Z',
275 '2.1.0': '2026-01-01T00:00:00.000Z',
276 '2.1.1': '2026-01-02T00:00:00.000Z',
277 '2.2.0': '2026-01-03T00:00:00.000Z',
278 '2.3.0': '2026-01-04T00:00:00.000Z',
279 '2.4.0': '2026-01-05T00:00:00.000Z',
280 },
281 '2.4.0',
282 )
283
284 const transformed = transformPackument(packument, '2.3.0')
285 const infos = toVersionInfos(transformed)
286
287 expect(detectPublishSecurityDowngradeForVersion(infos, '2.1.1')?.trustedVersion).toBe('2.1.0')
288 expect(detectPublishSecurityDowngradeForVersion(infos, '2.2.0')?.trustedVersion).toBe('2.1.0')
289 expect(detectPublishSecurityDowngradeForVersion(infos, '2.3.0')?.trustedVersion).toBe('2.1.0')
290 expect(detectPublishSecurityDowngradeForVersion(infos, '2.4.0')).toBeNull()
291 })
292})