diff --git a/README.md b/README.md index 0ed074f0..5bfa13bc 100644 --- a/README.md +++ b/README.md @@ -179,4 +179,4 @@ Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and -limitations under the License. \ No newline at end of file +limitations under the License. diff --git a/app/src/main/java/org/who/gdhcnvalidator/views/ResultFragment.kt b/app/src/main/java/org/who/gdhcnvalidator/views/ResultFragment.kt index 280ae4d3..d73fb1ef 100644 --- a/app/src/main/java/org/who/gdhcnvalidator/views/ResultFragment.kt +++ b/app/src/main/java/org/who/gdhcnvalidator/views/ResultFragment.kt @@ -1,14 +1,16 @@ package org.who.gdhcnvalidator.views import android.annotation.SuppressLint +import android.app.AlertDialog +import android.content.Intent import android.graphics.PorterDuff +import android.net.Uri import android.os.Bundle import android.util.TypedValue import android.view.LayoutInflater import android.view.View import android.view.ViewGroup -import android.widget.LinearLayout -import android.widget.TextView +import android.widget.* import androidx.fragment.app.Fragment import androidx.navigation.fragment.findNavController import androidx.navigation.fragment.navArgs @@ -21,6 +23,7 @@ import org.who.gdhcnvalidator.R import org.who.gdhcnvalidator.databinding.FragmentResultBinding import org.who.gdhcnvalidator.services.DDCCFormatter import org.who.gdhcnvalidator.trust.TrustRegistry +import org.who.gdhcnvalidator.verify.hcert.healthlink.VhlVerifier import kotlin.time.ExperimentalTime import kotlin.time.measureTimedValue @@ -45,6 +48,10 @@ class ResultFragment : Fragment() { QRDecoder.Status.REVOKED_KEYS to R.string.verification_status_revoked_keys, QRDecoder.Status.INVALID_SIGNATURE to R.string.verification_status_invalid_signature, QRDecoder.Status.VERIFIED to R.string.verification_status_verified, + // VHL specific statuses (we'll need to add these strings) + QRDecoder.Status.VHL_REQUIRES_PIN to R.string.vhl_status_requires_pin, + QRDecoder.Status.VHL_INVALID_URI to R.string.vhl_status_invalid_uri, + QRDecoder.Status.VHL_FETCH_ERROR to R.string.vhl_status_fetch_error, ) override fun onCreateView( @@ -152,42 +159,51 @@ class ResultFragment : Fragment() { if (DDCC.contents != null) { binding?.tvResultCard?.visibility = TextView.VISIBLE - val card = DDCCFormatter().run(DDCC.composition()!!) - - // Credential - setTextView(binding?.tvResultScanDate, card.cardTitle, binding?.tvResultScanDate) - setTextView(binding?.tvResultValidUntil, card.validUntil, binding?.llResultValidUntil) - - // Patient - setTextView(binding?.tvResultName, card.personName, binding?.tvResultName) - setTextView(binding?.tvResultPersonDetails, card.personDetails, binding?.tvResultPersonDetails) - setTextView(binding?.tvResultIdentifier, card.identifier, binding?.tvResultIdentifier) - - // Location, Practice, Practitioner - setTextView(binding?.tvResultHcid, card.hcid, binding?.llResultHcid) - setTextView(binding?.tvResultPha, card.pha, binding?.llResultPha) - setTextView(binding?.tvResultHw, card.hw, binding?.llResultHw) - - // Test Result - setTextView(binding?.tvResultTestType, card.testType, binding?.tvResultTestType) - setTextView(binding?.tvResultTestTypeDetail, card.testTypeDetail, binding?.llResultTestTypeDetail) - setTextView(binding?.tvResultTestDate, card.testDate, binding?.llResultTestDate) - setTextView(binding?.tvResultTestTitle, card.testResult, binding?.tvResultTestTitle) - - // Immunization - setTextView(binding?.tvResultVaccineType, card.vaccineType, binding?.tvResultVaccineType) - setTextView(binding?.tvResultDoseTitle, card.dose, binding?.tvResultDoseTitle) - setTextView(binding?.tvResultDoseDate, card.doseDate, binding?.llResultDoseDate) - setTextView(binding?.tvResultVaccineValid, card.vaccineValid, binding?.llResultVaccineValid) - setTextView(binding?.tvResultVaccineInfo, card.vaccineInfo, binding?.llResultVaccineInfo) - setTextView(binding?.tvResultVaccineInfo2, card.vaccineInfo2, binding?.llResultVaccineInfo2) - setTextView(binding?.tvResultCentre, card.location, binding?.llResultCentre) - - // Recommendation - setTextView(binding?.tvResultNextDose, card.nextDose, binding?.llResultNextDose) - - // Status - binding?.llResultStatus?.removeAllViews() + // Check if this is a VHL result with file list + if (DDCC.vhlInfo?.fileList != null) { + showVhlFileList(DDCC.vhlInfo.fileList) + } else { + // Traditional health certificate display + val card = DDCCFormatter().run(DDCC.composition()!!) + + // Credential + setTextView(binding?.tvResultScanDate, card.cardTitle, binding?.tvResultScanDate) + setTextView(binding?.tvResultValidUntil, card.validUntil, binding?.llResultValidUntil) + + // Patient + setTextView(binding?.tvResultName, card.personName, binding?.tvResultName) + setTextView(binding?.tvResultPersonDetails, card.personDetails, binding?.tvResultPersonDetails) + setTextView(binding?.tvResultIdentifier, card.identifier, binding?.tvResultIdentifier) + + // Location, Practice, Practitioner + setTextView(binding?.tvResultHcid, card.hcid, binding?.llResultHcid) + setTextView(binding?.tvResultPha, card.pha, binding?.llResultPha) + setTextView(binding?.tvResultHw, card.hw, binding?.llResultHw) + + // Test Result + setTextView(binding?.tvResultTestType, card.testType, binding?.tvResultTestType) + setTextView(binding?.tvResultTestTypeDetail, card.testTypeDetail, binding?.llResultTestTypeDetail) + setTextView(binding?.tvResultTestDate, card.testDate, binding?.llResultTestDate) + setTextView(binding?.tvResultTestTitle, card.testResult, binding?.tvResultTestTitle) + + // Immunization + setTextView(binding?.tvResultVaccineType, card.vaccineType, binding?.tvResultVaccineType) + setTextView(binding?.tvResultDoseTitle, card.dose, binding?.tvResultDoseTitle) + setTextView(binding?.tvResultDoseDate, card.doseDate, binding?.llResultDoseDate) + setTextView(binding?.tvResultVaccineValid, card.vaccineValid, binding?.llResultVaccineValid) + setTextView(binding?.tvResultVaccineInfo, card.vaccineInfo, binding?.llResultVaccineInfo) + setTextView(binding?.tvResultVaccineInfo2, card.vaccineInfo2, binding?.llResultVaccineInfo2) + setTextView(binding?.tvResultCentre, card.location, binding?.llResultCentre) + + // Recommendation + setTextView(binding?.tvResultNextDose, card.nextDose, binding?.llResultNextDose) + + // Status + binding?.llResultStatus?.removeAllViews() + } + } else if (DDCC.status == QRDecoder.Status.VHL_REQUIRES_PIN) { + // Show PIN entry interface + showVhlPinEntry(DDCC) } } @@ -281,6 +297,211 @@ class ResultFragment : Fragment() { null } } + + /** + * Shows PIN entry dialog for VHL + */ + private fun showVhlPinEntry(vhlResult: QRDecoder.VerificationResult) { + val input = EditText(requireContext()) + input.hint = "Enter PIN" + input.inputType = android.text.InputType.TYPE_CLASS_NUMBER or android.text.InputType.TYPE_NUMBER_VARIATION_PASSWORD + + AlertDialog.Builder(requireContext()) + .setTitle("PIN Required") + .setMessage("This Verifiable Health Link requires a PIN to access the manifest.") + .setView(input) + .setPositiveButton("OK") { _, _ -> + val pin = input.text.toString() + fetchVhlManifestWithPin(vhlResult, pin) + } + .setNegativeButton("Cancel") { dialog, _ -> + dialog.cancel() + } + .show() + } + + /** + * Fetches VHL manifest with PIN and updates the display + */ + private fun fetchVhlManifestWithPin(vhlResult: QRDecoder.VerificationResult, pin: String) { + CoroutineScope(Dispatchers.Main + Job()).launch { + val decodedLink = vhlResult.vhlInfo?.decodedLink + if (decodedLink != null) { + withContext(Dispatchers.IO) { + val vhlVerifier = VhlVerifier() + val request = VhlVerifier.VhlManifestRequest(decodedLink.url, pin) + val manifest = vhlVerifier.fetchManifest(request) + + withContext(Dispatchers.Main) { + if (manifest != null) { + val fileList = vhlVerifier.extractFileList(manifest) + showVhlFileList(fileList) + } else { + Toast.makeText(requireContext(), "Failed to fetch manifest. Check your PIN.", Toast.LENGTH_LONG).show() + } + } + } + } + } + } + + /** + * Shows the list of files available in the VHL manifest + */ + private fun showVhlFileList(fileList: List) { + // Clear existing content and show file list + clearResultFields() + + // Get the main content container + val resultCard = binding?.root?.findViewById(R.id.tv_result_card2) + resultCard?.removeAllViews() + + // Add header + val header = TextView(requireContext()) + header.text = "Available Files" + header.textSize = 18f + header.setTypeface(null, android.graphics.Typeface.BOLD) + header.setPadding(0, 0, 0, 16) + resultCard?.addView(header) + + // Add each file as a clickable item + fileList.forEach { file -> + val fileItem = createFileListItem(file) + resultCard?.addView(fileItem) + } + + binding?.tvResultCard?.visibility = View.VISIBLE + } + + /** + * Creates a clickable item for each file in the VHL manifest + */ + private fun createFileListItem(file: VhlVerifier.VhlFileInfo): View { + val fileItem = LinearLayout(requireContext()) + fileItem.orientation = LinearLayout.HORIZONTAL + fileItem.setPadding(16, 16, 16, 16) + fileItem.isClickable = true + + // Add some background styling + val typedValue = TypedValue() + requireContext().theme.resolveAttribute(android.R.attr.selectableItemBackground, typedValue, true) + fileItem.setBackgroundResource(typedValue.resourceId) + + // File icon based on type + val icon = ImageView(requireContext()) + when (file.type) { + "PDF" -> icon.setImageResource(android.R.drawable.ic_menu_edit) + "FHIR_IPS" -> icon.setImageResource(android.R.drawable.ic_menu_info_details) + else -> icon.setImageResource(android.R.drawable.ic_menu_help) + } + icon.layoutParams = LinearLayout.LayoutParams(64, 64) + fileItem.addView(icon) + + // File details + val textContainer = LinearLayout(requireContext()) + textContainer.orientation = LinearLayout.VERTICAL + textContainer.layoutParams = LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1f) + textContainer.setPadding(16, 0, 0, 0) + + val title = TextView(requireContext()) + title.text = file.title + title.textSize = 16f + title.setTypeface(null, android.graphics.Typeface.BOLD) + textContainer.addView(title) + + val subtitle = TextView(requireContext()) + subtitle.text = "Type: ${file.type}" + subtitle.textSize = 14f + textContainer.addView(subtitle) + + if (file.size != null) { + val size = TextView(requireContext()) + size.text = "Size: ${file.size} bytes" + size.textSize = 12f + textContainer.addView(size) + } + + fileItem.addView(textContainer) + + // Click handler + fileItem.setOnClickListener { + handleFileClick(file) + } + + // Add some margin between items + val layoutParams = LinearLayout.LayoutParams( + LinearLayout.LayoutParams.MATCH_PARENT, + LinearLayout.LayoutParams.WRAP_CONTENT + ) + layoutParams.setMargins(0, 8, 0, 8) + fileItem.layoutParams = layoutParams + + return fileItem + } + + /** + * Handles clicks on file items + */ + private fun handleFileClick(file: VhlVerifier.VhlFileInfo) { + when (file.type) { + "PDF" -> { + if (file.url != null) { + // Open PDF in browser or external app + val intent = Intent(Intent.ACTION_VIEW, Uri.parse(file.url)) + startActivity(intent) + } else { + Toast.makeText(requireContext(), "PDF URL not available", Toast.LENGTH_SHORT).show() + } + } + "FHIR_IPS" -> { + // Display FHIR IPS content + showFhirIpsDialog(file) + } + else -> { + Toast.makeText(requireContext(), "File type not supported: ${file.type}", Toast.LENGTH_SHORT).show() + } + } + } + + /** + * Shows FHIR IPS content in a dialog + */ + private fun showFhirIpsDialog(file: VhlVerifier.VhlFileInfo) { + AlertDialog.Builder(requireContext()) + .setTitle(file.title) + .setMessage("FHIR IPS Document\n\nThis contains structured health information that would be processed and displayed in a production implementation.") + .setPositiveButton("OK") { dialog, _ -> + dialog.dismiss() + } + .show() + } + + /** + * Clears all result fields for VHL display + */ + private fun clearResultFields() { + binding?.tvResultScanDate?.visibility = View.GONE + binding?.llResultValidUntil?.visibility = View.GONE + binding?.tvResultName?.visibility = View.GONE + binding?.tvResultPersonDetails?.visibility = View.GONE + binding?.tvResultIdentifier?.visibility = View.GONE + binding?.llResultHcid?.visibility = View.GONE + binding?.llResultPha?.visibility = View.GONE + binding?.llResultHw?.visibility = View.GONE + binding?.tvResultTestType?.visibility = View.GONE + binding?.llResultTestTypeDetail?.visibility = View.GONE + binding?.llResultTestDate?.visibility = View.GONE + binding?.tvResultTestTitle?.visibility = View.GONE + binding?.tvResultVaccineType?.visibility = View.GONE + binding?.tvResultDoseTitle?.visibility = View.GONE + binding?.llResultDoseDate?.visibility = View.GONE + binding?.llResultVaccineValid?.visibility = View.GONE + binding?.llResultVaccineInfo?.visibility = View.GONE + binding?.llResultVaccineInfo2?.visibility = View.GONE + binding?.llResultCentre?.visibility = View.GONE + binding?.llResultNextDose?.visibility = View.GONE + binding?.llResultStatus?.visibility = View.GONE + } override fun onDestroyView() { super.onDestroyView() diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 043632d3..e5e26812 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -51,4 +51,9 @@ Invalid Signature Signature Verified Signature Verified (Dev) + + + PIN Required for VHL + Invalid VHL URI + VHL Manifest Fetch Error \ No newline at end of file diff --git a/docs/data-models.md b/docs/data-models.md index 8ab5b1ae..94481305 100644 --- a/docs/data-models.md +++ b/docs/data-models.md @@ -171,13 +171,32 @@ WHO's international vaccination certificate standard. **Mapper:** [`DvcMapper`](../verify/src/main/java/org/who/gdhcnvalidator/verify/hcert/icvp/DvcMapper.kt) -### 7. Smart Health Links +### 7. Smart Health Links and Verifiable Health Links (VHL) **Source:** [`verify/src/main/java/org/who/gdhcnvalidator/verify/hcert/healthlink/SmartHealthLinkModel.kt`](../verify/src/main/java/org/who/gdhcnvalidator/verify/hcert/healthlink/SmartHealthLinkModel.kt) -Support for Smart Health Links protocol for sharing health data. +Support for Smart Health Links (SHL) and Verifiable Health Links (VHL) protocol for sharing health data. -**Mapper:** [`HealthLinkMapper`](../verify/src/main/java/org/who/gdhcnvalidator/verify/hcert/healthlink/HealthLinkMapper.kt) (TODO: Implementation pending) +#### SmartHealthLinkModel +**Key Fields:** +- `u: StringType` - URI containing the link to health data manifest + +**VHL Features:** +- Supports both `vhlink:/` and `shlink:/` URI formats +- Base64 decoding of URI payload to extract manifest URL +- PIN requirement detection and handling +- Manifest fetching with optional PIN parameter + +#### VHL Processing Flow +1. **URI Detection**: QR codes starting with `vhlink:/` or `shlink:/` are identified as VHL +2. **URI Decoding**: Base64 decode the URI payload to extract manifest URL and flags +3. **PIN Handling**: If PIN is required (P flag), prompt user for PIN entry +4. **Manifest Fetching**: HTTP request to retrieve FHIR SearchSet Bundle manifest +5. **File Extraction**: Parse manifest to extract available files (PDF, FHIR IPS) +6. **User Interface**: Display file list with download/view options + +**Mapper:** [`HealthLinkMapper`](../verify/src/main/java/org/who/gdhcnvalidator/verify/hcert/healthlink/HealthLinkMapper.kt) +**Verifier:** [`VhlVerifier`](../verify/src/main/java/org/who/gdhcnvalidator/verify/hcert/healthlink/VhlVerifier.kt) ## Data Flow and Transformation diff --git a/docs/user-workflows.md b/docs/user-workflows.md index 4924bd15..4f4cddc1 100644 --- a/docs/user-workflows.md +++ b/docs/user-workflows.md @@ -147,6 +147,16 @@ Each format parses into its logical data model: - **SHC** → [`JWTPayload`](../verify/src/main/java/org/who/gdhcnvalidator/verify/shc/ShcModel.kt) - **ICAO** → [`IJson`](../verify/src/main/java/org/who/gdhcnvalidator/verify/icao/IcaoModel.kt) - **ICVP** → [`DvcLogicalModel`](../verify/src/main/java/org/who/gdhcnvalidator/verify/hcert/icvp/DvcLogicalModel.kt) +- **VHL/SHL** → [`SmartHealthLinkModel`](../verify/src/main/java/org/who/gdhcnvalidator/verify/hcert/healthlink/SmartHealthLinkModel.kt) + +#### 3.4.1 VHL (Verifiable Health Link) Special Processing + +VHL follows a different workflow from traditional health certificates: + +1. **URI Decoding**: `vhlink:/` or `shlink:/` URIs are base64 decoded to extract manifest URL +2. **PIN Handling**: If required, user is prompted for PIN entry +3. **Manifest Fetching**: HTTP request retrieves FHIR SearchSet Bundle with file list +4. **File Presentation**: Available files (PDF, FHIR IPS) are displayed for user selection #### 3.5 FHIR Bundle Generation diff --git a/test-resources/src/main/resources/VHL_Examples.txt b/test-resources/src/main/resources/VHL_Examples.txt new file mode 100644 index 00000000..1012b828 --- /dev/null +++ b/test-resources/src/main/resources/VHL_Examples.txt @@ -0,0 +1,16 @@ +# VHL Example QR Codes for Testing + +## VHL with PIN Required +vhlink:/eyJ1cmwiOiJodHRwczovL2V4YW1wbGUuY29tL21hbmlmZXN0IiwiZmxhZyI6IlAifQ== + +This decodes to: {"url":"https://example.com/manifest","flag":"P"} + +## VHL without PIN +vhlink:/eyJ1cmwiOiJodHRwczovL2V4YW1wbGUuY29tL21hbmlmZXN0In0= + +This decodes to: {"url":"https://example.com/manifest"} + +## VHL with Additional Parameters +vhlink:/eyJ1cmwiOiJodHRwczovL2V4YW1wbGUuY29tL21hbmlmZXN0IiwiZmxhZyI6IlAiLCJrZXkiOiJhYmNkZWYxMjM0NTYiLCJsYWJlbCI6IlZhY2NpbmF0aW9uIFJlY29yZCIsImV4cCI6MTY5ODc2ODAwMH0= + +This decodes to: {"url":"https://example.com/manifest","flag":"P","key":"abcdef123456","label":"Vaccination Record","exp":1698768000} \ No newline at end of file diff --git a/verify/src/main/java/org/who/gdhcnvalidator/QRDecoder.kt b/verify/src/main/java/org/who/gdhcnvalidator/QRDecoder.kt index 2e365d68..e6dffc31 100644 --- a/verify/src/main/java/org/who/gdhcnvalidator/QRDecoder.kt +++ b/verify/src/main/java/org/who/gdhcnvalidator/QRDecoder.kt @@ -5,6 +5,7 @@ import org.hl7.fhir.r4.model.Composition import org.who.gdhcnvalidator.trust.TrustRegistry import org.who.gdhcnvalidator.verify.divoc.DivocVerifier import org.who.gdhcnvalidator.verify.hcert.HCertVerifier +import org.who.gdhcnvalidator.verify.hcert.healthlink.VhlVerifier import org.who.gdhcnvalidator.verify.icao.IcaoVerifier import org.who.gdhcnvalidator.verify.shc.ShcVerifier @@ -25,6 +26,9 @@ class QRDecoder(private val registry: TrustRegistry) { REVOKED_KEYS, // keys were revoked by the issuer INVALID_SIGNATURE, // signature doesn't match VERIFIED, // Verified content. + VHL_REQUIRES_PIN, // VHL requires PIN entry + VHL_INVALID_URI, // VHL URI could not be decoded + VHL_FETCH_ERROR, // VHL manifest could not be fetched } data class VerificationResult ( @@ -32,12 +36,32 @@ class QRDecoder(private val registry: TrustRegistry) { var contents: Bundle?, // the Composition var issuer: TrustRegistry.TrustedEntity?, var qr: String, - var unpacked: String? + var unpacked: String?, + var vhlInfo: VhlInfo? = null // Additional VHL-specific information ) { fun composition() = contents?.entry?.filter { it.resource is Composition }?.firstOrNull()?.resource as Composition? } + + /** + * Additional information for VHL processing + */ + data class VhlInfo( + val decodedLink: VhlVerifier.VhlDecodedLink?, + val requiresPin: Boolean = false, + val fileList: List? = null + ) fun decode(qrPayload : String): VerificationResult { + // Check for VHL URIs first - only vhlink:/ is supported + if (qrPayload.startsWith("vhlink:/")) { + return processVhlUri(qrPayload) + } + + // Explicitly reject SHL URIs as not supported per requirements + if (qrPayload.startsWith("shlink:/")) { + return VerificationResult(Status.NOT_SUPPORTED, null, null, qrPayload, null) + } + if (qrPayload.uppercase().startsWith("HC1:")) { return HCertVerifier(registry).unpackAndVerify(qrPayload) } @@ -53,4 +77,63 @@ class QRDecoder(private val registry: TrustRegistry) { return VerificationResult(Status.NOT_SUPPORTED, null, null, qrPayload, null) } + + /** + * Process VHL URI and return initial verification result + * Full VHL processing with PIN entry and manifest fetching happens in the UI layer + */ + private fun processVhlUri(qrPayload: String): VerificationResult { + val vhlVerifier = VhlVerifier() + val decodedLink = vhlVerifier.decodeVhlUri(qrPayload) + + return if (decodedLink != null) { + val requiresPin = vhlVerifier.isPinRequired(decodedLink) + val vhlInfo = VhlInfo( + decodedLink = decodedLink, + requiresPin = requiresPin + ) + + if (requiresPin) { + VerificationResult( + status = Status.VHL_REQUIRES_PIN, + contents = null, + issuer = null, + qr = qrPayload, + unpacked = null, + vhlInfo = vhlInfo + ) + } else { + // Try to fetch manifest without PIN + val manifest = vhlVerifier.fetchManifest(VhlVerifier.VhlManifestRequest(decodedLink.url)) + if (manifest != null) { + val fileList = vhlVerifier.extractFileList(manifest) + VerificationResult( + status = Status.VERIFIED, + contents = manifest, + issuer = null, + qr = qrPayload, + unpacked = decodedLink.url, + vhlInfo = vhlInfo.copy(fileList = fileList) + ) + } else { + VerificationResult( + status = Status.VHL_FETCH_ERROR, + contents = null, + issuer = null, + qr = qrPayload, + unpacked = decodedLink.url, + vhlInfo = vhlInfo + ) + } + } + } else { + VerificationResult( + status = Status.VHL_INVALID_URI, + contents = null, + issuer = null, + qr = qrPayload, + unpacked = null + ) + } + } } \ No newline at end of file diff --git a/verify/src/main/java/org/who/gdhcnvalidator/verify/hcert/healthlink/HealthLinkMapper.kt b/verify/src/main/java/org/who/gdhcnvalidator/verify/hcert/healthlink/HealthLinkMapper.kt index 9b591be0..26942021 100644 --- a/verify/src/main/java/org/who/gdhcnvalidator/verify/hcert/healthlink/HealthLinkMapper.kt +++ b/verify/src/main/java/org/who/gdhcnvalidator/verify/hcert/healthlink/HealthLinkMapper.kt @@ -1,14 +1,48 @@ package org.who.gdhcnvalidator.verify.hcert.healthlink import org.hl7.fhir.r4.model.Bundle +import org.hl7.fhir.r4.model.Composition +import org.hl7.fhir.r4.model.Narrative import org.who.gdhcnvalidator.verify.BaseMapper /** - * Translates a QR CBOR object into FHIR Objects + * Translates legacy Health Links found in CBOR payloads into FHIR Objects + * Note: Modern VHL processing (vhlink:/ URIs) is handled directly in VhlVerifier */ class HealthLinkMapper: BaseMapper() { + fun run(link: SmartHealthLinkModel): Bundle { - // TODO: how do we parse this? - return Bundle() + // Create a basic bundle for legacy healthLink fields found in CBOR payloads + // Modern VHL URIs (vhlink:/) are processed directly in QRDecoder/VhlVerifier + val bundle = Bundle().apply { + type = Bundle.BundleType.DOCUMENT + id = "healthlink-legacy" + } + + // Create a composition indicating this is a legacy health link + val composition = Composition().apply { + id = "composition-healthlink" + status = Composition.CompositionStatus.FINAL + type = org.hl7.fhir.r4.model.CodeableConcept().apply { + text = "Legacy Health Link" + } + title = "Health Link Reference" + + // Add the link URI as narrative text + text = Narrative().apply { + status = Narrative.NarrativeStatus.GENERATED + div = org.hl7.fhir.utilities.xhtml.XhtmlNode().apply { + addTag("div") + addTag("p").addText("Legacy health link found in certificate: ${link.getUri() ?: "Unknown URI"}") + addTag("p").addText("Modern VHL processing requires vhlink:/ URI format.") + } + } + } + + bundle.addEntry().apply { + resource = composition + } + + return bundle } } \ No newline at end of file diff --git a/verify/src/main/java/org/who/gdhcnvalidator/verify/hcert/healthlink/SmartHealthLinkModel.kt b/verify/src/main/java/org/who/gdhcnvalidator/verify/hcert/healthlink/SmartHealthLinkModel.kt index 890971d7..283aa02e 100644 --- a/verify/src/main/java/org/who/gdhcnvalidator/verify/hcert/healthlink/SmartHealthLinkModel.kt +++ b/verify/src/main/java/org/who/gdhcnvalidator/verify/hcert/healthlink/SmartHealthLinkModel.kt @@ -3,6 +3,24 @@ package org.who.gdhcnvalidator.verify.hcert.healthlink import org.hl7.fhir.r4.model.StringType import org.who.gdhcnvalidator.verify.BaseModel +/** + * Model for Verifiable Health Links (VHL) + * Supports only VHL (vhlink:/) URI format + */ open class SmartHealthLinkModel ( val u: StringType? -): BaseModel() \ No newline at end of file +): BaseModel() { + + /** + * Checks if this is a VHL (Verifiable Health Link) based on URI prefix + */ + fun isVHL(): Boolean { + val uri = u?.value ?: return false + return uri.startsWith("vhlink:/") + } + + /** + * Gets the raw URI value + */ + fun getUri(): String? = u?.value +} \ No newline at end of file diff --git a/verify/src/main/java/org/who/gdhcnvalidator/verify/hcert/healthlink/VhlVerifier.kt b/verify/src/main/java/org/who/gdhcnvalidator/verify/hcert/healthlink/VhlVerifier.kt new file mode 100644 index 00000000..9d81bdee --- /dev/null +++ b/verify/src/main/java/org/who/gdhcnvalidator/verify/hcert/healthlink/VhlVerifier.kt @@ -0,0 +1,200 @@ +package org.who.gdhcnvalidator.verify.hcert.healthlink + +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import org.hl7.fhir.r4.model.Bundle +import org.hl7.fhir.r4.model.ListResource +import java.net.URI +import java.net.URLDecoder +import java.net.http.HttpClient +import java.net.http.HttpRequest +import java.net.http.HttpResponse +import java.time.Duration +import java.util.* +import kotlin.text.Charsets.UTF_8 + +/** + * Verifies and processes Verifiable Health Links (VHL) according to the VHL specification + * https://build.fhir.org/ig/IHE/ITI.VHL/branches/master/volume-1.html + */ +class VhlVerifier { + + data class VhlManifestRequest( + val url: String, + val pin: String? = null + ) + + data class VhlDecodedLink( + val url: String, + val flag: String? = null, + val key: String? = null, + val label: String? = null, + val exp: Long? = null + ) + + private val mapper = jacksonObjectMapper() + private val httpClient = HttpClient.newBuilder() + .connectTimeout(Duration.ofSeconds(30)) + .build() + + /** + * Decodes a VHL URI (vhlink:/) to extract the manifest URL + */ + fun decodeVhlUri(uri: String): VhlDecodedLink? { + return try { + val payload = when { + uri.startsWith("vhlink:/") -> uri.substring(8) + else -> return null + } + + val decodedBytes = Base64.getUrlDecoder().decode(payload) + val decodedJson = String(decodedBytes, UTF_8) + mapper.readValue(decodedJson, VhlDecodedLink::class.java) + } catch (e: Exception) { + null + } + } + + /** + * Fetches the VHL manifest from the decoded URL + * Returns a FHIR SearchSet Bundle containing List resources and included items + */ + fun fetchManifest(request: VhlManifestRequest): Bundle? { + return try { + val url = if (request.pin != null) { + // Add PIN parameter if provided + val separator = if (request.url.contains("?")) "&" else "?" + "${request.url}${separator}recipient=${URLDecoder.decode(request.pin, "UTF-8")}" + } else { + request.url + } + + val httpRequest = HttpRequest.newBuilder() + .uri(URI.create(url)) + .header("Accept", "application/fhir+json") + .GET() + .build() + + val response = httpClient.send(httpRequest, HttpResponse.BodyHandlers.ofString()) + + if (response.statusCode() == 200) { + mapper.readValue(response.body(), Bundle::class.java) + } else { + null + } + } catch (e: Exception) { + null + } + } + + /** + * Checks if a PIN is required for accessing the manifest + * This is typically indicated by specific flags in the decoded link or 401 response + */ + fun isPinRequired(decodedLink: VhlDecodedLink): Boolean { + // Check if flag indicates PIN is required (P flag for password/PIN required) + return decodedLink.flag?.contains("P") == true + } + + /** + * Extracts file information from the VHL manifest + * Supports both current VHL manifest format and deprecated SHL manifest format + * Returns list of file metadata for display to user + */ + fun extractFileList(manifest: Bundle): List { + val files = mutableListOf() + + // Check if this is a current VHL manifest (FHIR SearchSet Bundle with List resources) + if (manifest.type == Bundle.BundleType.SEARCHSET) { + extractFromCurrentVhlManifest(manifest, files) + } else { + // Try deprecated SHL manifest format (files array in Bundle entries) + extractFromDeprecatedShlManifest(manifest, files) + } + + return files + } + + /** + * Extract files from current VHL manifest format (FHIR SearchSet Bundle with List resources) + */ + private fun extractFromCurrentVhlManifest(manifest: Bundle, files: MutableList) { + // Process List resources and their included items + manifest.entry?.forEach { entry -> + when (val resource = entry.resource) { + is ListResource -> { + resource.entry?.forEach { listEntry -> + val reference = listEntry.item?.reference + if (reference != null) { + // Find the referenced resource in the Bundle + val referencedResource = manifest.entry?.find { + it.resource?.id == reference.substringAfter("/") + }?.resource + + if (referencedResource != null) { + files.add(extractFileInfo(referencedResource)) + } + } + } + } + } + } + } + + /** + * Extract files from deprecated SHL manifest format + * Based on https://build.fhir.org/ig/HL7/smart-health-cards-and-links/StructureDefinition-ShlManifest.html + */ + private fun extractFromDeprecatedShlManifest(manifest: Bundle, files: MutableList) { + // In deprecated format, files are directly in Bundle entries + manifest.entry?.forEach { entry -> + val resource = entry.resource + if (resource != null) { + files.add(extractFileInfo(resource)) + } + } + } + + private fun extractFileInfo(resource: org.hl7.fhir.r4.model.Resource): VhlFileInfo { + // Extract file information based on resource type + return when (resource.resourceType.name) { + "DocumentReference" -> { + val docRef = resource as org.hl7.fhir.r4.model.DocumentReference + VhlFileInfo( + id = docRef.id ?: "unknown", + type = "PDF", // Assume PDF for DocumentReference + title = docRef.description ?: "Document", + url = docRef.content?.firstOrNull()?.attachment?.url, + size = docRef.content?.firstOrNull()?.attachment?.size + ) + } + "Bundle" -> { + val bundle = resource as Bundle + VhlFileInfo( + id = bundle.id ?: "unknown", + type = "FHIR_IPS", // Assume IPS for Bundle + title = "FHIR IPS Document", + content = bundle // Store the bundle for direct processing + ) + } + else -> { + VhlFileInfo( + id = resource.id ?: "unknown", + type = "UNKNOWN", + title = "Unknown Resource Type: ${resource.resourceType.name}" + ) + } + } + } +} + +/** + * Represents a file available in a VHL manifest + */ +data class VhlFileInfo( + val id: String, + val type: String, // "PDF", "FHIR_IPS", "UNKNOWN" + val title: String, + val url: String? = null, + val size: Long? = null, + val content: Any? = null // For direct FHIR content +) \ No newline at end of file diff --git a/verify/src/test/java/org/who/gdhcnvalidator/verify/VhlExampleQRTest.kt b/verify/src/test/java/org/who/gdhcnvalidator/verify/VhlExampleQRTest.kt new file mode 100644 index 00000000..3e855943 --- /dev/null +++ b/verify/src/test/java/org/who/gdhcnvalidator/verify/VhlExampleQRTest.kt @@ -0,0 +1,72 @@ +package org.who.gdhcnvalidator.verify + +import org.junit.Assert.* +import org.junit.Test +import org.who.gdhcnvalidator.QRDecoder +import org.who.gdhcnvalidator.test.BaseTrustRegistryTest + +class VhlExampleQRTest : BaseTrustRegistryTest() { + + @Test + fun testRealWorldVhlExamples() { + val decoder = QRDecoder(registry) + + // Test VHL with PIN required + val vhlWithPin = "vhlink:/eyJ1cmwiOiJodHRwczovL2V4YW1wbGUuY29tL21hbmlmZXN0IiwiZmxhZyI6IlAifQ==" + val resultWithPin = decoder.decode(vhlWithPin) + + assertEquals("Should require PIN", QRDecoder.Status.VHL_REQUIRES_PIN, resultWithPin.status) + assertNotNull("Should have VHL info", resultWithPin.vhlInfo) + assertTrue("Should require PIN", resultWithPin.vhlInfo?.requiresPin == true) + assertEquals("Should extract URL", "https://example.com/manifest", resultWithPin.vhlInfo?.decodedLink?.url) + assertEquals("Should extract flag", "P", resultWithPin.vhlInfo?.decodedLink?.flag) + + // Test VHL without PIN + val vhlWithoutPin = "vhlink:/eyJ1cmwiOiJodHRwczovL2V4YW1wbGUuY29tL21hbmlmZXN0In0=" + val resultWithoutPin = decoder.decode(vhlWithoutPin) + + assertEquals("Should fail to fetch manifest", QRDecoder.Status.VHL_FETCH_ERROR, resultWithoutPin.status) + assertNotNull("Should have VHL info", resultWithoutPin.vhlInfo) + assertFalse("Should not require PIN", resultWithoutPin.vhlInfo?.requiresPin == true) + assertEquals("Should extract URL", "https://example.com/manifest", resultWithoutPin.vhlInfo?.decodedLink?.url) + + // Test SHL format - should not be supported per requirements + val shlExample = "shlink:/eyJ1cmwiOiJodHRwczovL2V4YW1wbGUuY29tL3NobC1tYW5pZmVzdCJ9" + val shlResult = decoder.decode(shlExample) + + assertEquals("Should not support SHL", QRDecoder.Status.NOT_SUPPORTED, shlResult.status) + assertNull("Should not have VHL info", shlResult.vhlInfo) + + // Test VHL with additional parameters + val vhlFull = "vhlink:/eyJ1cmwiOiJodHRwczovL2V4YW1wbGUuY29tL21hbmlmZXN0IiwiZmxhZyI6IlAiLCJrZXkiOiJhYmNkZWYxMjM0NTYiLCJsYWJlbCI6IlZhY2NpbmF0aW9uIFJlY29yZCIsImV4cCI6MTY5ODc2ODAwMH0=" + val fullResult = decoder.decode(vhlFull) + + assertEquals("Should require PIN", QRDecoder.Status.VHL_REQUIRES_PIN, fullResult.status) + assertNotNull("Should have VHL info", fullResult.vhlInfo) + assertEquals("Should extract URL", "https://example.com/manifest", fullResult.vhlInfo?.decodedLink?.url) + assertEquals("Should extract flag", "P", fullResult.vhlInfo?.decodedLink?.flag) + assertEquals("Should extract key", "abcdef123456", fullResult.vhlInfo?.decodedLink?.key) + assertEquals("Should extract label", "Vaccination Record", fullResult.vhlInfo?.decodedLink?.label) + assertEquals("Should extract expiration", 1698768000L, fullResult.vhlInfo?.decodedLink?.exp) + } + + @Test + fun testInvalidVhlExamples() { + val decoder = QRDecoder(registry) + + // Test invalid base64 + val invalidBase64 = "vhlink:/invalid-base64-data!!!" + val result1 = decoder.decode(invalidBase64) + assertEquals("Should detect invalid URI", QRDecoder.Status.VHL_INVALID_URI, result1.status) + + // Test invalid JSON + val invalidJson = "vhlink:/dGhpcyBpcyBub3QganNvbg==" // "this is not json" in base64 + val result2 = decoder.decode(invalidJson) + assertEquals("Should detect invalid URI", QRDecoder.Status.VHL_INVALID_URI, result2.status) + + // Test non-VHL URI + val notVhl = "https://example.com/regular-url" + val result3 = decoder.decode(notVhl) + assertEquals("Should not support regular URL", QRDecoder.Status.NOT_SUPPORTED, result3.status) + } +} \ No newline at end of file diff --git a/verify/src/test/java/org/who/gdhcnvalidator/verify/VhlQRDecoderTest.kt b/verify/src/test/java/org/who/gdhcnvalidator/verify/VhlQRDecoderTest.kt new file mode 100644 index 00000000..e5f756ba --- /dev/null +++ b/verify/src/test/java/org/who/gdhcnvalidator/verify/VhlQRDecoderTest.kt @@ -0,0 +1,53 @@ +package org.who.gdhcnvalidator.verify + +import org.junit.Assert.* +import org.junit.Test +import org.who.gdhcnvalidator.QRDecoder +import org.who.gdhcnvalidator.test.BaseTrustRegistryTest +import java.util.* + +class VhlQRDecoderTest : BaseTrustRegistryTest() { + + @Test + fun testVhlUriDetection() { + val decoder = QRDecoder(registry) + + // Create a sample VHL URI + val testData = """{"url":"https://example.com/manifest","flag":"P"}""" + val encodedData = Base64.getUrlEncoder().encodeToString(testData.toByteArray()) + val vhlUri = "vhlink:/$encodedData" + + val result = decoder.decode(vhlUri) + + assertEquals("Should detect VHL and require PIN", QRDecoder.Status.VHL_REQUIRES_PIN, result.status) + assertNotNull("Should have VHL info", result.vhlInfo) + assertTrue("Should require PIN", result.vhlInfo?.requiresPin == true) + assertEquals("Should preserve original QR", vhlUri, result.qr) + } + + @Test + fun testInvalidShlUri() { + val decoder = QRDecoder(registry) + + // SHL URIs should not be processed as VHL + val testData = """{"url":"https://example.com/manifest"}""" + val encodedData = Base64.getUrlEncoder().encodeToString(testData.toByteArray()) + val shlUri = "shlink:/$encodedData" + + val result = decoder.decode(shlUri) + + // SHL should not be supported + assertEquals("Should not support SHL", QRDecoder.Status.NOT_SUPPORTED, result.status) + assertNull("Should not have VHL info", result.vhlInfo) + } + + @Test + fun testInvalidVhlUri() { + val decoder = QRDecoder(registry) + + val result = decoder.decode("vhlink:/invalid-base64-data") + + assertEquals("Should detect invalid VHL URI", QRDecoder.Status.VHL_INVALID_URI, result.status) + assertNull("Should not have VHL info", result.vhlInfo) + } +} \ No newline at end of file diff --git a/verify/src/test/java/org/who/gdhcnvalidator/verify/hcert/healthlink/HealthLinkMapperTest.kt b/verify/src/test/java/org/who/gdhcnvalidator/verify/hcert/healthlink/HealthLinkMapperTest.kt new file mode 100644 index 00000000..2776b6f1 --- /dev/null +++ b/verify/src/test/java/org/who/gdhcnvalidator/verify/hcert/healthlink/HealthLinkMapperTest.kt @@ -0,0 +1,63 @@ +package org.who.gdhcnvalidator.verify.hcert.healthlink + +import org.hl7.fhir.r4.model.Bundle +import org.hl7.fhir.r4.model.Composition +import org.hl7.fhir.r4.model.StringType +import org.junit.Assert.* +import org.junit.Test + +class HealthLinkMapperTest { + + @Test + fun testHealthLinkMapperDoesNotThrowException() { + val mapper = HealthLinkMapper() + val model = SmartHealthLinkModel(StringType("vhlink:/test")) + + // Should not throw UnsupportedOperationException anymore + val result = mapper.run(model) + + assertNotNull("Should return a Bundle", result) + assertEquals("Should be document bundle", Bundle.BundleType.DOCUMENT, result.type) + assertEquals("Should have correct ID", "healthlink-legacy", result.id) + + // Should have a composition entry + val compositionEntry = result.entry?.find { it.resource is Composition } + assertNotNull("Should have composition entry", compositionEntry) + + val composition = compositionEntry?.resource as Composition + assertEquals("Should have correct status", Composition.CompositionStatus.FINAL, composition.status) + assertEquals("Should have correct title", "Health Link Reference", composition.title) + } + + @Test + fun testHealthLinkMapperWithNullUri() { + val mapper = HealthLinkMapper() + val model = SmartHealthLinkModel(null) + + // Should handle null URI gracefully + val result = mapper.run(model) + + assertNotNull("Should return a Bundle", result) + assertTrue("Should contain 'Unknown URI' in narrative", + result.entry?.any { entry -> + val composition = entry.resource as? Composition + composition?.text?.div?.allText?.contains("Unknown URI") == true + } ?: false) + } + + @Test + fun testHealthLinkMapperWithShlUri() { + val mapper = HealthLinkMapper() + val model = SmartHealthLinkModel(StringType("shlink:/example")) + + // Should handle SHL URIs gracefully (legacy case) + val result = mapper.run(model) + + assertNotNull("Should return a Bundle", result) + assertTrue("Should contain the SHL URI in narrative", + result.entry?.any { entry -> + val composition = entry.resource as? Composition + composition?.text?.div?.allText?.contains("shlink:/example") == true + } ?: false) + } +} \ No newline at end of file diff --git a/verify/src/test/java/org/who/gdhcnvalidator/verify/hcert/healthlink/VhlIntegrationTest.kt b/verify/src/test/java/org/who/gdhcnvalidator/verify/hcert/healthlink/VhlIntegrationTest.kt new file mode 100644 index 00000000..2a165b81 --- /dev/null +++ b/verify/src/test/java/org/who/gdhcnvalidator/verify/hcert/healthlink/VhlIntegrationTest.kt @@ -0,0 +1,132 @@ +package org.who.gdhcnvalidator.verify.hcert.healthlink + +import org.hl7.fhir.r4.model.* +import org.junit.Assert.* +import org.junit.Test +import org.who.gdhcnvalidator.test.BaseTrustRegistryTest +import java.util.* + +class VhlIntegrationTest : BaseTrustRegistryTest() { + + @Test + fun testVhlWorkflowWithoutPin() { + val vhlVerifier = VhlVerifier() + + // Create a VHL URI without PIN requirement + val testData = """{"url":"https://example.com/manifest"}""" + val encodedData = Base64.getUrlEncoder().encodeToString(testData.toByteArray()) + val vhlUri = "vhlink:/$encodedData" + + // Test URI decoding + val decodedLink = vhlVerifier.decodeVhlUri(vhlUri) + assertNotNull("Should decode VHL URI", decodedLink) + assertEquals("Should extract URL", "https://example.com/manifest", decodedLink?.url) + assertFalse("Should not require PIN", vhlVerifier.isPinRequired(decodedLink!!)) + } + + @Test + fun testVhlWorkflowWithPin() { + val vhlVerifier = VhlVerifier() + + // Create a VHL URI with PIN requirement + val testData = """{"url":"https://example.com/manifest","flag":"P"}""" + val encodedData = Base64.getUrlEncoder().encodeToString(testData.toByteArray()) + val vhlUri = "vhlink:/$encodedData" + + // Test URI decoding + val decodedLink = vhlVerifier.decodeVhlUri(vhlUri) + assertNotNull("Should decode VHL URI", decodedLink) + assertEquals("Should extract URL", "https://example.com/manifest", decodedLink?.url) + assertEquals("Should extract flag", "P", decodedLink?.flag) + assertTrue("Should require PIN", vhlVerifier.isPinRequired(decodedLink!!)) + } + + @Test + fun testVhlManifestProcessing() { + val vhlVerifier = VhlVerifier() + + // Create a mock FHIR Bundle representing a VHL manifest + val manifest = createMockVhlManifest() + + // Test file extraction + val fileList = vhlVerifier.extractFileList(manifest) + + assertEquals("Should extract correct number of files", 2, fileList.size) + + // Verify PDF file + val pdfFile = fileList.find { it.type == "PDF" } + assertNotNull("Should have PDF file", pdfFile) + assertEquals("Should have correct PDF title", "Vaccination Certificate", pdfFile?.title) + + // Verify FHIR IPS file + val fhirFile = fileList.find { it.type == "FHIR_IPS" } + assertNotNull("Should have FHIR IPS file", fhirFile) + assertEquals("Should have correct FHIR title", "FHIR IPS Document", fhirFile?.title) + } + + @Test + fun testSmartHealthLinkModelVhlDetection() { + // Test VHL detection + val vhlModel = SmartHealthLinkModel(StringType("vhlink:/test")) + assertTrue("Should detect VHL URI", vhlModel.isVHL()) + + // Test SHL detection - should NOT be detected as VHL + val shlModel = SmartHealthLinkModel(StringType("shlink:/test")) + assertFalse("Should NOT detect SHL URI as VHL", shlModel.isVHL()) + + // Test non-VHL URI + val otherModel = SmartHealthLinkModel(StringType("https://example.com")) + assertFalse("Should not detect regular URI as VHL", otherModel.isVHL()) + + // Test null URI + val nullModel = SmartHealthLinkModel(null) + assertFalse("Should not detect null URI as VHL", nullModel.isVHL()) + } + + /** + * Creates a mock VHL manifest Bundle for testing + */ + private fun createMockVhlManifest(): Bundle { + val bundle = Bundle() + bundle.id = "test-manifest" + bundle.type = Bundle.BundleType.SEARCHSET + + // Create a List resource + val listResource = ListResource() + listResource.id = "file-list" + listResource.status = ListResource.ListStatus.CURRENT + listResource.mode = ListResource.ListMode.WORKING + + // Add PDF document reference + val pdfDocRef = DocumentReference() + pdfDocRef.id = "pdf-doc" + pdfDocRef.description = "Vaccination Certificate" + val pdfAttachment = Attachment() + pdfAttachment.url = "https://example.com/cert.pdf" + pdfAttachment.size = 12345L + val pdfContent = DocumentReference.DocumentReferenceContentComponent() + pdfContent.attachment = pdfAttachment + pdfDocRef.content = listOf(pdfContent) + + // Add FHIR IPS Bundle + val ipsBundle = Bundle() + ipsBundle.id = "ips-bundle" + ipsBundle.type = Bundle.BundleType.DOCUMENT + + // Add references to List + val pdfListEntry = ListResource.ListEntryComponent() + pdfListEntry.item = Reference("DocumentReference/pdf-doc") + listResource.entry.add(pdfListEntry) + + val ipsListEntry = ListResource.ListEntryComponent() + ipsListEntry.item = Reference("Bundle/ips-bundle") + listResource.entry.add(ipsListEntry) + + // Add resources to Bundle + bundle.addEntry().setResource(listResource) + bundle.addEntry().setResource(pdfDocRef) + bundle.addEntry().setResource(ipsBundle) + + return bundle + } +} \ No newline at end of file diff --git a/verify/src/test/java/org/who/gdhcnvalidator/verify/hcert/healthlink/VhlVerifierTest.kt b/verify/src/test/java/org/who/gdhcnvalidator/verify/hcert/healthlink/VhlVerifierTest.kt new file mode 100644 index 00000000..e8d23fc2 --- /dev/null +++ b/verify/src/test/java/org/who/gdhcnvalidator/verify/hcert/healthlink/VhlVerifierTest.kt @@ -0,0 +1,66 @@ +package org.who.gdhcnvalidator.verify.hcert.healthlink + +import org.junit.Assert.* +import org.junit.Test +import org.who.gdhcnvalidator.test.BaseTrustRegistryTest +import java.util.* + +class VhlVerifierTest : BaseTrustRegistryTest() { + + @Test + fun testDecodeVhlUri() { + val vhlVerifier = VhlVerifier() + + // Create a sample VHL URI with base64 encoded JSON + val testData = """{"url":"https://example.com/manifest","flag":"P"}""" + val encodedData = Base64.getUrlEncoder().encodeToString(testData.toByteArray()) + val vhlUri = "vhlink:/$encodedData" + + val result = vhlVerifier.decodeVhlUri(vhlUri) + + assertNotNull("Should decode VHL URI successfully", result) + assertEquals("Should extract correct URL", "https://example.com/manifest", result?.url) + assertEquals("Should extract correct flag", "P", result?.flag) + } + + @Test + fun testDecodeShlUri() { + val vhlVerifier = VhlVerifier() + + // SHL URIs should not be processed by VHL verifier + val testData = """{"url":"https://example.com/shl-manifest"}""" + val encodedData = Base64.getUrlEncoder().encodeToString(testData.toByteArray()) + val shlUri = "shlink:/$encodedData" + + val result = vhlVerifier.decodeVhlUri(shlUri) + + assertNull("Should not decode SHL URI (VHL verifier only processes vhlink:/ URIs)", result) + } + + @Test + fun testInvalidUri() { + val vhlVerifier = VhlVerifier() + + val result = vhlVerifier.decodeVhlUri("invalid-uri") + + assertNull("Should return null for invalid URI", result) + } + + @Test + fun testIsPinRequired() { + val vhlVerifier = VhlVerifier() + + val linkWithPin = VhlVerifier.VhlDecodedLink( + url = "https://example.com/manifest", + flag = "P" + ) + + val linkWithoutPin = VhlVerifier.VhlDecodedLink( + url = "https://example.com/manifest", + flag = null + ) + + assertTrue("Should require PIN when flag contains P", vhlVerifier.isPinRequired(linkWithPin)) + assertFalse("Should not require PIN when flag is null", vhlVerifier.isPinRequired(linkWithoutPin)) + } +} \ No newline at end of file