diff --git a/.github/workflows/validation-and-tests.yml b/.github/workflows/validation-and-tests.yml index 3c283fd..03c6672 100755 --- a/.github/workflows/validation-and-tests.yml +++ b/.github/workflows/validation-and-tests.yml @@ -37,6 +37,8 @@ jobs: command: vp run test:redirects - name: Type Check command: vp run test:types + - name: Unit Tests + command: vp run test:unit steps: # Check out the repository diff --git a/.gitignore b/.gitignore index 8f9a22b..e5bce09 100644 --- a/.gitignore +++ b/.gitignore @@ -38,6 +38,7 @@ dist-ssr # CloudFlare .cloudflare +.wrangler _redirects _redirects.json diff --git a/app/utils/build-phone-uri.test.ts b/app/utils/build-phone-uri.test.ts new file mode 100644 index 0000000..9b9a8d1 --- /dev/null +++ b/app/utils/build-phone-uri.test.ts @@ -0,0 +1,19 @@ +import { expect, test } from 'vite-plus/test' + +import { buildPhoneUri } from './build-phone-uri' + +test('builds a tel URI from a spaced international number', () => { + expect(buildPhoneUri('+49 351 0000000')).toBe('tel:+493510000000') +}) + +test('keeps an existing tel URI valid', () => { + expect(buildPhoneUri('tel:+493510000000')).toBe('tel:+493510000000') +}) + +test('strips common display separators', () => { + expect(buildPhoneUri('+49 (351) 000-0000')).toBe('tel:+493510000000') +}) + +test('returns an empty string when input contains no digits', () => { + expect(buildPhoneUri('contact me maybe')).toBe('') +}) diff --git a/app/utils/buildVCard.test.ts b/app/utils/buildVCard.test.ts new file mode 100644 index 0000000..057ef2f --- /dev/null +++ b/app/utils/buildVCard.test.ts @@ -0,0 +1,112 @@ +import { describe, expect, it } from 'vite-plus/test' + +import { buildVCard } from './buildVCard' + +const projectMetadata = { + author: { + lastName: 'Seyschab', + firstName: 'Thorsten', + handle: '@toddeTV', + name: 'Thorsten Seyschab', + nickname: 'toddeTV', + role: 'IT consultant, senior full-stack developer, and conference speaker', + }, + siteUrl: 'https://todde.tv', +} as const + +describe('buildVCard', () => { + it('renders selected fixed fields and contact entries in vCard order', () => { + const card = buildVCard( + projectMetadata, + { + name: true, + nickname: true, + website: true, + bio: true, + }, + [ + 'hello@todde.tv', + ], + [ + '+4917691404834', + ], + [ + { + name: 'GitHub', + url: 'https://github.com/toddeTV', + }, + ], + ) + + expect(card).toBe([ + 'BEGIN:VCARD', + 'VERSION:3.0', + 'N:Seyschab;Thorsten;;;', + 'FN:Thorsten Seyschab', + 'NICKNAME:toddeTV', + 'URL:https://todde.tv', + 'NOTE:@toddeTV - IT consultant\\, senior full-stack developer\\, and conference speaker', + 'EMAIL:hello@todde.tv', + 'TEL;TYPE=CELL:+4917691404834', + 'X-SOCIALPROFILE;TYPE=GitHub:https://github.com/toddeTV', + 'END:VCARD', + ].join('\r\n')) + }) + + it('escapes vCard value characters in names, notes, and social type labels', () => { + const card = buildVCard( + { + author: { + lastName: 'Semi;Colon', + firstName: 'Comma,Slash\\Line\nBreak', + handle: '@semi;comma', + name: 'Semi;Colon, Slash\\Line\nBreak', + nickname: 'nick;name', + role: 'Line one\nLine two', + }, + siteUrl: 'https://todde.tv', + }, + { + name: true, + nickname: true, + website: false, + bio: true, + }, + [], + [], + [ + { + name: 'Mastodon;Profile', + url: 'https://example.com/@todde', + }, + ], + ) + + expect(card).toContain('N:Semi\\;Colon;Comma\\,Slash\\\\Line\\nBreak;;;') + expect(card).toContain('FN:Semi\\;Colon\\, Slash\\\\Line\\nBreak') + expect(card).toContain('NICKNAME:nick\\;name') + expect(card).toContain('NOTE:@semi\\;comma - Line one\\nLine two') + expect(card).toContain('X-SOCIALPROFILE;TYPE=Mastodon\\;Profile:https://example.com/@todde') + }) + + it('omits optional fields that are not selected', () => { + const card = buildVCard( + projectMetadata, + { + name: false, + nickname: false, + website: false, + bio: false, + }, + [], + [], + [], + ) + + expect(card).toBe([ + 'BEGIN:VCARD', + 'VERSION:3.0', + 'END:VCARD', + ].join('\r\n')) + }) +}) diff --git a/eslint.config.mjs b/eslint.config.mjs index bf76e86..2a63b50 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -19,6 +19,7 @@ export default withNuxt( '.output/', 'dist/', '.data/', + '.wrangler/', '.claude/skills/**/.skilld/', 'pnpm-lock.yaml', ], diff --git a/package.json b/package.json index c00a2fe..658bdb4 100644 --- a/package.json +++ b/package.json @@ -55,16 +55,18 @@ "fix:lint": "run-s \"test:lint --fix\"", "nuxi": "nuxi", "nuxt": "nuxt", - "prepare": "nuxt prepare", - "preview:ssg": "pnpm exec serve .output/public", + "postinstall": "nuxt prepare", + "prepare": "vp config --hooks-only", + "preview:ssg": "vp exec serve .output/public", "preview:ssr": "nuxt preview", "redirects:check": "vp dlx tsx@4.21.0 scripts/generate-redirects.ts --check", "redirects:generate": "vp dlx tsx@4.21.0 scripts/generate-redirects.ts", "reset": "rimraf node_modules .nuxt .output dist .data", - "test": "run-s test:*", + "test": "run-s test:lint test:redirects test:types test:unit", "test:lint": "eslint .", "test:redirects": "vp run redirects:generate && vp run redirects:check", "test:types": "nuxt typecheck", + "test:unit": "vp test app/ server/ shared/ scripts/", "verify:cloudflare:smoke": "vp dlx tsx@4.21.0 scripts/check-cloudflare-smoke.ts" }, "dependencies": { diff --git a/server/routes/.well-known/security.txt.get.ts b/server/routes/.well-known/security.txt.get.ts index eb1a027..62a09a7 100644 --- a/server/routes/.well-known/security.txt.get.ts +++ b/server/routes/.well-known/security.txt.get.ts @@ -1,43 +1,6 @@ -const defaultSecurityTxtLifetimeDays = 180 -const projectMetadata = getProjectMetadata() - -interface SecurityTxtContentOptions { - canonicalUrl: string - contact: string - policyUrl?: string - preferredLanguages: string[] -} - -/** Returns the ISO 8601 expiration timestamp for `security.txt`. */ -function createSecurityTxtExpires( - now: Date = new Date(), - lifetimeDays: number = defaultSecurityTxtLifetimeDays, -): string { - const expiresAt = new Date(now.getTime()) - - expiresAt.setUTCDate(expiresAt.getUTCDate() + lifetimeDays) +import { buildSecurityTxtContent } from '#server/utils/security-txt' - return expiresAt.toISOString() -} - -/** Builds the plain text `security.txt` response body from route metadata. */ -function buildSecurityTxtContent( - options: SecurityTxtContentOptions, - now: Date = new Date(), -): string { - const lines = [ - `Contact: ${options.contact}`, - `Expires: ${createSecurityTxtExpires(now)}`, - `Canonical: ${options.canonicalUrl}`, - `Preferred-Languages: ${options.preferredLanguages.join(', ')}`, - ] - - if (options.policyUrl) { - lines.push(`Policy: ${options.policyUrl}`) - } - - return `${lines.join('\n')}\n` -} +const projectMetadata = getProjectMetadata() export default defineEventHandler((event) => { const content = buildSecurityTxtContent({ diff --git a/server/utils/build-release-metadata.test.ts b/server/utils/build-release-metadata.test.ts new file mode 100644 index 0000000..dfd5be1 --- /dev/null +++ b/server/utils/build-release-metadata.test.ts @@ -0,0 +1,63 @@ +import { afterEach, describe, expect, it, vi } from 'vite-plus/test' + +import { resolveBuildReleaseMetadata } from './build-release-metadata' + +afterEach(() => { + vi.useRealTimers() +}) + +describe('resolveBuildReleaseMetadata', () => { + it('prefers explicit build metadata env values in declared order', () => { + const metadata = resolveBuildReleaseMetadata({ + BUILD_RELEASE_DATE: '2026-04-15', + BUILD_RELEASE_COMMIT_SHORT: 'ABCDEF123456', + GITHUB_SHA: '1234567890abcdef', + }, () => '7654321') + + expect(metadata).toEqual({ + buildDateIso: '2026-04-15', + commitShort: 'abcdef1', + releaseLabel: '2026.04.15+abcdef1', + }) + }) + + it('normalizes datetime env values and falls back to git resolver for commit', () => { + const metadata = resolveBuildReleaseMetadata({ + NUXT_BUILD_RELEASE_DATE: '2026-04-15T19:20:21.000Z', + }, () => 'Feature abcdef1234567890') + + expect(metadata).toEqual({ + buildDateIso: '2026-04-15', + commitShort: 'abcdef1', + releaseLabel: '2026.04.15+abcdef1', + }) + }) + + it('falls back to current UTC date when build date is invalid', () => { + vi.useFakeTimers() + vi.setSystemTime(new Date('2026-05-23T08:00:00.000Z')) + + const metadata = resolveBuildReleaseMetadata({ + BUILD_RELEASE_DATE: '2026-02-30', + }, () => 'abcdef1') + + expect(metadata).toEqual({ + buildDateIso: '2026-05-23', + commitShort: 'abcdef1', + releaseLabel: '2026.05.23+abcdef1', + }) + }) + + it('uses unknown when no valid commit can be resolved', () => { + const metadata = resolveBuildReleaseMetadata({ + BUILD_RELEASE_DATE: '2026-04-15', + GITHUB_SHA: 'not-a-sha', + }, () => undefined) + + expect(metadata).toEqual({ + buildDateIso: '2026-04-15', + commitShort: 'unknown', + releaseLabel: '2026.04.15+unknown', + }) + }) +}) diff --git a/server/utils/security-txt.test.ts b/server/utils/security-txt.test.ts new file mode 100644 index 0000000..8dede85 --- /dev/null +++ b/server/utils/security-txt.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from 'vite-plus/test' +import { + buildSecurityTxtContent, + createSecurityTxtExpires, + defaultSecurityTxtLifetimeDays, +} from './security-txt' + +describe('security txt utilities', () => { + it('creates an RFC 3339 expires value within the default lifetime', () => { + const now = new Date('2026-05-10T12:34:56.000Z') + + expect(defaultSecurityTxtLifetimeDays).toBe(180) + expect(createSecurityTxtExpires(now)).toBe('2026-11-06T12:34:56.000Z') + }) + + it('renders required security.txt fields', () => { + const content = buildSecurityTxtContent({ + canonicalUrl: 'https://todde.tv/.well-known/security.txt', + contact: 'mailto:hello@todde.tv', + preferredLanguages: [ + 'en', + 'de', + ], + }, new Date('2026-05-10T12:34:56.000Z')) + + expect(content).toBe([ + 'Contact: mailto:hello@todde.tv', + 'Expires: 2026-11-06T12:34:56.000Z', + 'Canonical: https://todde.tv/.well-known/security.txt', + 'Preferred-Languages: en, de', + '', + ].join('\n')) + }) + + it('renders the optional policy field when configured', () => { + const content = buildSecurityTxtContent({ + canonicalUrl: 'https://todde.tv/.well-known/security.txt', + contact: 'mailto:hello@todde.tv', + policyUrl: 'https://todde.tv/legal-notice', + preferredLanguages: [ + 'en', + ], + }, new Date('2026-05-10T12:34:56.000Z')) + + expect(content).toContain('Policy: https://todde.tv/legal-notice') + }) +}) diff --git a/server/utils/security-txt.ts b/server/utils/security-txt.ts new file mode 100644 index 0000000..790f397 --- /dev/null +++ b/server/utils/security-txt.ts @@ -0,0 +1,39 @@ +export const defaultSecurityTxtLifetimeDays = 180 + +export interface SecurityTxtContentOptions { + canonicalUrl: string + contact: string + policyUrl?: string + preferredLanguages: string[] +} + +/** Returns the ISO 8601 expiration timestamp for `security.txt`. */ +export function createSecurityTxtExpires( + now: Date = new Date(), + lifetimeDays: number = defaultSecurityTxtLifetimeDays, +): string { + const expiresAt = new Date(now.getTime()) + + expiresAt.setUTCDate(expiresAt.getUTCDate() + lifetimeDays) + + return expiresAt.toISOString() +} + +/** Builds the plain text `security.txt` response body from route metadata. */ +export function buildSecurityTxtContent( + options: SecurityTxtContentOptions, + now: Date = new Date(), +): string { + const lines = [ + `Contact: ${options.contact}`, + `Expires: ${createSecurityTxtExpires(now)}`, + `Canonical: ${options.canonicalUrl}`, + `Preferred-Languages: ${options.preferredLanguages.join(', ')}`, + ] + + if (options.policyUrl) { + lines.push(`Policy: ${options.policyUrl}`) + } + + return `${lines.join('\n')}\n` +} diff --git a/shared/utils/project-metadata-redirects.test.ts b/shared/utils/project-metadata-redirects.test.ts new file mode 100644 index 0000000..aa2e195 --- /dev/null +++ b/shared/utils/project-metadata-redirects.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from 'vite-plus/test' + +import { + buildProjectMetadataRedirectRouteRules, + getProjectMetadataRedirectEntries, + getProjectMetadataRedirectGroups, + renderProjectMetadataRedirectsFile, +} from '../../project-metadata.config' + +describe('project metadata redirects', () => { + it('keeps redirect groups in declaration order', () => { + const groups = getProjectMetadataRedirectGroups() + + expect(groups).toHaveLength(3) + expect(groups[0]?.comment).toBe('Legal Notice alternative paths') + expect(groups[1]?.comment).toBe('Privacy Policy alternative paths') + expect(groups[2]?.comment).toBe('Machine-readable metadata alias') + }) + + it('flattens redirect entries in declaration order', () => { + const entries = getProjectMetadataRedirectEntries() + + expect(entries.map(entry => entry.from)).toEqual([ + '/imprint', + '/impressum', + '/legal', + '/privacy', + '/datenschutz', + '/security.txt', + ]) + }) + + it('reuses redirect metadata when building route rules', () => { + const routeRules = buildProjectMetadataRedirectRouteRules() + + expect(routeRules['/imprint']).toEqual({ + redirect: { + to: '/legal-notice', + statusCode: 301, + }, + }) + expect(routeRules['/security.txt']).toEqual({ + redirect: { + to: '/.well-known/security.txt', + statusCode: 301, + }, + }) + }) + + it('renders the Cloudflare redirects artifact', () => { + const redirectsFile = renderProjectMetadataRedirectsFile() + + expect(redirectsFile).toMatch(/^# Cloudflare Pages edge-level redirects\./) + expect(redirectsFile).toContain('# Legal Notice alternative paths') + expect(redirectsFile).toContain('/privacy /privacy-policy 301') + expect(redirectsFile).toContain('/security.txt /.well-known/security.txt 301') + }) +}) diff --git a/shared/utils/project-metadata.test.ts b/shared/utils/project-metadata.test.ts new file mode 100644 index 0000000..1b33f01 --- /dev/null +++ b/shared/utils/project-metadata.test.ts @@ -0,0 +1,131 @@ +import { describe, expect, it } from 'vite-plus/test' +import { prepareProjectMetadata } from '#shared/utils/project-metadata' + +describe('prepareProjectMetadata', () => { + it('derives author contact and sameAs from socials content', () => { + const projectMetadata = prepareProjectMetadata([ + { + featured: true, + handle: 'hello@todde.tv', + icon: 'ph:envelope-simple', + name: 'Email', + sortOrder: 1, + url: 'mailto:hello@todde.tv', + }, + { + featured: false, + handle: '+49 176 91404834', + icon: 'ph:phone', + name: 'Phone', + sortOrder: 2, + url: 'tel:+4917691404834', + }, + { + featured: true, + handle: '@toddeTV', + icon: 'ph:github-logo', + name: 'GitHub', + sortOrder: 4, + url: 'https://github.com/toddeTV', + }, + { + featured: true, + handle: '@toddeTV', + icon: 'ph:x-logo', + name: 'X', + sortOrder: 5, + url: 'https://x.com/toddeTV', + }, + ]) + + expect(projectMetadata.author.contact).toBe('hello@todde.tv') + expect(projectMetadata.author.sameAs).toEqual([ + 'https://github.com/toddeTV', + 'https://x.com/toddeTV', + ]) + expect(projectMetadata.featuredSocials).toHaveLength(3) + expect(projectMetadata.primaryPhoneSocial?.url).toBe('tel:+4917691404834') + }) + + it('returns empty derived author fields when socials do not provide them', () => { + const projectMetadata = prepareProjectMetadata([]) + + expect(projectMetadata.author.contact).toBe('') + expect(projectMetadata.author.sameAs).toEqual([]) + }) + + it('filters inactive socials, sorts active entries, and deduplicates featured profiles', () => { + const projectMetadata = prepareProjectMetadata([ + { + active: false, + featured: true, + handle: '@old-handle', + icon: 'ph:x-logo', + name: 'Inactive X', + sortOrder: 0, + url: 'https://x.com/old-handle', + }, + { + featured: true, + handle: '@toddeTV', + icon: 'ph:github-logo', + name: 'GitHub', + sortOrder: 30, + url: 'https://github.com/toddeTV', + }, + { + featured: true, + handle: '+49 176 91404834', + icon: 'ph:phone', + name: 'Phone', + sortOrder: 20, + url: 'tel:+4917691404834', + }, + { + featured: true, + handle: '@toddeTV', + icon: 'ph:link', + name: 'Homepage', + sortOrder: 10, + url: 'https://todde.tv', + }, + { + featured: true, + handle: 'hello@todde.tv', + icon: 'ph:envelope-simple', + name: 'Email', + sortOrder: 5, + url: 'mailto:hello@todde.tv', + }, + { + featured: true, + handle: '@toddeTV', + icon: 'ph:globe', + name: 'Homepage duplicate', + sortOrder: 40, + url: 'https://todde.tv', + }, + ]) + + expect(projectMetadata.socials.map(social => social.name)).toEqual([ + 'Email', + 'Homepage', + 'Phone', + 'GitHub', + 'Homepage duplicate', + ]) + expect(projectMetadata.featuredSocials.map(social => social.name)).toEqual([ + 'Email', + 'Homepage', + 'Phone', + 'GitHub', + 'Homepage duplicate', + ]) + expect(projectMetadata.author.sameAs).toEqual([ + 'https://todde.tv', + 'https://github.com/toddeTV', + ]) + expect(projectMetadata.primaryEmailSocial?.url).toBe('mailto:hello@todde.tv') + expect(projectMetadata.primaryPhoneSocial?.url).toBe('tel:+4917691404834') + }) +}) diff --git a/vite.config.ts b/vite.config.ts new file mode 100644 index 0000000..667082b --- /dev/null +++ b/vite.config.ts @@ -0,0 +1,33 @@ +import { fileURLToPath } from 'node:url' + +import { defineConfig } from 'vite-plus' + +const resolveWorkspacePath = (path: string) => fileURLToPath(new URL(path, import.meta.url)) + +const nuxtTestAliases = { + '~': resolveWorkspacePath('./app'), + '@': resolveWorkspacePath('./app'), + '~~': resolveWorkspacePath('./'), + '@@': resolveWorkspacePath('./'), + '#shared': resolveWorkspacePath('./shared'), + '#server': resolveWorkspacePath('./server'), + '#build': resolveWorkspacePath('./.nuxt'), + '#app': resolveWorkspacePath('./node_modules/nuxt/dist/app'), +} as const + +export default defineConfig({ + staged: { + '*.{css,html,json,jsonc,md,mjs,ts,vue,yaml,yml}': [ + 'vp exec eslint --max-warnings=0 --no-warn-ignored', + ], + }, + test: { + include: [ + 'app/**/*.test.ts', + 'scripts/**/*.test.ts', + 'shared/**/*.test.ts', + 'server/**/*.test.ts', + ], + alias: nuxtTestAliases, + }, +})