From 356bfad8e835de7071efdb554a1bdc4a2f10f2ac Mon Sep 17 00:00:00 2001 From: Nam Hoang Date: Tue, 10 Dec 2024 10:18:52 +0700 Subject: [PATCH 1/4] feat: support extension and multiple version of the schema --- packages/untp-playground/src/app/page.tsx | 83 ++-- .../src/components/TestResults.tsx | 426 +++++++++--------- .../src/lib/credentialService.ts | 35 +- .../src/lib/schemaValidation.ts | 93 +++- 4 files changed, 361 insertions(+), 276 deletions(-) diff --git a/packages/untp-playground/src/app/page.tsx b/packages/untp-playground/src/app/page.tsx index 8e683911..3e51c210 100644 --- a/packages/untp-playground/src/app/page.tsx +++ b/packages/untp-playground/src/app/page.tsx @@ -1,17 +1,15 @@ -"use client"; +'use client'; -import { CredentialUploader } from "@/components/CredentialUploader"; -import { DownloadCredential } from "@/components/DownloadCredential"; -import { Footer } from "@/components/Footer"; -import { Header } from "@/components/Header"; -import { TestResults } from "@/components/TestResults"; -import { - decodeEnvelopedCredential, - isEnvelopedProof, -} from "@/lib/credentialService"; -import type { Credential, CredentialType } from "@/types/credential"; -import { useState } from "react"; -import { toast } from "sonner"; +import { CredentialUploader } from '@/components/CredentialUploader'; +import { DownloadCredential } from '@/components/DownloadCredential'; +import { Footer } from '@/components/Footer'; +import { Header } from '@/components/Header'; +import { TestResults } from '@/components/TestResults'; +import { decodeEnvelopedCredential, detectCredentialType, isEnvelopedProof } from '@/lib/credentialService'; +import { detectExtension } from '@/lib/schemaValidation'; +import type { Credential, CredentialType } from '@/types/credential'; +import { useState } from 'react'; +import { toast } from 'sonner'; interface StoredCredential { original: any; @@ -19,11 +17,11 @@ interface StoredCredential { } const CREDENTIAL_TYPES = [ - "DigitalProductPassport", - "DigitalConformityCredential", - "DigitalFacilityRecord", - "DigitalIdentityAnchor", - "DigitalTraceabilityEvent", + 'DigitalProductPassport', + 'DigitalConformityCredential', + 'DigitalFacilityRecord', + 'DigitalIdentityAnchor', + 'DigitalTraceabilityEvent', ] as const; export default function Home() { @@ -33,25 +31,21 @@ export default function Home() { const handleCredentialUpload = async (rawCredential: any) => { try { - const normalizedCredential = - rawCredential.verifiableCredential || rawCredential; + const normalizedCredential = rawCredential.verifiableCredential || rawCredential; - if (!normalizedCredential || typeof normalizedCredential !== "object") { - toast.error("Invalid credential format"); + if (!normalizedCredential || typeof normalizedCredential !== 'object') { + toast.error('Invalid credential format'); return; } const isEnveloped = isEnvelopedProof(normalizedCredential); - const decodedCredential = isEnveloped - ? decodeEnvelopedCredential(normalizedCredential) - : normalizedCredential; + const decodedCredential = isEnveloped ? decodeEnvelopedCredential(normalizedCredential) : normalizedCredential; - const credentialType = decodedCredential.type.find((t: string) => - CREDENTIAL_TYPES.includes(t as CredentialType) - ) as CredentialType; + const extension = detectExtension(decodedCredential); + let credentialType = extension ? extension.core.type : detectCredentialType(decodedCredential); - if (!credentialType) { - toast.error("Unknown credential type"); + if (!credentialType || !CREDENTIAL_TYPES.includes(credentialType as CredentialType)) { + toast.error('Unknown credential type'); return; } @@ -62,33 +56,30 @@ export default function Home() { decoded: decodedCredential, }, })); - } catch { - toast.error("Failed to process credential"); + } catch (error) { + console.error(error); + toast.error('Failed to process credential'); } }; return ( -
+
-
-
-
-

Your Credentials

+
+
+
+

Your Credentials

-
+
-

Add New Credential

-
- +

Add New Credential

+
+
-

- Download Test Credential -

+

Download Test Credential

diff --git a/packages/untp-playground/src/components/TestResults.tsx b/packages/untp-playground/src/components/TestResults.tsx index dd4cff1f..6432c941 100644 --- a/packages/untp-playground/src/components/TestResults.tsx +++ b/packages/untp-playground/src/components/TestResults.tsx @@ -1,38 +1,25 @@ -"use client"; - -import { Button } from "@/components/ui/button"; -import { Card } from "@/components/ui/card"; -import { - Sheet, - SheetContent, - SheetHeader, - SheetTitle, - SheetTrigger, -} from "@/components/ui/sheet"; -import { isEnvelopedProof } from "@/lib/credentialService"; -import { validateCredentialSchema } from "@/lib/schemaValidation"; -import { verifyCredential } from "@/lib/verificationService"; -import type { Credential, CredentialType, TestStep } from "@/types/credential"; -import confetti from "canvas-confetti"; -import { - AlertCircle, - Check, - ChevronDown, - ChevronRight, - Loader2, - X, -} from "lucide-react"; -import { useEffect, useMemo, useRef, useState } from "react"; -import { toast } from "sonner"; -import { ErrorDialog } from "./ErrorDialog"; +'use client'; + +import { Button } from '@/components/ui/button'; +import { Card } from '@/components/ui/card'; +import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetTrigger } from '@/components/ui/sheet'; +import { detectVersion, isEnvelopedProof } from '@/lib/credentialService'; +import { detectExtension, validateCredentialSchema, validateExtension } from '@/lib/schemaValidation'; +import { verifyCredential } from '@/lib/verificationService'; +import type { Credential, CredentialType, TestStep } from '@/types/credential'; +import confetti from 'canvas-confetti'; +import { AlertCircle, Check, ChevronDown, ChevronRight, Loader2, X } from 'lucide-react'; +import { useEffect, useMemo, useRef, useState } from 'react'; +import { toast } from 'sonner'; +import { ErrorDialog } from './ErrorDialog'; // Define all possible credential types const ALL_CREDENTIAL_TYPES: CredentialType[] = [ - "DigitalProductPassport", - "DigitalConformityCredential", - "DigitalFacilityRecord", - "DigitalIdentityAnchor", - "DigitalTraceabilityEvent", + 'DigitalProductPassport', + 'DigitalConformityCredential', + 'DigitalFacilityRecord', + 'DigitalIdentityAnchor', + 'DigitalTraceabilityEvent', ]; // Add this type to help with tracking previous credentials @@ -58,54 +45,49 @@ interface TestGroupProps { const TestGroup = ({ credentialType, version, + extensionCredentialType, + extensionVersion, steps, isExpanded, onToggle, proofType, hasCredential, }: TestGroupProps & { - proofType: "enveloping" | "embedded" | "none"; + proofType: 'enveloping' | 'embedded' | 'none'; hasCredential: boolean; + extensionCredentialType?: string; + extensionVersion?: string; }) => { - const isLoading = steps.some((step) => step.status === "in-progress"); + const isLoading = steps.some((step) => step.status === 'in-progress'); const overallStatus = useMemo(() => { - if (!hasCredential) return "missing"; - if (version === "unknown") return "failure"; - if (isLoading || steps.some((step) => step.status === "pending")) - return "in-progress"; - return steps.every((step) => step.status === "success") - ? "success" - : "failure"; + if (!hasCredential) return 'missing'; + if (version === 'unknown') return 'failure'; + if (isLoading || steps.some((step) => step.status === 'pending')) return 'in-progress'; + return steps.every((step) => step.status === 'success') ? 'success' : 'failure'; }, [steps, isLoading, hasCredential, version]); return ( - -
-
- {isExpanded ? ( - - ) : ( - - )} -

+ +
+
+ {isExpanded ? : } +

{credentialType} - {hasCredential && - ` (${ - version === "unknown" ? version + " version" : "v" + version - })`} + {hasCredential && ` (${version === 'unknown' ? version + ' version' : 'v' + version})`}

+ {extensionCredentialType && ( +

+ {extensionCredentialType} + {extensionVersion === 'unknown' ? 'unknown' : ` (v${extensionVersion})`} +

+ )}
-
- {hasCredential && proofType !== "none" && ( +
+ {hasCredential && proofType !== 'none' && ( {proofType} proof @@ -115,15 +97,11 @@ const TestGroup = ({
{isExpanded && ( -
+
{steps.map((step) => ( ))} - {!hasCredential && ( -

- Upload a credential to begin validation -

- )} + {!hasCredential &&

Upload a credential to begin validation

}
)} @@ -136,39 +114,35 @@ const TestStepItem = ({ step }: { step: TestStep }) => { const shouldShowDetails = step.details && ((step.details.errors && step.details.errors.length > 0) || - (step.details.additionalProperties && - Object.keys(step.details.additionalProperties).length > 0)); + (step.details.additionalProperties && Object.keys(step.details.additionalProperties).length > 0)); return ( -
-
-
+
+
+
{step.name}
{step.details && - step.id === "schema" && - (step.details.errors?.[0]?.message === "Failed to fetch schema" ? ( - Failed to load schema + (step.id === 'schema' || step.id === 'extension-schema') && + (step.details.errors?.[0]?.message === 'Failed to fetch schema' ? ( + Failed to load schema ) : shouldShowDetails ? ( - - + Validation Details -
+
{step.details.errors && step.details.errors.length > 0 ? ( - + ) : ( -
+

⚠️ Additional properties found in credential

)} @@ -181,23 +155,17 @@ const TestStepItem = ({ step }: { step: TestStep }) => { ); }; -const StatusIcon = ({ - status, - size = "default", -}: { - status: TestStep["status"]; - size?: "sm" | "default"; -}) => { - const sizeClass = size === "sm" ? "h-3 w-3" : "h-4 w-4"; +const StatusIcon = ({ status, size = 'default' }: { status: TestStep['status']; size?: 'sm' | 'default' }) => { + const sizeClass = size === 'sm' ? 'h-3 w-3' : 'h-4 w-4'; switch (status) { - case "success": + case 'success': return ; - case "failure": + case 'failure': return ; - case "in-progress": + case 'in-progress': return ; - case "missing": + case 'missing': return ; default: return ; @@ -218,52 +186,58 @@ export function TestResults({ const validatedCredentialsRef = useRef({}); const previousCredentialsRef = useRef(credentials); - const initializeTestSteps = (credential?: { - original: any; - decoded: Credential; - }) => { + const initializeTestSteps = (credential?: { original: any; decoded: Credential }) => { if (!credential) { return [ { - id: "proof-type", - name: "Proof Type Detection", - status: "missing", + id: 'proof-type', + name: 'Proof Type Detection', + status: 'missing', }, { - id: "verification", - name: "Credential Verification", - status: "missing", + id: 'verification', + name: 'Credential Verification', + status: 'missing', }, { - id: "schema", - name: "Schema Validation", - status: "missing", + id: 'schema', + name: 'Schema Validation', + status: 'missing', }, ]; } - return [ + const steps = [ { - id: "proof-type", - name: "Proof Type Detection", - status: "success", + id: 'proof-type', + name: 'Proof Type Detection', + status: 'success', details: { - type: isEnvelopedProof(credential.original) - ? "enveloping" - : "embedded", + type: isEnvelopedProof(credential.original) ? 'enveloping' : 'embedded', }, }, { - id: "verification", - name: "Credential Verification", - status: "pending", + id: 'verification', + name: 'Credential Verification', + status: 'pending', }, { - id: "schema", - name: "Schema Validation", - status: "pending", + id: 'schema', + name: 'Schema Validation', + status: 'pending', }, ]; + + const extension = detectExtension(credential.decoded); + + if (extension) { + steps.push({ + id: 'extension-schema', + name: 'Extension Schema Validation', + status: 'pending', + }); + } + return steps; }; // First useEffect for initializing test steps @@ -300,79 +274,66 @@ export function TestResults({ // Set in-progress state setTestResults((prev) => ({ ...prev, - [type as CredentialType]: prev[type as CredentialType]?.map( - (step) => - step.id === "verification" || step.id === "schema" - ? { ...step, status: "in-progress" } - : step + [type as CredentialType]: prev[type as CredentialType]?.map((step) => + step.id === 'verification' || step.id === 'schema' ? { ...step, status: 'in-progress' } : step, ), })); // Verification - const verificationResult = await verifyCredential( - credential.original - ); + const verificationResult = await verifyCredential(credential.original); setTestResults((prev) => ({ ...prev, - [type as CredentialType]: prev[type as CredentialType]?.map( - (step) => - step.id === "verification" - ? { - ...step, - status: verificationResult.verified - ? "success" - : "failure", - details: verificationResult, - } - : step + [type as CredentialType]: prev[type as CredentialType]?.map((step) => + step.id === 'verification' + ? { + ...step, + status: verificationResult.verified ? 'success' : 'failure', + details: verificationResult, + } + : step, ), })); if (!verificationResult.verified) { const errorMessage = - typeof verificationResult.error === "object" - ? verificationResult.error.message || - "The credential could not be verified" - : verificationResult.error || - "The credential could not be verified"; + typeof verificationResult.error === 'object' + ? verificationResult.error.message || 'The credential could not be verified' + : verificationResult.error || 'The credential could not be verified'; - toast.error("Credential verification failed", { + toast.error('Credential verification failed', { description: errorMessage, }); } + // Store reference to validated credential + validatedCredentialsRef.current[credentialType] = { + credential: { + original: credential.original, + decoded: credential.decoded, + }, + validated: true, + }; + + const extension = detectExtension(credential.decoded); + // Schema validation try { - const validationResult = await validateCredentialSchema( - credential.decoded - ); + const validationResult = await validateCredentialSchema(credential.decoded); setTestResults((prev) => ({ ...prev, - [type as CredentialType]: prev[type as CredentialType]?.map( - (step) => - step.id === "schema" - ? { - ...step, - status: validationResult.valid ? "success" : "failure", - details: validationResult, - } - : step + [type as CredentialType]: prev[type as CredentialType]?.map((step) => + step.id === 'schema' + ? { + ...step, + status: validationResult.valid ? 'success' : 'failure', + details: validationResult, + } + : step, ), })); - // Store reference to validated credential - validatedCredentialsRef.current[credentialType] = { - credential: { - original: credential.original, - decoded: credential.decoded, - }, - validated: true, - }; - - if (validationResult.valid) { - if ( - !validatedCredentialsRef.current[credentialType]?.confettiShown - ) { + if (!extension && validationResult.valid) { + if (!validatedCredentialsRef.current[credentialType]?.confettiShown) { confetti({ particleCount: 200, spread: 90, @@ -386,34 +347,91 @@ export function TestResults({ } } } catch (error) { - console.log("Schema validation error:", error); - toast.error("Failed to fetch schema. Please try again."); + console.log('Schema validation error:', error); + toast.error('Failed to fetch schema. Please try again.'); // Only update the schema validation step setTestResults((prev) => ({ ...prev, - [type as CredentialType]: prev[type as CredentialType]?.map( - (step) => - step.id === "schema" + [type as CredentialType]: prev[type as CredentialType]?.map((step) => + step.id === 'schema' + ? { + ...step, + status: 'failure', + details: { + errors: [ + { + keyword: 'schema', + message: 'Failed to fetch schema', + instancePath: '', + }, + ], + }, + } + : step, + ), + })); + } + + // Extension schema validation + if (extension) { + try { + const extensionValidationResult = await validateExtension(credential.decoded); + setTestResults((prev) => ({ + ...prev, + [type as CredentialType]: prev[type as CredentialType]?.map((step) => + step.id === 'extension-schema' ? { ...step, - status: "failure", + status: extensionValidationResult.valid ? 'success' : 'failure', + details: extensionValidationResult, + } + : step, + ), + })); + if (extensionValidationResult.valid) { + if (!validatedCredentialsRef.current[credentialType]?.confettiShown) { + confetti({ + particleCount: 200, + spread: 90, + origin: { y: 0.7 }, + }); + + validatedCredentialsRef.current[credentialType] = { + ...validatedCredentialsRef.current[credentialType]!, + confettiShown: true, + }; + } + } + } catch (error) { + console.log('Extension schema validation error:', error); + toast.error('Failed to fetch extension schema. Please try again.'); + + // Only update the schema validation step + setTestResults((prev) => ({ + ...prev, + [type as CredentialType]: prev[type as CredentialType]?.map((step) => + step.id === 'extension-schema' + ? { + ...step, + status: 'failure', details: { errors: [ { - keyword: "schema", - message: "Failed to fetch schema", - instancePath: "", + keyword: 'schema', + message: 'Failed to fetch extension schema', + instancePath: '', }, ], }, } - : step - ), - })); + : step, + ), + })); + } } } catch (error) { - console.log("Error processing credential:", error); + console.log('Error processing credential:', error); } }; @@ -422,32 +440,28 @@ export function TestResults({ }, [credentials]); const toggleGroup = (groupId: string) => { - setExpandedGroups((prev) => - prev.includes(groupId) - ? prev.filter((id) => id !== groupId) - : [...prev, groupId] - ); + setExpandedGroups((prev) => (prev.includes(groupId) ? prev.filter((id) => id !== groupId) : [...prev, groupId])); }; return ( -
+
{ALL_CREDENTIAL_TYPES.map((type) => { const credential = credentials[type]; const steps = testResults[type] || []; const hasCredential = !!credential; - const proofType = credential - ? isEnvelopedProof(credential.original) - ? "enveloping" - : "embedded" - : "none"; + const extension = hasCredential ? detectExtension(credential.decoded) : null; + const version = hasCredential ? extension?.core?.version || detectVersion(credential.decoded) : 'unknown'; + const extensionCredentialType = extension?.extension?.type; + const extensionVersion = extension?.extension?.version; + const proofType = credential ? (isEnvelopedProof(credential.original) ? 'enveloping' : 'embedded') : 'none'; return ( toggleGroup(type)} @@ -463,19 +477,17 @@ export function TestResults({ // Helper function to extract version function extractVersion(credential: Credential): string { try { - if (!credential["@context"] || !Array.isArray(credential["@context"])) { - return "unknown"; + if (!credential['@context'] || !Array.isArray(credential['@context'])) { + return 'unknown'; } - const contextUrl = credential["@context"].find( + const contextUrl = credential['@context'].find( (ctx) => - typeof ctx === "string" && - (ctx.includes("vocabulary.uncefact.org") || - ctx.includes("test.uncefact.org")) + typeof ctx === 'string' && (ctx.includes('vocabulary.uncefact.org') || ctx.includes('test.uncefact.org')), ); - return contextUrl?.match(/\/(\d+\.\d+\.\d+)\//)?.[1] || "unknown"; + return contextUrl?.match(/\/(\d+\.\d+\.\d+)\//)?.[1] || 'unknown'; } catch { - return "unknown"; + return 'unknown'; } } diff --git a/packages/untp-playground/src/lib/credentialService.ts b/packages/untp-playground/src/lib/credentialService.ts index 6273dda2..f6b30d80 100644 --- a/packages/untp-playground/src/lib/credentialService.ts +++ b/packages/untp-playground/src/lib/credentialService.ts @@ -1,5 +1,5 @@ -import type { Credential } from "@/types/credential"; -import { jwtDecode } from "jwt-decode"; +import type { Credential } from '@/types/credential'; +import { jwtDecode } from 'jwt-decode'; export function decodeEnvelopedCredential(credential: any): Credential { if (!isEnvelopedProof(credential)) { @@ -7,43 +7,42 @@ export function decodeEnvelopedCredential(credential: any): Credential { } try { - const jwtPart = credential.id.split(",")[1]; + const jwtPart = credential.id.split(',')[1]; if (!jwtPart) { return credential; } return jwtDecode(jwtPart); } catch (error) { - console.log("Error processing enveloped credential:", error); + console.log('Error processing enveloped credential:', error); return credential; } } export function detectCredentialType(credential: Credential): string { const types = [ - "DigitalProductPassport", - "DigitalConformityCredential", - "DigitalFacilityRecord", - "DigitalIdentityAnchor", - "DigitalTraceabilityEvent", + 'DigitalProductPassport', + 'DigitalLivestockPassport', + 'DigitalConformityCredential', + 'DigitalFacilityRecord', + 'DigitalIdentityAnchor', + 'DigitalTraceabilityEvent', ]; - return credential.type.find((t) => types.includes(t)) || "Unknown"; + return credential.type.find((t) => types.includes(t)) || 'Unknown'; } -export function detectVersion(credential: Credential): string { - const contextUrl = credential["@context"].find((ctx) => - ctx.includes("vocabulary.uncefact.org") - ); +export function detectVersion(credential: Credential, domain?: string): string { + const contextUrl = credential['@context'].find((ctx) => ctx.includes(domain || 'test.uncefact.org')); - if (!contextUrl) return "unknown"; + if (!contextUrl) return 'unknown'; - const versionMatch = contextUrl.match(/\/(\d+\.\d+\.\d+)\//); - return versionMatch ? versionMatch[1] : "unknown"; + const versionMatch = contextUrl.match(/(\d+\.\d+\.\d+)/); + return versionMatch ? versionMatch[1] : 'unknown'; } export function isEnvelopedProof(credential: any): boolean { const normalizedCredential = credential.verifiableCredential || credential; - return normalizedCredential.type === "EnvelopedVerifiableCredential"; + return normalizedCredential.type === 'EnvelopedVerifiableCredential'; } diff --git a/packages/untp-playground/src/lib/schemaValidation.ts b/packages/untp-playground/src/lib/schemaValidation.ts index 09523554..d767e121 100644 --- a/packages/untp-playground/src/lib/schemaValidation.ts +++ b/packages/untp-playground/src/lib/schemaValidation.ts @@ -1,5 +1,6 @@ import addFormats from 'ajv-formats'; import Ajv2020 from 'ajv/dist/2020'; +import { detectCredentialType, detectVersion } from './credentialService'; const ajv = new Ajv2020({ allErrors: true, @@ -18,18 +19,100 @@ const SCHEMA_URLS = { DigitalIdentityAnchor: 'https://test.uncefact.org/vocabulary/untp/dia/untp-dia-schema-0.2.1.json', }; +const EXTENSION_VERSIONS: Record< + string, + { domain: string; versions: { version: string; core: { type: string; version: string } }[] } +> = { + DigitalLivestockPassport: { + domain: 'aatp.foodagility.com', + versions: [ + { + version: '0.4.0', + core: { type: 'DigitalProductPassport', version: '0.5.0' }, + }, + ], + }, +}; + +const schemaURLConstructor = (type: string, version: string) => { + const shortCredentialTypes: Record = { + DigitalProductPassport: 'dpp', + DigitalConformityCredential: 'dcc', + DigitalTraceabilityEvent: 'dte', + DigitalFacilityRecord: 'dfr', + DigitalIdentityAnchor: 'dia', + }; + return `https://test.uncefact.org/vocabulary/untp/${shortCredentialTypes[type]}/untp-${shortCredentialTypes[type]}-schema-${version}.json`; +}; + +const extensionSchemaURLConstructor = (type: string, version: string) => { + const shortCredentialTypes: Record = { + DigitalLivestockPassport: 'dlp', + }; + if (type === 'DigitalLivestockPassport' && version === '0.4.0') { + return 'https://aatp.foodagility.com/assets/files/aatp-dlp-schema-0.4.0-9c0ad2b1ca6a9e497dedcfd8b87f35f1.json'; + } + return `https://aatp.foodagility.com/vocabulary/aatp/${shortCredentialTypes[type]}/aatp-${shortCredentialTypes[type]}-schema-${version}.json`; +}; + export async function validateCredentialSchema(credential: any): Promise<{ valid: boolean; errors?: any[]; }> { - try { - const credentialType = credential.type.find((t: string) => Object.keys(SCHEMA_URLS).includes(t)); + const extension = detectExtension(credential); + const credentialType = extension ? extension.core.type : detectCredentialType(credential); + + if (credentialType === 'Unknown') { + throw new Error('Unsupported credential type'); + } + + const version = extension?.core?.version || detectVersion(credential); + + if (!version) { + throw new Error('Unsupported version'); + } + + const schemaUrl = schemaURLConstructor(credentialType, version); + + return validateCredentialOnSchemaUrl(credential, schemaUrl, ['type', '@context']); +} + +export async function validateExtension(credential: any): Promise<{ + valid: boolean; + errors?: any[]; +}> { + const extension = detectExtension(credential); + if (!extension) { + throw new Error('Unknown extension'); + } - if (!credentialType) { - throw new Error('Unsupported credential type'); + const schemaUrl = extensionSchemaURLConstructor(extension.extension.type, extension.extension.version); + + return validateCredentialOnSchemaUrl(credential, schemaUrl); +} + +export function detectExtension(credential: any): + | { + core: { type: string; version: string }; + extension: { type: string; version: string }; } + | undefined { + const credentialType = detectCredentialType(credential); + const extension = EXTENSION_VERSIONS[credentialType]; + if (!extension) { + return undefined; + } + const version = detectVersion(credential, extension.domain); + const extensionVersion = extension.versions.find((v) => v.version === version); + if (!extensionVersion) { + return undefined; + } - const schemaUrl = SCHEMA_URLS[credentialType as keyof typeof SCHEMA_URLS]; + return { + core: extensionVersion.core, + extension: { type: credentialType, version }, + }; +} if (!schemaCache.has(schemaUrl)) { const proxyUrl = `/untp-playground/api/schema?url=${encodeURIComponent(schemaUrl)}`; From cbdc20865ff8c2dd50b02f30f256c20b95b09f31 Mon Sep 17 00:00:00 2001 From: Nam Hoang Date: Tue, 10 Dec 2024 10:54:12 +0700 Subject: [PATCH 2/4] feat: enhance relax schema validation for extension --- .../src/lib/schemaValidation.ts | 24 ++++++++++++++++--- 1 file changed, 21 insertions(+), 3 deletions(-) diff --git a/packages/untp-playground/src/lib/schemaValidation.ts b/packages/untp-playground/src/lib/schemaValidation.ts index d767e121..750ff4cd 100644 --- a/packages/untp-playground/src/lib/schemaValidation.ts +++ b/packages/untp-playground/src/lib/schemaValidation.ts @@ -74,7 +74,18 @@ export async function validateCredentialSchema(credential: any): Promise<{ const schemaUrl = schemaURLConstructor(credentialType, version); - return validateCredentialOnSchemaUrl(credential, schemaUrl, ['type', '@context']); + if (extension?.core.type === 'DigitalProductPassport' && extension?.core.version === '0.5.0') { + const relaxFunction = (schema: any) => { + delete schema?.properties?.type?.const; + delete schema?.properties?.type?.items?.enum; + delete schema?.properties?.['@context']?.const; + delete schema?.properties?.['@context']?.items?.enum; + return schema; + }; + return validateCredentialOnSchemaUrl(credential, schemaUrl, relaxFunction); + } + + return validateCredentialOnSchemaUrl(credential, schemaUrl); } export async function validateExtension(credential: any): Promise<{ @@ -114,8 +125,11 @@ export function detectExtension(credential: any): }; } +async function validateCredentialOnSchemaUrl(credential: any, schemaUrl: string, relaxFunction?: (schema: any) => any) { + try { if (!schemaCache.has(schemaUrl)) { - const proxyUrl = `/untp-playground/api/schema?url=${encodeURIComponent(schemaUrl)}`; + const baseUrl = process.env.NEXT_PUBLIC_BASE_PATH || ''; + const proxyUrl = `${baseUrl}/api/schema?url=${encodeURIComponent(schemaUrl)}`; const schemaResponse = await fetch(proxyUrl); if (!schemaResponse.ok) { @@ -126,7 +140,11 @@ export function detectExtension(credential: any): schemaCache.set(schemaUrl, schema); } - const schema = schemaCache.get(schemaUrl); + let schema = schemaCache.get(schemaUrl); + if (relaxFunction) { + schema = relaxFunction(schema); + } + const validate = ajv.compile(schema); const isValid = validate(credential); const errors = validate.errors || []; From eaae4cd1575b392b72081997dbc2bc46cf135088 Mon Sep 17 00:00:00 2001 From: Nam Hoang Date: Tue, 10 Dec 2024 10:55:22 +0700 Subject: [PATCH 3/4] test: update unit test for services --- .../__tests__/app/page.test.tsx | 28 ++- .../__tests__/lib/credentialService.test.ts | 168 +++++++++++++++++ .../__tests__/lib/schemaValidation.test.ts | 172 ++++++++++++++++++ 3 files changed, 366 insertions(+), 2 deletions(-) create mode 100644 packages/untp-playground/__tests__/lib/credentialService.test.ts create mode 100644 packages/untp-playground/__tests__/lib/schemaValidation.test.ts diff --git a/packages/untp-playground/__tests__/app/page.test.tsx b/packages/untp-playground/__tests__/app/page.test.tsx index 8e5584af..045e9294 100644 --- a/packages/untp-playground/__tests__/app/page.test.tsx +++ b/packages/untp-playground/__tests__/app/page.test.tsx @@ -1,8 +1,14 @@ import { render, screen, fireEvent, waitFor } from '@testing-library/react'; import { toast } from 'sonner'; -import { decodeEnvelopedCredential, isEnvelopedProof } from '@/lib/credentialService'; +import { + decodeEnvelopedCredential, + isEnvelopedProof, + detectCredentialType, + detectVersion, +} from '@/lib/credentialService'; +import { detectExtension, validateCredentialSchema } from '@/lib/schemaValidation'; import { CredentialUploader } from '@/components/CredentialUploader'; -import Home from '../../src/app/page'; +import Home from '@/app/page'; import { mockCredential } from '../mocks/vc'; // Mock the dependencies @@ -15,6 +21,13 @@ jest.mock('sonner', () => ({ jest.mock('@/lib/credentialService', () => ({ isEnvelopedProof: jest.fn(), decodeEnvelopedCredential: jest.fn(), + detectCredentialType: jest.fn(), + detectVersion: jest.fn(), +})); + +jest.mock('@/lib/schemaValidation', () => ({ + detectExtension: jest.fn(), + validateCredentialSchema: jest.fn(), })); // Mock child components @@ -68,6 +81,10 @@ describe('Home Component', () => { it('handles valid credential upload', async () => { (isEnvelopedProof as jest.Mock).mockReturnValue(false); + (detectCredentialType as jest.Mock).mockReturnValue('DigitalProductPassport'); + (detectVersion as jest.Mock).mockReturnValue('0.5.0'); + (detectExtension as jest.Mock).mockReturnValue(undefined); + (validateCredentialSchema as jest.Mock).mockReturnValue({ valid: true }); render(); @@ -108,6 +125,9 @@ describe('Home Component', () => { ); (isEnvelopedProof as jest.Mock).mockReturnValue(false); + (detectExtension as jest.Mock).mockReturnValue(undefined); + (detectCredentialType as jest.Mock).mockReturnValue('Unknown'); + (detectVersion as jest.Mock).mockReturnValue('0.1.0'); render(); @@ -126,6 +146,10 @@ describe('Home Component', () => { (isEnvelopedProof as jest.Mock).mockReturnValue(true); (decodeEnvelopedCredential as jest.Mock).mockReturnValue(mockEnvelopedCredential); + (detectCredentialType as jest.Mock).mockReturnValue('DigitalProductPassport'); + (detectVersion as jest.Mock).mockReturnValue('0.5.0'); + (detectExtension as jest.Mock).mockReturnValue(undefined); + (validateCredentialSchema as jest.Mock).mockReturnValue({ valid: true }); render(); diff --git a/packages/untp-playground/__tests__/lib/credentialService.test.ts b/packages/untp-playground/__tests__/lib/credentialService.test.ts new file mode 100644 index 00000000..e08aa720 --- /dev/null +++ b/packages/untp-playground/__tests__/lib/credentialService.test.ts @@ -0,0 +1,168 @@ +import { + decodeEnvelopedCredential, + detectCredentialType, + detectVersion, + isEnvelopedProof, +} from '@/lib/credentialService'; +import { jwtDecode } from 'jwt-decode'; + +// Mock jwt-decode +jest.mock('jwt-decode'); + +describe('credentialService', () => { + describe('decodeEnvelopedCredential', () => { + beforeEach(() => { + (jwtDecode as jest.Mock).mockClear(); + }); + + it('should return original credential if not enveloped', () => { + const credential = { + type: ['DigitalProductPassport'], + '@context': ['https://test.uncefact.org/vocabulary/untp/dpp/0.5.0'], + }; + + const result = decodeEnvelopedCredential(credential); + expect(result).toBe(credential); + }); + + it('should decode JWT from enveloped credential', () => { + const mockDecodedCredential = { + type: ['DigitalProductPassport'], + '@context': ['https://test.uncefact.org/vocabulary/untp/dpp/0.5.0'], + }; + + (jwtDecode as jest.Mock).mockReturnValue(mockDecodedCredential); + + const envelopedCredential = { + type: 'EnvelopedVerifiableCredential', + id: 'did:example:123,eyJhbGciOiJFUzI1NksifQ', + }; + + const result = decodeEnvelopedCredential(envelopedCredential); + expect(result).toEqual(mockDecodedCredential); + expect(jwtDecode).toHaveBeenCalledWith('eyJhbGciOiJFUzI1NksifQ'); + }); + + it('should handle missing JWT part', () => { + const envelopedCredential = { + type: 'EnvelopedVerifiableCredential', + id: 'did:example:123', + }; + + const result = decodeEnvelopedCredential(envelopedCredential); + expect(result).toBe(envelopedCredential); + }); + + it('should handle JWT decode errors', () => { + (jwtDecode as jest.Mock).mockImplementation(() => { + throw new Error('Invalid JWT'); + }); + + const envelopedCredential = { + type: 'EnvelopedVerifiableCredential', + id: 'did:example:123,invalid-jwt', + }; + + const result = decodeEnvelopedCredential(envelopedCredential); + expect(result).toBe(envelopedCredential); + }); + }); + + describe('detectCredentialType', () => { + it('should detect DigitalProductPassport', () => { + const credential = { + type: ['VerifiableCredential', 'DigitalProductPassport'], + '@context': ['https://test.uncefact.org/vocabulary/untp/dpp/0.5.0'], + }; + + expect(detectCredentialType(credential)).toBe('DigitalProductPassport'); + }); + + it('should detect DigitalLivestockPassport', () => { + const credential = { + type: ['VerifiableCredential', 'DigitalLivestockPassport'], + '@context': ['https://aatp.foodagility.com/vocabulary/aatp/dlp/0.4.0'], + }; + + expect(detectCredentialType(credential)).toBe('DigitalLivestockPassport'); + }); + + it('should return Unknown for unsupported type', () => { + const credential = { + type: ['VerifiableCredential', 'UnsupportedType'], + '@context': ['https://example.com'], + }; + + expect(detectCredentialType(credential)).toBe('Unknown'); + }); + }); + + describe('detectVersion', () => { + it('should detect version from UNTP context', () => { + const credential = { + type: ['DigitalProductPassport'], + '@context': ['https://test.uncefact.org/vocabulary/untp/dpp/0.5.0'], + }; + + expect(detectVersion(credential)).toBe('0.5.0'); + }); + + it('should detect version from custom domain', () => { + const credential = { + type: ['DigitalLivestockPassport'], + '@context': ['https://aatp.foodagility.com/vocabulary/aatp/dlp/0.4.0'], + }; + + expect(detectVersion(credential, 'aatp.foodagility.com')).toBe('0.4.0'); + }); + + it('should return unknown for missing version', () => { + const credential = { + type: ['DigitalProductPassport'], + '@context': ['https://test.uncefact.org/vocabulary/untp/dpp'], + }; + + expect(detectVersion(credential)).toBe('unknown'); + }); + + it('should return unknown for missing context', () => { + const credential = { + type: ['DigitalProductPassport'], + '@context': ['https://example.com'], + }; + + expect(detectVersion(credential)).toBe('unknown'); + }); + }); + + describe('isEnvelopedProof', () => { + it('should detect enveloped proof', () => { + const credential = { + type: 'EnvelopedVerifiableCredential', + id: 'did:example:123,jwt', + }; + + expect(isEnvelopedProof(credential)).toBe(true); + }); + + it('should detect enveloped proof in verifiableCredential', () => { + const credential = { + verifiableCredential: { + type: 'EnvelopedVerifiableCredential', + id: 'did:example:123,jwt', + }, + }; + + expect(isEnvelopedProof(credential)).toBe(true); + }); + + it('should return false for non-enveloped credential', () => { + const credential = { + type: ['DigitalProductPassport'], + '@context': ['https://test.uncefact.org/vocabulary/untp/dpp/0.5.0'], + }; + + expect(isEnvelopedProof(credential)).toBe(false); + }); + }); +}); diff --git a/packages/untp-playground/__tests__/lib/schemaValidation.test.ts b/packages/untp-playground/__tests__/lib/schemaValidation.test.ts new file mode 100644 index 00000000..02af9ac2 --- /dev/null +++ b/packages/untp-playground/__tests__/lib/schemaValidation.test.ts @@ -0,0 +1,172 @@ +import { validateCredentialSchema, validateExtension, detectExtension } from '@/lib/schemaValidation'; +import { detectCredentialType, detectVersion } from '@/lib/credentialService'; + +// Mock the global fetch +global.fetch = jest.fn(); + +jest.mock('@/lib/credentialService', () => ({ + detectCredentialType: jest.fn(), + detectVersion: jest.fn(), +})); + +describe('schemaValidation', () => { + beforeEach(() => { + // Clear all mocks before each test + (global.fetch as jest.Mock).mockClear(); + (detectCredentialType as jest.Mock).mockClear(); + (detectVersion as jest.Mock).mockClear(); + }); + + describe('validateCredentialSchema', () => { + it('should validate a valid DPP credential', async () => { + const mockSchema = { + $schema: 'https://json-schema.org/draft/2020-12/schema', + properties: { + type: { type: 'string' }, + '@context': { type: 'array' }, + }, + }; + + (global.fetch as jest.Mock).mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve(mockSchema), + }); + + const validCredential = { + type: 'DigitalProductPassport', + '@context': ['https://test.uncefact.org/vocabulary/untp/dpp/0.5.0'], + version: '0.5.0', + }; + + (detectCredentialType as jest.Mock).mockReturnValue('DigitalProductPassport'); + (detectVersion as jest.Mock).mockReturnValue('0.5.0'); + + const result = await validateCredentialSchema(validCredential); + expect(result.valid).toBe(true); + expect(result.errors).toEqual([]); + }); + + it('should validate a valid DLP credential', async () => { + const validCredential = { + type: 'DigitalLivestockPassport', + '@context': ['https://aatp.foodagility.com/vocabulary/aatp/dlp/0.4.0'], + version: '0.4.0', + }; + + (detectCredentialType as jest.Mock).mockReturnValue('DigitalLivestockPassport'); + (detectVersion as jest.Mock).mockReturnValue('0.4.0'); + + const result = await validateCredentialSchema(validCredential); + expect(result.valid).toBe(true); + expect(result.errors).toEqual([]); + }); + + it('should throw error for unsupported credential type', async () => { + const invalidCredential = { + type: 'UnsupportedType', + version: '0.5.0', + }; + + (detectCredentialType as jest.Mock).mockReturnValue('Unknown'); + + await expect(validateCredentialSchema(invalidCredential)).rejects.toThrow('Unsupported credential type'); + }); + + it('should throw error for missing version', async () => { + const invalidCredential = { + type: 'DigitalProductPassport', + }; + + (detectCredentialType as jest.Mock).mockReturnValue('DigitalProductPassport'); + (detectVersion as jest.Mock).mockReturnValue(undefined); + + await expect(validateCredentialSchema(invalidCredential)).rejects.toThrow('Unsupported version'); + }); + }); + + describe('validateExtension', () => { + it('should validate a specific extension credential', async () => { + const mockSchema = { + $schema: 'https://json-schema.org/draft/2020-12/schema', + properties: { + type: { type: 'string' }, + }, + }; + + (global.fetch as jest.Mock).mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve(mockSchema), + }); + + const validExtensionCredential = { + type: 'DigitalLivestockPassport', + '@context': ['https://aatp.foodagility.com/vocabulary/aatp/dlp/0.4.0'], + version: '0.4.0', + }; + + (detectCredentialType as jest.Mock).mockReturnValue('DigitalLivestockPassport'); + (detectVersion as jest.Mock).mockReturnValue('0.4.0'); + + const result = await validateExtension(validExtensionCredential); + expect(result.valid).toBe(true); + expect(result.errors).toEqual([]); + }); + + it('should throw error for unknown extension', async () => { + const invalidCredential = { + type: 'UnknownExtension', + version: '0.1.0', + }; + + (detectCredentialType as jest.Mock).mockReturnValue('UnknownExtension'); + (detectVersion as jest.Mock).mockReturnValue('0.1.0'); + + await expect(validateExtension(invalidCredential)).rejects.toThrow('Unknown extension'); + }); + }); + + describe('detectExtension', () => { + it('should detect a valid extension', () => { + const credential = { + type: 'DigitalLivestockPassport', + '@context': ['https://aatp.foodagility.com/vocabulary/aatp/dlp/0.4.0'], + version: '0.4.0', + }; + + (detectCredentialType as jest.Mock).mockReturnValue('DigitalLivestockPassport'); + (detectVersion as jest.Mock).mockReturnValue('0.4.0'); + + const result = detectExtension(credential); + expect(result).toEqual({ + core: { type: 'DigitalProductPassport', version: '0.5.0' }, + extension: { type: 'DigitalLivestockPassport', version: '0.4.0' }, + }); + }); + + it('should return undefined for non-extension credential', () => { + const credential = { + type: 'DigitalProductPassport', + version: '0.5.0', + }; + + (detectCredentialType as jest.Mock).mockReturnValue('DigitalProductPassport'); + + const result = detectExtension(credential); + expect(result).toBeUndefined(); + }); + + it('should return undefined for unknown version', () => { + const credential = { + type: 'DigitalLivestockPassport', + '@context': ['https://aatp.foodagility.com/vocabulary/aatp/dlp/999.999.999'], + version: '999.999.999', + }; + + (detectCredentialType as jest.Mock).mockReturnValue('DigitalLivestockPassport'); + (detectVersion as jest.Mock).mockReturnValue('999.999.999'); + + const result = detectExtension(credential); + expect(result).toBeUndefined(); + }); + }); +}); From 427c3e9e324fac2c3659d0090c95676854c4c342 Mon Sep 17 00:00:00 2001 From: Nam Hoang Date: Tue, 10 Dec 2024 16:48:12 +0700 Subject: [PATCH 4/4] refactor: extension configuration data --- .../__tests__/lib/credentialService.test.ts | 12 ++--- .../src/lib/schemaValidation.ts | 44 ++++++++++--------- 2 files changed, 30 insertions(+), 26 deletions(-) diff --git a/packages/untp-playground/__tests__/lib/credentialService.test.ts b/packages/untp-playground/__tests__/lib/credentialService.test.ts index e08aa720..2cc19830 100644 --- a/packages/untp-playground/__tests__/lib/credentialService.test.ts +++ b/packages/untp-playground/__tests__/lib/credentialService.test.ts @@ -35,18 +35,18 @@ describe('credentialService', () => { const envelopedCredential = { type: 'EnvelopedVerifiableCredential', - id: 'did:example:123,eyJhbGciOiJFUzI1NksifQ', + id: 'data:application/vc-ld+jwt,eyJhbGciOiJFZERTQSIsIm', }; const result = decodeEnvelopedCredential(envelopedCredential); expect(result).toEqual(mockDecodedCredential); - expect(jwtDecode).toHaveBeenCalledWith('eyJhbGciOiJFUzI1NksifQ'); + expect(jwtDecode).toHaveBeenCalledWith('eyJhbGciOiJFZERTQSIsIm'); }); it('should handle missing JWT part', () => { const envelopedCredential = { type: 'EnvelopedVerifiableCredential', - id: 'did:example:123', + id: 'data:application/vc+jwt', }; const result = decodeEnvelopedCredential(envelopedCredential); @@ -60,7 +60,7 @@ describe('credentialService', () => { const envelopedCredential = { type: 'EnvelopedVerifiableCredential', - id: 'did:example:123,invalid-jwt', + id: 'data:application/vc+jwt,invalid-jwt', }; const result = decodeEnvelopedCredential(envelopedCredential); @@ -139,7 +139,7 @@ describe('credentialService', () => { it('should detect enveloped proof', () => { const credential = { type: 'EnvelopedVerifiableCredential', - id: 'did:example:123,jwt', + id: 'data:application/vc+jwt,eyJhbGciOiJFZERTQSIsIm', }; expect(isEnvelopedProof(credential)).toBe(true); @@ -149,7 +149,7 @@ describe('credentialService', () => { const credential = { verifiableCredential: { type: 'EnvelopedVerifiableCredential', - id: 'did:example:123,jwt', + id: 'data:application/vc+jwt,eyJhbGciOiJFZERTQSIsIm', }, }; diff --git a/packages/untp-playground/src/lib/schemaValidation.ts b/packages/untp-playground/src/lib/schemaValidation.ts index 750ff4cd..bebfd61d 100644 --- a/packages/untp-playground/src/lib/schemaValidation.ts +++ b/packages/untp-playground/src/lib/schemaValidation.ts @@ -11,23 +11,29 @@ addFormats(ajv); const schemaCache = new Map(); -const SCHEMA_URLS = { - DigitalProductPassport: 'https://test.uncefact.org/vocabulary/untp/dpp/untp-dpp-schema-0.5.0.json', - DigitalConformityCredential: 'https://test.uncefact.org/vocabulary/untp/dcc/untp-dcc-schema-0.5.0.json', - DigitalTraceabilityEvent: 'https://test.uncefact.org/vocabulary/untp/dte/untp-dte-schema-0.5.0.json', - DigitalFacilityRecord: 'https://test.uncefact.org/vocabulary/untp/dfr/untp-dfr-schema-0.5.0.json', - DigitalIdentityAnchor: 'https://test.uncefact.org/vocabulary/untp/dia/untp-dia-schema-0.2.1.json', -}; +interface CoreVersion { + type: string; + version: string; +} + +interface ExtensionVersion { + version: string; + schema: string; + core: CoreVersion; +} + +interface ExtensionConfig { + domain: string; + versions: ExtensionVersion[]; +} -const EXTENSION_VERSIONS: Record< - string, - { domain: string; versions: { version: string; core: { type: string; version: string } }[] } -> = { +const EXTENSION_VERSIONS: Record = { DigitalLivestockPassport: { domain: 'aatp.foodagility.com', versions: [ { version: '0.4.0', + schema: 'https://aatp.foodagility.com/assets/files/aatp-dlp-schema-0.4.0-9c0ad2b1ca6a9e497dedcfd8b87f35f1.json', core: { type: 'DigitalProductPassport', version: '0.5.0' }, }, ], @@ -45,14 +51,8 @@ const schemaURLConstructor = (type: string, version: string) => { return `https://test.uncefact.org/vocabulary/untp/${shortCredentialTypes[type]}/untp-${shortCredentialTypes[type]}-schema-${version}.json`; }; -const extensionSchemaURLConstructor = (type: string, version: string) => { - const shortCredentialTypes: Record = { - DigitalLivestockPassport: 'dlp', - }; - if (type === 'DigitalLivestockPassport' && version === '0.4.0') { - return 'https://aatp.foodagility.com/assets/files/aatp-dlp-schema-0.4.0-9c0ad2b1ca6a9e497dedcfd8b87f35f1.json'; - } - return `https://aatp.foodagility.com/vocabulary/aatp/${shortCredentialTypes[type]}/aatp-${shortCredentialTypes[type]}-schema-${version}.json`; +const findExtensionSchemaURL = (type: string, version: string) => { + return EXTENSION_VERSIONS[type].versions.find((v) => v.version === version)?.schema; }; export async function validateCredentialSchema(credential: any): Promise<{ @@ -97,7 +97,11 @@ export async function validateExtension(credential: any): Promise<{ throw new Error('Unknown extension'); } - const schemaUrl = extensionSchemaURLConstructor(extension.extension.type, extension.extension.version); + const schemaUrl = findExtensionSchemaURL(extension.extension.type, extension.extension.version); + + if (!schemaUrl) { + throw new Error('Unsupported extension version'); + } return validateCredentialOnSchemaUrl(credential, schemaUrl); }