mirror of
https://github.com/stablyai/orca.git
synced 2026-09-30 08:03:12 +00:00
* fix(repo-icon): keep a renamed fork's own owner avatar Fork repos always took the upstream owner's avatar, so a renamed fork showed its parent project's logo. Same-name forks (personal copies) still prefer the upstream owner; renamed forks now keep their origin owner across auto-detect, the startup backfill, and the settings avatar refresh. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(repo-icon): re-read repo state before backfill avatar write The startup backfill computed icon updates from a pre-loop snapshot, so an icon chosen in settings while the upstream/origin probes were pending could be clobbered. Re-read the repo after the probes and only migrate an icon that is still the auto-detected GitHub avatar. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * refactor(repo-icon): own the fork avatar rule in one shared selector The renamed-fork rule was written out twice — once in the main-process auto-detect and once in the renderer refresh — so the two copies could drift. Move it next to `githubAvatarIcon` as `githubAvatarSlug`, which collapses the renderer resolver to a single unbranched path. Also stop swallowing a rejected origin probe: it cannot tell a renamed fork from a same-name one, so degrading to the upstream owner would flip a renamed fork's stored avatar back to the parent's. Letting it propagate keeps the stored icon, matching how the non-fork path already behaved. Adds coverage for the startup backfill, the third decision point the fix claims, which had none. * test(repo-icon): cover pending backfill icon change --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> Co-authored-by: Brennan Benson <79079362+brennanb2025@users.noreply.github.com>
191 lines
5.7 KiB
TypeScript
191 lines
5.7 KiB
TypeScript
import { describe, expect, it } from 'vitest'
|
|
import { githubAvatarIcon, githubAvatarSlug, sanitizeRepoIcon } from './repo-icon'
|
|
|
|
const PNG_1X1_BASE64 =
|
|
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/p9sAAAAASUVORK5CYII='
|
|
const WEBP_1X1_BASE64 = 'UklGRhoAAABXRUJQVlA4IA4AAAAwAQCdASoBAAEAAQIlSkwAAA=='
|
|
|
|
function pngBase64(width: number, height: number): string {
|
|
const bytes = Buffer.alloc(24)
|
|
Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]).copy(bytes)
|
|
bytes.writeUInt32BE(13, 8)
|
|
bytes.write('IHDR', 12, 'ascii')
|
|
bytes.writeUInt32BE(width, 16)
|
|
bytes.writeUInt32BE(height, 20)
|
|
return bytes.toString('base64')
|
|
}
|
|
|
|
describe('sanitizeRepoIcon', () => {
|
|
it('accepts lucide, emoji, and supported image icons', () => {
|
|
expect(sanitizeRepoIcon({ type: 'lucide', name: 'Folder' })).toEqual({
|
|
type: 'lucide',
|
|
name: 'Folder'
|
|
})
|
|
expect(sanitizeRepoIcon({ type: 'emoji', emoji: '🚀' })).toEqual({
|
|
type: 'emoji',
|
|
emoji: '🚀'
|
|
})
|
|
expect(
|
|
sanitizeRepoIcon({
|
|
type: 'image',
|
|
src: 'https://github.com/stablyai.png?size=64',
|
|
source: 'github',
|
|
label: 'stablyai/orca'
|
|
})
|
|
).toEqual({
|
|
type: 'image',
|
|
src: 'https://github.com/stablyai.png?size=64',
|
|
source: 'github',
|
|
label: 'stablyai/orca'
|
|
})
|
|
expect(
|
|
sanitizeRepoIcon({
|
|
type: 'image',
|
|
src: 'https://github.acme.test/stablyai.png?size=64',
|
|
source: 'github',
|
|
label: 'stablyai/orca'
|
|
})
|
|
).toEqual({
|
|
type: 'image',
|
|
src: 'https://github.acme.test/stablyai.png?size=64',
|
|
source: 'github',
|
|
label: 'stablyai/orca'
|
|
})
|
|
expect(
|
|
sanitizeRepoIcon({
|
|
type: 'image',
|
|
src: 'https://www.google.com/s2/favicons?domain=example.com&sz=64',
|
|
source: 'favicon'
|
|
})
|
|
).toEqual({
|
|
type: 'image',
|
|
src: 'https://www.google.com/s2/favicons?domain=example.com&sz=64',
|
|
source: 'favicon'
|
|
})
|
|
expect(
|
|
sanitizeRepoIcon({
|
|
type: 'image',
|
|
src: `data:image/png;base64,${PNG_1X1_BASE64}`,
|
|
source: 'upload'
|
|
})
|
|
).toEqual({
|
|
type: 'image',
|
|
src: `data:image/png;base64,${PNG_1X1_BASE64}`,
|
|
source: 'upload'
|
|
})
|
|
expect(
|
|
sanitizeRepoIcon({
|
|
type: 'image',
|
|
src: `data:image/png;base64,${PNG_1X1_BASE64}`,
|
|
source: 'file'
|
|
})
|
|
).toEqual({
|
|
type: 'image',
|
|
src: `data:image/png;base64,${PNG_1X1_BASE64}`,
|
|
source: 'file'
|
|
})
|
|
expect(
|
|
sanitizeRepoIcon({
|
|
type: 'image',
|
|
src: `data:image/webp;base64,${WEBP_1X1_BASE64}`,
|
|
source: 'file'
|
|
})
|
|
).toEqual({
|
|
type: 'image',
|
|
src: `data:image/webp;base64,${WEBP_1X1_BASE64}`,
|
|
source: 'file'
|
|
})
|
|
})
|
|
|
|
it('keeps null as an explicit reset', () => {
|
|
expect(sanitizeRepoIcon(null)).toBeNull()
|
|
})
|
|
|
|
it('rejects unsupported image urls and oversized payloads', () => {
|
|
expect(
|
|
sanitizeRepoIcon({
|
|
type: 'image',
|
|
src: 'javascript:alert(1)',
|
|
source: 'favicon'
|
|
})
|
|
).toBeUndefined()
|
|
expect(
|
|
sanitizeRepoIcon({
|
|
type: 'image',
|
|
src: `data:image/png;base64,${pngBase64(32_769, 1)}`,
|
|
source: 'upload'
|
|
})
|
|
).toBeUndefined()
|
|
expect(
|
|
sanitizeRepoIcon({
|
|
type: 'image',
|
|
src: `data:image/png;base64,${'a'.repeat(401 * 1024)}`,
|
|
source: 'upload'
|
|
})
|
|
).toBeUndefined()
|
|
expect(
|
|
sanitizeRepoIcon({
|
|
type: 'image',
|
|
src: 'data:image/svg+xml;base64,PHN2Zz48L3N2Zz4=',
|
|
source: 'upload'
|
|
})
|
|
).toBeUndefined()
|
|
expect(
|
|
sanitizeRepoIcon({
|
|
type: 'image',
|
|
src: 'data:image/svg+xml;base64,PHN2Zz48L3N2Zz4=',
|
|
source: 'file'
|
|
})
|
|
).toBeUndefined()
|
|
expect(
|
|
sanitizeRepoIcon({
|
|
type: 'image',
|
|
src: 'https://example.com/nested/icon.png',
|
|
source: 'github'
|
|
})
|
|
).toBeUndefined()
|
|
expect(
|
|
sanitizeRepoIcon({
|
|
type: 'image',
|
|
src: 'https://user@example.com/icon.png',
|
|
source: 'github'
|
|
})
|
|
).toBeUndefined()
|
|
})
|
|
|
|
it('builds hosted avatar URLs only from a valid host value', () => {
|
|
expect(
|
|
githubAvatarIcon({ owner: 'acme', repo: 'widgets', host: 'GitHub.Acme.Test:8443' })
|
|
).toMatchObject({ src: 'https://github.acme.test:8443/acme.png?size=64' })
|
|
// Explicit default port 443 is canonical for an HTTPS host: accept it
|
|
// (serialized without the port) rather than falling back to github.com.
|
|
expect(
|
|
githubAvatarIcon({ owner: 'acme', repo: 'widgets', host: 'ghe.example:443' })
|
|
).toMatchObject({ src: 'https://ghe.example/acme.png?size=64' })
|
|
expect(
|
|
githubAvatarIcon({ owner: 'acme', repo: 'widgets', host: 'github.com@evil.example' })
|
|
).toMatchObject({ src: 'https://github.com/acme.png?size=64' })
|
|
})
|
|
})
|
|
|
|
describe('githubAvatarSlug', () => {
|
|
const upstream = { owner: 'upstream-org', repo: 'rocket' }
|
|
|
|
it('keeps the upstream owner for a same-name fork, case-insensitively', () => {
|
|
expect(githubAvatarSlug({ owner: 'acme', repo: 'rocket' }, upstream)).toEqual(upstream)
|
|
expect(githubAvatarSlug({ owner: 'acme', repo: 'RocKet' }, upstream)).toEqual(upstream)
|
|
})
|
|
|
|
it('keeps the fork own owner once it has been renamed', () => {
|
|
const origin = { owner: 'acme', repo: 'rocket-pro' }
|
|
expect(githubAvatarSlug(origin, upstream)).toEqual(origin)
|
|
})
|
|
|
|
it('falls back to whichever identity is known', () => {
|
|
const origin = { owner: 'acme', repo: 'rocket-pro' }
|
|
expect(githubAvatarSlug(origin, null)).toEqual(origin)
|
|
expect(githubAvatarSlug(null, upstream)).toEqual(upstream)
|
|
expect(githubAvatarSlug(null, undefined)).toBeNull()
|
|
})
|
|
})
|