Files
goodbuddy/src/main/version-checker.test.ts
T
lofyer 4100911c34
Cross-platform packages / ${{ matrix.platform }} ${{ matrix.arch }} (x64, macos, macos-15-intel) (push) Blocked by required conditions
Cross-platform packages / ${{ matrix.platform }} ${{ matrix.arch }} (x64, windows, windows-2025) (push) Blocked by required conditions
Cross-platform packages / Validate source (push) Waiting to run
Cross-platform packages / ${{ matrix.platform }} ${{ matrix.arch }} (arm64, linux, ubuntu-24.04-arm) (push) Blocked by required conditions
Cross-platform packages / ${{ matrix.platform }} ${{ matrix.arch }} (arm64, macos, macos-15) (push) Blocked by required conditions
Cross-platform packages / ${{ matrix.platform }} ${{ matrix.arch }} (arm64, windows, windows-2025) (push) Blocked by required conditions
Cross-platform packages / ${{ matrix.platform }} ${{ matrix.arch }} (x64, linux, ubuntu-24.04) (push) Blocked by required conditions
Cross-platform packages / Publish GitHub Release (push) Blocked by required conditions
chore: prepare GoodBuddy 0.8.3
2026-08-07 01:56:32 +08:00

477 lines
13 KiB
TypeScript

import { describe, expect, it, vi } from 'vitest'
import {
checkForUpdates,
compareStrictSemVer,
GOODBUDDY_LATEST_RELEASE_API_URL
} from './version-checker'
const latestVersion = '1.2.3'
const manifestUrl =
'https://github.com/mesalogo/goodbuddy/releases/download/' +
`v${latestVersion}/release-manifest.json`
const manifestAssetApiUrl =
'https://api.github.com/repos/mesalogo/goodbuddy/releases/assets/123'
const releaseAssetUrl =
'https://release-assets.githubusercontent.com/github-production-release-asset/' +
'123/release-manifest.json?download=1'
const files = [
{
name: `GoodBuddy-${latestVersion}-windows-x64-setup.exe`,
size: 101,
sha256: 'a'.repeat(64)
},
{
name: `GoodBuddy-${latestVersion}-windows-x64-portable.zip`,
size: 102,
sha256: 'b'.repeat(64)
}
]
function releasePayload(
overrides: Record<string, unknown> = {}
): Record<string, unknown> {
return {
tag_name: `v${latestVersion}`,
draft: false,
prerelease: false,
assets: [
{
name: 'release-manifest.json',
url: manifestAssetApiUrl,
browser_download_url: manifestUrl
}
],
...overrides
}
}
function manifestPayload(
overrides: Record<string, unknown> = {}
): Record<string, unknown> {
return {
formatVersion: 1,
productName: 'GoodBuddy',
version: latestVersion,
targets: [
{
platform: 'windows',
arch: 'x64',
formats: ['nsis', 'portable'],
manifest: 'release-manifest-windows-x64.json',
files
}
],
files: files.map((file) => ({
platform: 'windows',
arch: 'x64',
...file
})),
...overrides
}
}
function jsonResponse(
value: unknown,
init: ResponseInit = {}
): Response {
return new Response(JSON.stringify(value), {
status: 200,
headers: { 'content-type': 'application/json' },
...init
})
}
function successfulFetch(): ReturnType<typeof vi.fn<typeof fetch>> {
return vi.fn<typeof fetch>(async (input) => {
const url = String(input)
if (url === GOODBUDDY_LATEST_RELEASE_API_URL) {
return jsonResponse(releasePayload())
}
if (url === manifestAssetApiUrl) {
return jsonResponse(manifestPayload())
}
throw new Error(`Unexpected request: ${url}`)
})
}
describe('compareStrictSemVer', () => {
it('implements SemVer precedence without treating build metadata as newer', () => {
expect(compareStrictSemVer('1.0.0-alpha.2', '1.0.0-alpha.10')).toBe(-1)
expect(compareStrictSemVer('1.0.0-rc.1', '1.0.0')).toBe(-1)
expect(compareStrictSemVer('1.0.0+build.2', '1.0.0+build.1')).toBe(0)
})
it.each([
'v1.2.3',
'1.2',
'01.2.3',
'1.2.3-01',
'1.2.3-',
'1.2.3+'
])('rejects non-strict version %s', (version) => {
expect(() => compareStrictSemVer(version, '1.0.0')).toThrow(
'Invalid semantic version'
)
})
})
describe('checkForUpdates', () => {
it('uses only the official latest release and canonical manifest URLs', async () => {
const transport = successfulFetch()
await expect(
checkForUpdates({
fetch: transport,
currentVersion: '1.0.0',
platform: 'win32',
arch: 'x64'
})
).resolves.toEqual({
updateAvailable: true,
currentVersion: '1.0.0',
latestVersion,
releaseUrl:
`https://github.com/mesalogo/goodbuddy/releases/tag/v${latestVersion}`,
target: {
platform: 'windows',
arch: 'x64',
formats: ['nsis', 'portable'],
files
}
})
expect(transport).toHaveBeenCalledTimes(2)
expect(transport.mock.calls.map(([input]) => String(input))).toEqual([
GOODBUDDY_LATEST_RELEASE_API_URL,
manifestAssetApiUrl
])
expect(transport.mock.calls[1]?.[1]?.headers).toMatchObject({
Accept: 'application/octet-stream'
})
for (const [, init] of transport.mock.calls) {
expect(init).toMatchObject({
method: 'GET',
redirect: 'manual',
credentials: 'omit',
cache: 'no-store'
})
}
})
it('follows only the official GitHub release asset redirect', async () => {
const transport = vi.fn<typeof fetch>(async (input) => {
const url = String(input)
if (url === GOODBUDDY_LATEST_RELEASE_API_URL) {
return jsonResponse(releasePayload())
}
if (url === manifestAssetApiUrl) {
return new Response(null, {
status: 302,
headers: { location: releaseAssetUrl }
})
}
if (url === releaseAssetUrl) {
return jsonResponse(manifestPayload())
}
throw new Error(`Unexpected request: ${url}`)
})
await expect(
checkForUpdates({
fetch: transport,
currentVersion: '1.0.0',
platform: 'win32',
arch: 'x64'
})
).resolves.toMatchObject({ latestVersion })
expect(transport.mock.calls.map(([input]) => String(input))).toEqual([
GOODBUDDY_LATEST_RELEASE_API_URL,
manifestAssetApiUrl,
releaseAssetUrl
])
})
it.each([
'http://release-assets.githubusercontent.com/manifest.json',
'https://attacker.invalid/manifest.json',
'https://user:password@release-assets.githubusercontent.com/manifest.json'
])('rejects untrusted release redirect %s', async (location) => {
const transport = vi.fn<typeof fetch>(async (input) =>
String(input) === GOODBUDDY_LATEST_RELEASE_API_URL
? jsonResponse(releasePayload())
: new Response(null, {
status: 302,
headers: { location }
})
)
await expect(
checkForUpdates({
fetch: transport,
currentVersion: '1.0.0',
platform: 'win32',
arch: 'x64'
})
).rejects.toThrow('not trusted')
})
it('does not consider equal precedence or an older release an update', async () => {
await expect(
checkForUpdates({
fetch: successfulFetch(),
currentVersion: '1.2.3+local',
platform: 'win32',
arch: 'x64'
})
).resolves.toMatchObject({ updateAvailable: false })
await expect(
checkForUpdates({
fetch: successfulFetch(),
currentVersion: '2.0.0',
platform: 'win32',
arch: 'x64'
})
).resolves.toMatchObject({ updateAvailable: false })
})
it('rejects invalid tags, prereleases, and mismatched manifest versions', async () => {
const cases: Array<{
release: Record<string, unknown>
manifest?: Record<string, unknown>
}> = [
{ release: releasePayload({ tag_name: '1.2.3' }) },
{ release: releasePayload({ tag_name: 'v01.2.3' }) },
{ release: releasePayload({ prerelease: true }) },
{
release: releasePayload(),
manifest: manifestPayload({ version: '1.2.4' })
}
]
for (const testCase of cases) {
const transport = vi.fn<typeof fetch>(async (input) =>
String(input) === GOODBUDDY_LATEST_RELEASE_API_URL
? jsonResponse(testCase.release)
: jsonResponse(testCase.manifest ?? manifestPayload())
)
await expect(
checkForUpdates({
fetch: transport,
currentVersion: '1.0.0',
platform: 'win32',
arch: 'x64'
})
).rejects.toThrow()
}
})
it.each([
{
url: manifestAssetApiUrl,
browser_download_url: 'https://attacker.invalid/manifest.json'
},
{
url: 'https://api.github.com/repos/attacker/repo/releases/assets/123',
browser_download_url: manifestUrl
}
])('rejects noncanonical release assets without requesting them', async (asset) => {
const transport = vi.fn<typeof fetch>(async () =>
jsonResponse(
releasePayload({
assets: [
{
name: 'release-manifest.json',
...asset
}
]
})
)
)
await expect(
checkForUpdates({
fetch: transport,
currentVersion: '1.0.0',
platform: 'win32',
arch: 'x64'
})
).rejects.toThrow('canonical aggregate manifest')
expect(transport).toHaveBeenCalledTimes(1)
})
it('rejects missing, duplicate, incomplete, and inconsistent targets', async () => {
const invalidManifests = [
manifestPayload({ targets: [] }),
manifestPayload({
targets: [
manifestPayload().targets,
manifestPayload().targets
].flat()
}),
manifestPayload({
targets: [
{
platform: 'windows',
arch: 'x64',
formats: ['nsis', 'portable'],
manifest: 'release-manifest-windows-x64.json',
files: [files[0]]
}
]
}),
manifestPayload({ files: [] })
]
for (const manifest of invalidManifests) {
const transport = vi.fn<typeof fetch>(async (input) =>
String(input) === GOODBUDDY_LATEST_RELEASE_API_URL
? jsonResponse(releasePayload())
: jsonResponse(manifest)
)
await expect(
checkForUpdates({
fetch: transport,
currentVersion: '1.0.0',
platform: 'win32',
arch: 'x64'
})
).rejects.toThrow()
}
})
it('rejects unsafe file metadata and unsupported targets', async () => {
const unsafeManifest = manifestPayload({
targets: [
{
platform: 'windows',
arch: 'x64',
formats: ['nsis', 'portable'],
manifest: 'release-manifest-windows-x64.json',
files: [
{ ...files[0], name: '../GoodBuddy.exe' },
files[1]
]
}
]
})
const transport = vi.fn<typeof fetch>(async (input) =>
String(input) === GOODBUDDY_LATEST_RELEASE_API_URL
? jsonResponse(releasePayload())
: jsonResponse(unsafeManifest)
)
await expect(
checkForUpdates({
fetch: transport,
currentVersion: '1.0.0',
platform: 'win32',
arch: 'x64'
})
).rejects.toThrow()
await expect(
checkForUpdates({
fetch: successfulFetch(),
currentVersion: '1.0.0',
platform: 'freebsd',
arch: 'x64'
})
).rejects.toThrow('Unsupported update platform')
await expect(
checkForUpdates({
fetch: successfulFetch(),
currentVersion: '1.0.0',
platform: 'win32',
arch: 'ia32'
})
).rejects.toThrow('Unsupported update architecture')
})
it('rejects HTTP errors, invalid JSON, and bounded oversized bodies', async () => {
const failedFetch = vi.fn<typeof fetch>(async () =>
new Response('{}', { status: 503 })
)
await expect(
checkForUpdates({
fetch: failedFetch,
currentVersion: '1.0.0',
platform: 'win32',
arch: 'x64'
})
).rejects.toThrow('HTTP 503')
const invalidJsonFetch = vi.fn<typeof fetch>(async () =>
new Response('{invalid')
)
await expect(
checkForUpdates({
fetch: invalidJsonFetch,
currentVersion: '1.0.0',
platform: 'win32',
arch: 'x64'
})
).rejects.toThrow('not valid JSON')
const oversizedFetch = vi.fn<typeof fetch>(async () =>
new Response('x'.repeat(65), {
headers: { 'content-length': '65' }
})
)
await expect(
checkForUpdates({
fetch: oversizedFetch,
currentVersion: '1.0.0',
platform: 'win32',
arch: 'x64',
maxJsonBytes: 64
})
).rejects.toThrow('too large')
})
it('applies the timeout while reading a stalled response body', async () => {
const transport = vi.fn<typeof fetch>(async (input) => {
if (String(input) === GOODBUDDY_LATEST_RELEASE_API_URL) {
return jsonResponse(releasePayload())
}
return new Response(
new ReadableStream<Uint8Array>({
start() {
// Keep the body open without yielding bytes.
}
})
)
})
await expect(
checkForUpdates({
fetch: transport,
currentVersion: '1.0.0',
platform: 'win32',
arch: 'x64',
timeoutMs: 5
})
).rejects.toMatchObject({ name: 'AbortError' })
})
it('aborts a request at the configured timeout', async () => {
const transport = vi.fn<typeof fetch>(
async (_input, init) =>
new Promise<Response>((_resolve, reject) => {
init?.signal?.addEventListener(
'abort',
() =>
reject(
new DOMException('The operation was aborted', 'AbortError')
),
{ once: true }
)
})
)
await expect(
checkForUpdates({
fetch: transport,
currentVersion: '1.0.0',
platform: 'win32',
arch: 'x64',
timeoutMs: 5
})
).rejects.toMatchObject({ name: 'AbortError' })
})
})