From 86052d330bdd6585ab1d936e955545ca42815628 Mon Sep 17 00:00:00 2001 From: OmarAlJarrah Date: Wed, 17 Jun 2026 06:03:15 +0300 Subject: [PATCH] feat: add per-operation auth descriptor with precedence ladder MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Auth requirements are per-operation, and a two-boolean "needs-auth / needs-key" model does not generalise to operations that accept several alternative schemes with different OAuth parameters. This adds a hand-constructable, scheme-agnostic descriptor model to sdk-core plus a deterministic resolver. - AuthRequirement: one accepted AuthScheme paired with its own OAuth scopes/params (immutable, Builder + newBuilder). - AuthDescriptor: a per-operation ordered list of AuthRequirements in preference order (immutable, Builder + newBuilder, of/ofSchemes factories). Records which schemes are acceptable, never how they are stamped onto the wire. - AuthDescriptorTier / AuthResolution: the precedence tier and the resolved outcome (requirement + tier + anonymous flag). - AuthDescriptorResolver: applies two precedence orders — tier precedence (per-call override > operation default > client default, no fall-through past a supplied higher tier) then requirement precedence within the chosen descriptor (first satisfiable scheme wins; NO_AUTH is always satisfiable). Throws AuthResolutionException naming the required and available schemes when nothing matches. The resolver stays scheme-agnostic: callers supply the set of schemes they can satisfy and map the resolved requirement to a concrete credential and auth step themselves; per-cloud / OAuth specifics stay in adapters. No code generation — these are the runtime primitives a generator would later target. Closes #63 --- sdk-core/api/sdk-core.api | 103 ++++++++++ .../sdk/core/http/auth/AuthDescriptor.kt | 122 ++++++++++++ .../core/http/auth/AuthDescriptorResolver.kt | 95 +++++++++ .../sdk/core/http/auth/AuthDescriptorTier.kt | 24 +++ .../sdk/core/http/auth/AuthRequirement.kt | 119 +++++++++++ .../sdk/core/http/auth/AuthResolution.kt | 34 ++++ .../core/http/auth/AuthResolutionException.kt | 44 +++++ .../http/auth/AuthDescriptorResolverTest.kt | 186 ++++++++++++++++++ .../sdk/core/http/auth/AuthDescriptorTest.kt | 138 +++++++++++++ .../core/http/auth/AuthDescriptorTierTest.kt | 32 +++ .../sdk/core/http/auth/AuthRequirementTest.kt | 107 ++++++++++ .../http/auth/AuthResolutionExceptionTest.kt | 66 +++++++ .../sdk/core/http/auth/AuthResolutionTest.kt | 44 +++++ 13 files changed, 1114 insertions(+) create mode 100644 sdk-core/src/main/kotlin/org/dexpace/sdk/core/http/auth/AuthDescriptor.kt create mode 100644 sdk-core/src/main/kotlin/org/dexpace/sdk/core/http/auth/AuthDescriptorResolver.kt create mode 100644 sdk-core/src/main/kotlin/org/dexpace/sdk/core/http/auth/AuthDescriptorTier.kt create mode 100644 sdk-core/src/main/kotlin/org/dexpace/sdk/core/http/auth/AuthRequirement.kt create mode 100644 sdk-core/src/main/kotlin/org/dexpace/sdk/core/http/auth/AuthResolution.kt create mode 100644 sdk-core/src/main/kotlin/org/dexpace/sdk/core/http/auth/AuthResolutionException.kt create mode 100644 sdk-core/src/test/kotlin/org/dexpace/sdk/core/http/auth/AuthDescriptorResolverTest.kt create mode 100644 sdk-core/src/test/kotlin/org/dexpace/sdk/core/http/auth/AuthDescriptorTest.kt create mode 100644 sdk-core/src/test/kotlin/org/dexpace/sdk/core/http/auth/AuthDescriptorTierTest.kt create mode 100644 sdk-core/src/test/kotlin/org/dexpace/sdk/core/http/auth/AuthRequirementTest.kt create mode 100644 sdk-core/src/test/kotlin/org/dexpace/sdk/core/http/auth/AuthResolutionExceptionTest.kt create mode 100644 sdk-core/src/test/kotlin/org/dexpace/sdk/core/http/auth/AuthResolutionTest.kt diff --git a/sdk-core/api/sdk-core.api b/sdk-core/api/sdk-core.api index 7ebcc312..d020c9bd 100644 --- a/sdk-core/api/sdk-core.api +++ b/sdk-core/api/sdk-core.api @@ -65,6 +65,58 @@ public final class org/dexpace/sdk/core/http/auth/AuthChallengeParser { public static final fun parse (Ljava/lang/String;)Ljava/util/List; } +public final class org/dexpace/sdk/core/http/auth/AuthDescriptor { + public static final field Companion Lorg/dexpace/sdk/core/http/auth/AuthDescriptor$Companion; + public synthetic fun (Ljava/util/List;Lkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun allowsAnonymous ()Z + public fun equals (Ljava/lang/Object;)Z + public final fun getRequirements ()Ljava/util/List; + public final fun getSchemes ()Ljava/util/List; + public fun hashCode ()I + public final fun newBuilder ()Lorg/dexpace/sdk/core/http/auth/AuthDescriptor$Builder; + public static final fun of (Ljava/util/List;)Lorg/dexpace/sdk/core/http/auth/AuthDescriptor; + public static final fun ofSchemes (Ljava/util/List;)Lorg/dexpace/sdk/core/http/auth/AuthDescriptor; + public fun toString ()Ljava/lang/String; +} + +public final class org/dexpace/sdk/core/http/auth/AuthDescriptor$Builder : org/dexpace/sdk/core/generics/Builder { + public fun ()V + public fun (Lorg/dexpace/sdk/core/http/auth/AuthDescriptor;)V + public final fun addRequirement (Lorg/dexpace/sdk/core/http/auth/AuthRequirement;)Lorg/dexpace/sdk/core/http/auth/AuthDescriptor$Builder; + public final fun addScheme (Lorg/dexpace/sdk/core/http/auth/AuthScheme;)Lorg/dexpace/sdk/core/http/auth/AuthDescriptor$Builder; + public synthetic fun build ()Ljava/lang/Object; + public fun build ()Lorg/dexpace/sdk/core/http/auth/AuthDescriptor; + public final fun requirements (Ljava/util/List;)Lorg/dexpace/sdk/core/http/auth/AuthDescriptor$Builder; +} + +public final class org/dexpace/sdk/core/http/auth/AuthDescriptor$Companion { + public final fun of (Ljava/util/List;)Lorg/dexpace/sdk/core/http/auth/AuthDescriptor; + public final fun ofSchemes (Ljava/util/List;)Lorg/dexpace/sdk/core/http/auth/AuthDescriptor; +} + +public final class org/dexpace/sdk/core/http/auth/AuthDescriptorResolver { + public static final field Companion Lorg/dexpace/sdk/core/http/auth/AuthDescriptorResolver$Companion; + public static final field INSTANCE Lorg/dexpace/sdk/core/http/auth/AuthDescriptorResolver; + public fun ()V + public final fun resolve (Ljava/util/Set;)Lorg/dexpace/sdk/core/http/auth/AuthResolution; + public final fun resolve (Ljava/util/Set;Lorg/dexpace/sdk/core/http/auth/AuthDescriptor;)Lorg/dexpace/sdk/core/http/auth/AuthResolution; + public final fun resolve (Ljava/util/Set;Lorg/dexpace/sdk/core/http/auth/AuthDescriptor;Lorg/dexpace/sdk/core/http/auth/AuthDescriptor;)Lorg/dexpace/sdk/core/http/auth/AuthResolution; + public final fun resolve (Ljava/util/Set;Lorg/dexpace/sdk/core/http/auth/AuthDescriptor;Lorg/dexpace/sdk/core/http/auth/AuthDescriptor;Lorg/dexpace/sdk/core/http/auth/AuthDescriptor;)Lorg/dexpace/sdk/core/http/auth/AuthResolution; + public static synthetic fun resolve$default (Lorg/dexpace/sdk/core/http/auth/AuthDescriptorResolver;Ljava/util/Set;Lorg/dexpace/sdk/core/http/auth/AuthDescriptor;Lorg/dexpace/sdk/core/http/auth/AuthDescriptor;Lorg/dexpace/sdk/core/http/auth/AuthDescriptor;ILjava/lang/Object;)Lorg/dexpace/sdk/core/http/auth/AuthResolution; +} + +public final class org/dexpace/sdk/core/http/auth/AuthDescriptorResolver$Companion { +} + +public final class org/dexpace/sdk/core/http/auth/AuthDescriptorTier : java/lang/Enum { + public static final field CLIENT Lorg/dexpace/sdk/core/http/auth/AuthDescriptorTier; + public static final field OPERATION Lorg/dexpace/sdk/core/http/auth/AuthDescriptorTier; + public static final field PER_CALL Lorg/dexpace/sdk/core/http/auth/AuthDescriptorTier; + public static fun getEntries ()Lkotlin/enums/EnumEntries; + public static fun valueOf (Ljava/lang/String;)Lorg/dexpace/sdk/core/http/auth/AuthDescriptorTier; + public static fun values ()[Lorg/dexpace/sdk/core/http/auth/AuthDescriptorTier; +} + public final class org/dexpace/sdk/core/http/auth/AuthMetadata { public fun (Ljava/util/List;)V public fun (Ljava/util/List;Ljava/util/List;)V @@ -75,6 +127,57 @@ public final class org/dexpace/sdk/core/http/auth/AuthMetadata { public final fun getSchemes ()Ljava/util/List; } +public final class org/dexpace/sdk/core/http/auth/AuthRequirement { + public static final field Companion Lorg/dexpace/sdk/core/http/auth/AuthRequirement$Companion; + public fun (Lorg/dexpace/sdk/core/http/auth/AuthScheme;)V + public fun (Lorg/dexpace/sdk/core/http/auth/AuthScheme;Ljava/util/List;)V + public fun (Lorg/dexpace/sdk/core/http/auth/AuthScheme;Ljava/util/List;Ljava/util/Map;)V + public synthetic fun (Lorg/dexpace/sdk/core/http/auth/AuthScheme;Ljava/util/List;Ljava/util/Map;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun equals (Ljava/lang/Object;)Z + public final fun getOauthParams ()Ljava/util/Map; + public final fun getOauthScopes ()Ljava/util/List; + public final fun getScheme ()Lorg/dexpace/sdk/core/http/auth/AuthScheme; + public fun hashCode ()I + public final fun newBuilder ()Lorg/dexpace/sdk/core/http/auth/AuthRequirement$Builder; + public static final fun of (Lorg/dexpace/sdk/core/http/auth/AuthScheme;)Lorg/dexpace/sdk/core/http/auth/AuthRequirement; + public fun toString ()Ljava/lang/String; +} + +public final class org/dexpace/sdk/core/http/auth/AuthRequirement$Builder : org/dexpace/sdk/core/generics/Builder { + public fun ()V + public fun (Lorg/dexpace/sdk/core/http/auth/AuthRequirement;)V + public synthetic fun build ()Ljava/lang/Object; + public fun build ()Lorg/dexpace/sdk/core/http/auth/AuthRequirement; + public final fun oauthParams (Ljava/util/Map;)Lorg/dexpace/sdk/core/http/auth/AuthRequirement$Builder; + public final fun oauthScopes (Ljava/util/List;)Lorg/dexpace/sdk/core/http/auth/AuthRequirement$Builder; + public final fun scheme (Lorg/dexpace/sdk/core/http/auth/AuthScheme;)Lorg/dexpace/sdk/core/http/auth/AuthRequirement$Builder; +} + +public final class org/dexpace/sdk/core/http/auth/AuthRequirement$Companion { + public final fun of (Lorg/dexpace/sdk/core/http/auth/AuthScheme;)Lorg/dexpace/sdk/core/http/auth/AuthRequirement; +} + +public final class org/dexpace/sdk/core/http/auth/AuthResolution { + public fun (Lorg/dexpace/sdk/core/http/auth/AuthRequirement;Lorg/dexpace/sdk/core/http/auth/AuthDescriptorTier;)V + public final fun component1 ()Lorg/dexpace/sdk/core/http/auth/AuthRequirement; + public final fun component2 ()Lorg/dexpace/sdk/core/http/auth/AuthDescriptorTier; + public final fun copy (Lorg/dexpace/sdk/core/http/auth/AuthRequirement;Lorg/dexpace/sdk/core/http/auth/AuthDescriptorTier;)Lorg/dexpace/sdk/core/http/auth/AuthResolution; + public static synthetic fun copy$default (Lorg/dexpace/sdk/core/http/auth/AuthResolution;Lorg/dexpace/sdk/core/http/auth/AuthRequirement;Lorg/dexpace/sdk/core/http/auth/AuthDescriptorTier;ILjava/lang/Object;)Lorg/dexpace/sdk/core/http/auth/AuthResolution; + public fun equals (Ljava/lang/Object;)Z + public final fun getRequirement ()Lorg/dexpace/sdk/core/http/auth/AuthRequirement; + public final fun getScheme ()Lorg/dexpace/sdk/core/http/auth/AuthScheme; + public final fun getTier ()Lorg/dexpace/sdk/core/http/auth/AuthDescriptorTier; + public fun hashCode ()I + public final fun isAnonymous ()Z + public fun toString ()Ljava/lang/String; +} + +public final class org/dexpace/sdk/core/http/auth/AuthResolutionException : java/lang/RuntimeException { + public fun (Ljava/util/List;Ljava/util/Set;)V + public final fun getAvailableSchemes ()Ljava/util/Set; + public final fun getRequiredSchemes ()Ljava/util/List; +} + public final class org/dexpace/sdk/core/http/auth/AuthScheme : java/lang/Enum { public static final field API_KEY Lorg/dexpace/sdk/core/http/auth/AuthScheme; public static final field BASIC Lorg/dexpace/sdk/core/http/auth/AuthScheme; diff --git a/sdk-core/src/main/kotlin/org/dexpace/sdk/core/http/auth/AuthDescriptor.kt b/sdk-core/src/main/kotlin/org/dexpace/sdk/core/http/auth/AuthDescriptor.kt new file mode 100644 index 00000000..1a2122f9 --- /dev/null +++ b/sdk-core/src/main/kotlin/org/dexpace/sdk/core/http/auth/AuthDescriptor.kt @@ -0,0 +1,122 @@ +/* + * Copyright (c) 2026 dexpace and Omar Aljarrah + * + * Licensed under the MIT License. See LICENSE in the project root. + * SPDX-License-Identifier: MIT + */ + +package org.dexpace.sdk.core.http.auth + +import java.util.Collections +import org.dexpace.sdk.core.generics.Builder as GenericBuilder + +/** + * Per-operation auth descriptor: the ordered set of [AuthRequirement]s an operation accepts, + * in preference order. The first requirement whose scheme can be satisfied by the available + * credentials wins (see [AuthDescriptorResolver]). + * + * This is the runtime primitive a code generator would later emit one instance of per + * operation, but it is fully hand-constructable today. A two-boolean "needs-auth / + * needs-key" model does not generalise to operations that accept several alternative + * schemes with different OAuth parameters; an ordered [requirements] list does. + * + * The descriptor is deliberately **scheme-agnostic**: it records *which* schemes are + * acceptable and in what order, never how a scheme is stamped onto the wire. Mapping a + * resolved requirement to a concrete credential and auth step is the resolver's and the + * caller's job; per-cloud / OAuth specifics stay in adapters. + * + * A descriptor that lists [AuthScheme.NO_AUTH] anywhere in [requirements] declares that the + * operation may be invoked anonymously. [allowsAnonymous] reports this; the resolver treats + * it as an always-satisfiable terminal alternative. + * + * Immutable: [requirements] is copied on the way in and exposed as an unmodifiable view. + * + * @param requirements the accepted auth alternatives, in preference order. Must not be empty. + * @throws IllegalArgumentException if [requirements] is empty. + */ +public class AuthDescriptor private constructor( + requirements: List, +) { + /** The accepted auth alternatives, in preference order; an unmodifiable defensive copy. */ + public val requirements: List = + Collections.unmodifiableList(requirements.toList()) + + /** The schemes this operation accepts, in preference order. Convenience over [requirements]. */ + public val schemes: List + get() = requirements.map { it.scheme } + + init { + require(this.requirements.isNotEmpty()) { "requirements must not be empty" } + } + + /** True if any requirement is [AuthScheme.NO_AUTH] — the operation may run anonymously. */ + public fun allowsAnonymous(): Boolean = requirements.any { it.scheme == AuthScheme.NO_AUTH } + + /** A [Builder] pre-filled with this descriptor's requirements. */ + public fun newBuilder(): Builder = Builder(this) + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is AuthDescriptor) return false + return requirements == other.requirements + } + + override fun hashCode(): Int = requirements.hashCode() + + override fun toString(): String = "AuthDescriptor(requirements=$requirements)" + + /** + * Mutable builder for [AuthDescriptor]. At least one requirement must be added before + * [build]; requirements are kept in the order they are added. + */ + public class Builder : GenericBuilder { + private val requirements: MutableList = mutableListOf() + + public constructor() + + /** Pre-fills the builder with an existing [descriptor]'s requirements. */ + public constructor(descriptor: AuthDescriptor) { + requirements.addAll(descriptor.requirements) + } + + /** Appends a single [requirement] to the preference order. */ + public fun addRequirement(requirement: AuthRequirement): Builder = + apply { + requirements.add(requirement) + } + + /** Appends a single [scheme] (with no OAuth parameters) to the preference order. */ + public fun addScheme(scheme: AuthScheme): Builder = + apply { + requirements.add(AuthRequirement.of(scheme)) + } + + /** Replaces all requirements with [requirements], preserving order. */ + public fun requirements(requirements: List): Builder = + apply { + this.requirements.clear() + this.requirements.addAll(requirements) + } + + override fun build(): AuthDescriptor = AuthDescriptor(requirements) + } + + public companion object { + /** + * Builds a descriptor from [requirements] in the given preference order. + * + * @throws IllegalArgumentException if [requirements] is empty. + */ + @JvmStatic + public fun of(requirements: List): AuthDescriptor = AuthDescriptor(requirements) + + /** + * Builds a descriptor from bare [schemes] (no OAuth parameters), in preference order. + * + * @throws IllegalArgumentException if [schemes] is empty. + */ + @JvmStatic + public fun ofSchemes(schemes: List): AuthDescriptor = + AuthDescriptor(schemes.map { AuthRequirement.of(it) }) + } +} diff --git a/sdk-core/src/main/kotlin/org/dexpace/sdk/core/http/auth/AuthDescriptorResolver.kt b/sdk-core/src/main/kotlin/org/dexpace/sdk/core/http/auth/AuthDescriptorResolver.kt new file mode 100644 index 00000000..4fe3a32f --- /dev/null +++ b/sdk-core/src/main/kotlin/org/dexpace/sdk/core/http/auth/AuthDescriptorResolver.kt @@ -0,0 +1,95 @@ +/* + * Copyright (c) 2026 dexpace and Omar Aljarrah + * + * Licensed under the MIT License. See LICENSE in the project root. + * SPDX-License-Identifier: MIT + */ + +package org.dexpace.sdk.core.http.auth + +/** + * Deterministic resolver for the per-operation auth descriptor ladder. + * + * Two independent precedence orders are applied, in this order: + * + * 1. **Tier precedence** — across the three descriptor tiers, the most specific *present* + * descriptor wins outright: per-call override > operation default > client default + * (see [AuthDescriptorTier]). A lower tier is consulted only when every higher tier is + * absent (`null`). The descriptor chosen this way is the only one resolved against — + * a per-call descriptor that cannot be satisfied does **not** fall through to the + * operation descriptor; it fails, because the caller asked for that override explicitly. + * + * 2. **Requirement precedence** — within the chosen descriptor, [AuthDescriptor.requirements] + * are tried in declared order and the first one whose scheme is *satisfiable* is returned. + * [AuthScheme.NO_AUTH] is always satisfiable (anonymous access); any other scheme is + * satisfiable iff it is in the supplied `availableSchemes` set. + * + * The resolver is **scheme-agnostic**: it never inspects a concrete [Credential] or knows how + * a scheme is stamped onto the wire. The caller supplies the set of schemes it can satisfy + * (typically derived from the credentials configured on the client), and the resolver returns + * the [AuthRequirement] to apply — mapping that requirement to a credential and an auth step + * stays with the caller / adapter. Per-cloud and OAuth specifics never reach core. + * + * Stateless and therefore safe for concurrent use; the single shared [INSTANCE] is the + * intended entry point. + */ +public class AuthDescriptorResolver { + /** + * Resolves the descriptor ladder against [availableSchemes]. + * + * At least one of [perCall], [operation], or [client] must be non-null. The most specific + * present descriptor is selected by tier precedence, then its requirements are matched in + * declared order. + * + * @param availableSchemes the schemes the caller can satisfy (e.g. from configured + * credentials). [AuthScheme.NO_AUTH] need not be present — it is always satisfiable. + * @param perCall the highest-precedence descriptor attached to this single call, or null. + * @param operation the descriptor declared by the operation, or null. + * @param client the client-wide default descriptor, or null. + * @return the resolved [AuthResolution] — the requirement to apply and the tier it came from. + * @throws IllegalArgumentException if all three descriptors are null. + * @throws AuthResolutionException if the selected descriptor lists no satisfiable scheme. + */ + @JvmOverloads + public fun resolve( + availableSchemes: Set, + perCall: AuthDescriptor? = null, + operation: AuthDescriptor? = null, + client: AuthDescriptor? = null, + ): AuthResolution { + val (descriptor, tier) = + selectDescriptor(perCall, operation, client) + ?: throw IllegalArgumentException( + "at least one of perCall, operation, or client descriptor must be supplied", + ) + + val match = + descriptor.requirements.firstOrNull { isSatisfiable(it.scheme, availableSchemes) } + ?: throw AuthResolutionException(descriptor.schemes, availableSchemes) + + return AuthResolution(match, tier) + } + + private fun selectDescriptor( + perCall: AuthDescriptor?, + operation: AuthDescriptor?, + client: AuthDescriptor?, + ): Pair? = + when { + perCall != null -> perCall to AuthDescriptorTier.PER_CALL + operation != null -> operation to AuthDescriptorTier.OPERATION + client != null -> client to AuthDescriptorTier.CLIENT + else -> null + } + + private fun isSatisfiable( + scheme: AuthScheme, + availableSchemes: Set, + ): Boolean = scheme == AuthScheme.NO_AUTH || scheme in availableSchemes + + public companion object { + /** Shared stateless resolver instance. */ + @JvmField + public val INSTANCE: AuthDescriptorResolver = AuthDescriptorResolver() + } +} diff --git a/sdk-core/src/main/kotlin/org/dexpace/sdk/core/http/auth/AuthDescriptorTier.kt b/sdk-core/src/main/kotlin/org/dexpace/sdk/core/http/auth/AuthDescriptorTier.kt new file mode 100644 index 00000000..0fcd72f7 --- /dev/null +++ b/sdk-core/src/main/kotlin/org/dexpace/sdk/core/http/auth/AuthDescriptorTier.kt @@ -0,0 +1,24 @@ +/* + * Copyright (c) 2026 dexpace and Omar Aljarrah + * + * Licensed under the MIT License. See LICENSE in the project root. + * SPDX-License-Identifier: MIT + */ + +package org.dexpace.sdk.core.http.auth + +/** + * The precedence tier an [AuthDescriptor] occupies in the resolution ladder. Listed from + * highest precedence to lowest; [AuthDescriptorResolver] consults a present descriptor in + * this exact order and resolves against the first one that is supplied. + */ +public enum class AuthDescriptorTier { + /** A descriptor attached to a single call, overriding everything below it. */ + PER_CALL, + + /** The descriptor declared by the operation being invoked. */ + OPERATION, + + /** The client-wide default descriptor, used when nothing more specific is supplied. */ + CLIENT, +} diff --git a/sdk-core/src/main/kotlin/org/dexpace/sdk/core/http/auth/AuthRequirement.kt b/sdk-core/src/main/kotlin/org/dexpace/sdk/core/http/auth/AuthRequirement.kt new file mode 100644 index 00000000..f9f242dd --- /dev/null +++ b/sdk-core/src/main/kotlin/org/dexpace/sdk/core/http/auth/AuthRequirement.kt @@ -0,0 +1,119 @@ +/* + * Copyright (c) 2026 dexpace and Omar Aljarrah + * + * Licensed under the MIT License. See LICENSE in the project root. + * SPDX-License-Identifier: MIT + */ + +package org.dexpace.sdk.core.http.auth + +import java.util.Collections +import org.dexpace.sdk.core.generics.Builder as GenericBuilder + +/** + * A single auth alternative an operation accepts: one [AuthScheme] plus any OAuth-specific + * parameters ([oauthScopes] / [oauthParams]) the auth step should forward to the token + * provider when the chosen scheme is [AuthScheme.OAUTH2]. + * + * Where [AuthMetadata] flattens "the schemes this operation supports" into a bare scheme list + * with a single set of shared OAuth parameters, an [AuthRequirement] pairs each scheme with + * its *own* OAuth parameters. An operation that accepts OAuth with `read` scopes **or** a + * static API key is two requirements, only one of which carries scopes. A list of these is + * the building block of an [AuthDescriptor]. + * + * The OAuth collections are meaningful only for [AuthScheme.OAUTH2]; for any other scheme + * they are ignored by the resolution path but still defensively copied and exposed so a + * caller can inspect what was declared. + * + * Immutable: each collection is copied on the way in and exposed as an unmodifiable view, so + * a caller that retains and later mutates the argument collection cannot mutate this instance. + * + * @param scheme the auth scheme this alternative selects. + * @param oauthScopes OAuth scopes to forward to the token provider; ignored for non-OAuth schemes. + * @param oauthParams extra OAuth params to forward (e.g. `claims`); ignored for non-OAuth schemes. + */ +public class AuthRequirement + @JvmOverloads + constructor( + scheme: AuthScheme, + oauthScopes: List = emptyList(), + oauthParams: Map = emptyMap(), + ) { + /** The auth scheme this alternative selects. */ + public val scheme: AuthScheme = scheme + + /** OAuth scopes to forward to the token provider; an unmodifiable defensive copy. */ + public val oauthScopes: List = Collections.unmodifiableList(oauthScopes.toList()) + + /** Extra OAuth params to forward; an unmodifiable defensive copy. */ + public val oauthParams: Map = Collections.unmodifiableMap(oauthParams.toMap()) + + /** A [Builder] pre-filled with this requirement's state. */ + public fun newBuilder(): Builder = Builder(this) + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is AuthRequirement) return false + return scheme == other.scheme && + oauthScopes == other.oauthScopes && + oauthParams == other.oauthParams + } + + override fun hashCode(): Int { + var result = scheme.hashCode() + result = 31 * result + oauthScopes.hashCode() + result = 31 * result + oauthParams.hashCode() + return result + } + + override fun toString(): String = + "AuthRequirement(scheme=$scheme, oauthScopes=$oauthScopes, oauthParams=$oauthParams)" + + /** + * Mutable builder for [AuthRequirement]. The [scheme] is mandatory; OAuth collections + * default to empty. + */ + public class Builder() : GenericBuilder { + private var scheme: AuthScheme? = null + private var oauthScopes: List = emptyList() + private var oauthParams: Map = emptyMap() + + /** Pre-fills the builder with an existing [requirement]'s state. */ + public constructor(requirement: AuthRequirement) : this() { + scheme = requirement.scheme + oauthScopes = requirement.oauthScopes + oauthParams = requirement.oauthParams + } + + /** Sets the auth scheme (required). */ + public fun scheme(scheme: AuthScheme): Builder = + apply { + this.scheme = scheme + } + + /** Replaces the OAuth scopes. */ + public fun oauthScopes(oauthScopes: List): Builder = + apply { + this.oauthScopes = oauthScopes + } + + /** Replaces the OAuth params. */ + public fun oauthParams(oauthParams: Map): Builder = + apply { + this.oauthParams = oauthParams + } + + override fun build(): AuthRequirement = + AuthRequirement( + scheme = checkNotNull(scheme) { "scheme is required" }, + oauthScopes = oauthScopes, + oauthParams = oauthParams, + ) + } + + public companion object { + /** Convenience: a requirement for [scheme] with no OAuth parameters. */ + @JvmStatic + public fun of(scheme: AuthScheme): AuthRequirement = AuthRequirement(scheme) + } + } diff --git a/sdk-core/src/main/kotlin/org/dexpace/sdk/core/http/auth/AuthResolution.kt b/sdk-core/src/main/kotlin/org/dexpace/sdk/core/http/auth/AuthResolution.kt new file mode 100644 index 00000000..05e90d86 --- /dev/null +++ b/sdk-core/src/main/kotlin/org/dexpace/sdk/core/http/auth/AuthResolution.kt @@ -0,0 +1,34 @@ +/* + * Copyright (c) 2026 dexpace and Omar Aljarrah + * + * Licensed under the MIT License. See LICENSE in the project root. + * SPDX-License-Identifier: MIT + */ + +package org.dexpace.sdk.core.http.auth + +/** + * The outcome of resolving an auth descriptor ladder against the available schemes — the + * single [AuthRequirement] the auth step should apply, the [AuthDescriptorTier] the winning + * descriptor came from, and whether the resolution chose the anonymous ([AuthScheme.NO_AUTH]) + * alternative. + * + * Value semantics via `data class`: two resolutions are equal when they pick the same + * requirement from the same tier. + * + * @param requirement the chosen auth alternative; its [AuthRequirement.scheme] is the scheme + * to apply, and its OAuth parameters are forwarded for [AuthScheme.OAUTH2]. + * @param tier the precedence tier of the descriptor the requirement was resolved from. + */ +public data class AuthResolution( + val requirement: AuthRequirement, + val tier: AuthDescriptorTier, +) { + /** The scheme to apply. Shorthand for `requirement.scheme`. */ + val scheme: AuthScheme + get() = requirement.scheme + + /** True when the resolved alternative is the anonymous [AuthScheme.NO_AUTH] marker. */ + val isAnonymous: Boolean + get() = requirement.scheme == AuthScheme.NO_AUTH +} diff --git a/sdk-core/src/main/kotlin/org/dexpace/sdk/core/http/auth/AuthResolutionException.kt b/sdk-core/src/main/kotlin/org/dexpace/sdk/core/http/auth/AuthResolutionException.kt new file mode 100644 index 00000000..6b03ed58 --- /dev/null +++ b/sdk-core/src/main/kotlin/org/dexpace/sdk/core/http/auth/AuthResolutionException.kt @@ -0,0 +1,44 @@ +/* + * Copyright (c) 2026 dexpace and Omar Aljarrah + * + * Licensed under the MIT License. See LICENSE in the project root. + * SPDX-License-Identifier: MIT + */ + +package org.dexpace.sdk.core.http.auth + +import java.util.Collections + +/** + * Thrown when an [AuthDescriptor] ladder cannot be satisfied: the resolved descriptor lists + * one or more required schemes, none of which is covered by the available schemes (and the + * descriptor does not permit anonymous access). + * + * The message names the schemes the operation accepts so the caller learns *which* credential + * to configure — e.g. `operation requires one of [OAUTH2, API_KEY] but no matching credential + * is available (have: [BASIC])`. + * + * @param requiredSchemes the schemes the failed descriptor accepts, in preference order. + * @param availableSchemes the schemes the caller could satisfy at resolution time. + */ +public class AuthResolutionException( + requiredSchemes: List, + availableSchemes: Set, +) : RuntimeException(buildMessage(requiredSchemes, availableSchemes)) { + /** The schemes the failed descriptor accepts; an unmodifiable defensive copy. */ + public val requiredSchemes: List = + Collections.unmodifiableList(requiredSchemes.toList()) + + /** The schemes the caller could satisfy; an unmodifiable defensive copy. */ + public val availableSchemes: Set = + Collections.unmodifiableSet(availableSchemes.toSet()) + + private companion object { + private fun buildMessage( + required: List, + available: Set, + ): String = + "operation requires one of $required but no matching credential is available " + + "(have: $available)" + } +} diff --git a/sdk-core/src/test/kotlin/org/dexpace/sdk/core/http/auth/AuthDescriptorResolverTest.kt b/sdk-core/src/test/kotlin/org/dexpace/sdk/core/http/auth/AuthDescriptorResolverTest.kt new file mode 100644 index 00000000..ca89bb6b --- /dev/null +++ b/sdk-core/src/test/kotlin/org/dexpace/sdk/core/http/auth/AuthDescriptorResolverTest.kt @@ -0,0 +1,186 @@ +/* + * Copyright (c) 2026 dexpace and Omar Aljarrah + * + * Licensed under the MIT License. See LICENSE in the project root. + * SPDX-License-Identifier: MIT + */ + +package org.dexpace.sdk.core.http.auth + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertTrue + +class AuthDescriptorResolverTest { + private val resolver = AuthDescriptorResolver.INSTANCE + + private fun descriptor(vararg schemes: AuthScheme): AuthDescriptor = AuthDescriptor.ofSchemes(schemes.toList()) + + // ---- tier precedence ------------------------------------------------------------------ + + @Test + fun `per-call descriptor wins over operation and client`() { + val resolution = + resolver.resolve( + availableSchemes = setOf(AuthScheme.OAUTH2, AuthScheme.API_KEY, AuthScheme.BASIC), + perCall = descriptor(AuthScheme.BASIC), + operation = descriptor(AuthScheme.API_KEY), + client = descriptor(AuthScheme.OAUTH2), + ) + assertEquals(AuthDescriptorTier.PER_CALL, resolution.tier) + assertEquals(AuthScheme.BASIC, resolution.scheme) + } + + @Test + fun `operation descriptor wins over client when per-call is absent`() { + val resolution = + resolver.resolve( + availableSchemes = setOf(AuthScheme.API_KEY, AuthScheme.OAUTH2), + operation = descriptor(AuthScheme.API_KEY), + client = descriptor(AuthScheme.OAUTH2), + ) + assertEquals(AuthDescriptorTier.OPERATION, resolution.tier) + assertEquals(AuthScheme.API_KEY, resolution.scheme) + } + + @Test + fun `client descriptor is used when nothing more specific is supplied`() { + val resolution = + resolver.resolve( + availableSchemes = setOf(AuthScheme.OAUTH2), + client = descriptor(AuthScheme.OAUTH2), + ) + assertEquals(AuthDescriptorTier.CLIENT, resolution.tier) + assertEquals(AuthScheme.OAUTH2, resolution.scheme) + } + + @Test + fun `a present higher tier is resolved against even when a lower tier would succeed`() { + // per-call lists only BASIC which is NOT available; the operation lists OAUTH2 which IS. + // The ladder must NOT fall through: the explicit per-call override fails outright. + val ex = + assertFailsWith { + resolver.resolve( + availableSchemes = setOf(AuthScheme.OAUTH2), + perCall = descriptor(AuthScheme.BASIC), + operation = descriptor(AuthScheme.OAUTH2), + ) + } + assertEquals(listOf(AuthScheme.BASIC), ex.requiredSchemes) + } + + // ---- requirement precedence within a descriptor --------------------------------------- + + @Test + fun `first satisfiable requirement in declared order wins`() { + val resolution = + resolver.resolve( + availableSchemes = setOf(AuthScheme.OAUTH2, AuthScheme.API_KEY), + operation = descriptor(AuthScheme.OAUTH2, AuthScheme.API_KEY), + ) + assertEquals(AuthScheme.OAUTH2, resolution.scheme) + } + + @Test + fun `unsatisfiable leading requirement is skipped for the next satisfiable one`() { + val resolution = + resolver.resolve( + availableSchemes = setOf(AuthScheme.API_KEY), + operation = descriptor(AuthScheme.OAUTH2, AuthScheme.API_KEY), + ) + assertEquals(AuthScheme.API_KEY, resolution.scheme) + } + + @Test + fun `forwards oauth parameters from the resolved requirement`() { + val operation = + AuthDescriptor.of( + listOf( + AuthRequirement(AuthScheme.OAUTH2, listOf("read", "write"), mapOf("a" to "1")), + ), + ) + val resolution = + resolver.resolve(availableSchemes = setOf(AuthScheme.OAUTH2), operation = operation) + assertEquals(listOf("read", "write"), resolution.requirement.oauthScopes) + assertEquals(mapOf("a" to "1"), resolution.requirement.oauthParams) + } + + // ---- anonymous handling --------------------------------------------------------------- + + @Test + fun `NO_AUTH is always satisfiable without any available scheme`() { + val resolution = + resolver.resolve( + availableSchemes = emptySet(), + operation = descriptor(AuthScheme.NO_AUTH), + ) + assertTrue(resolution.isAnonymous) + assertEquals(AuthScheme.NO_AUTH, resolution.scheme) + } + + @Test + fun `a real scheme is preferred over a trailing NO_AUTH fallback when available`() { + val resolution = + resolver.resolve( + availableSchemes = setOf(AuthScheme.OAUTH2), + operation = descriptor(AuthScheme.OAUTH2, AuthScheme.NO_AUTH), + ) + assertEquals(AuthScheme.OAUTH2, resolution.scheme) + assertTrue(!resolution.isAnonymous) + } + + @Test + fun `NO_AUTH fallback resolves anonymously when the real scheme is unavailable`() { + val resolution = + resolver.resolve( + availableSchemes = emptySet(), + operation = descriptor(AuthScheme.OAUTH2, AuthScheme.NO_AUTH), + ) + assertTrue(resolution.isAnonymous) + } + + // ---- failure / argument validation ---------------------------------------------------- + + @Test + fun `no satisfiable scheme raises a tailored error naming required and available`() { + val ex = + assertFailsWith { + resolver.resolve( + availableSchemes = setOf(AuthScheme.BASIC), + operation = descriptor(AuthScheme.OAUTH2, AuthScheme.API_KEY), + ) + } + assertEquals(listOf(AuthScheme.OAUTH2, AuthScheme.API_KEY), ex.requiredSchemes) + assertEquals(setOf(AuthScheme.BASIC), ex.availableSchemes) + val message = ex.message ?: "" + assertTrue(message.contains("OAUTH2")) + assertTrue(message.contains("API_KEY")) + assertTrue(message.contains("BASIC")) + } + + @Test + fun `all-null descriptors raises IllegalArgumentException`() { + val ex = + assertFailsWith { + resolver.resolve(availableSchemes = setOf(AuthScheme.OAUTH2)) + } + assertTrue(ex.message!!.contains("at least one")) + } + + @Test + fun `shared INSTANCE is reusable across calls`() { + val a = + AuthDescriptorResolver.INSTANCE.resolve( + availableSchemes = setOf(AuthScheme.OAUTH2), + client = descriptor(AuthScheme.OAUTH2), + ) + val b = + AuthDescriptorResolver.INSTANCE.resolve( + availableSchemes = setOf(AuthScheme.API_KEY), + client = descriptor(AuthScheme.API_KEY), + ) + assertEquals(AuthScheme.OAUTH2, a.scheme) + assertEquals(AuthScheme.API_KEY, b.scheme) + } +} diff --git a/sdk-core/src/test/kotlin/org/dexpace/sdk/core/http/auth/AuthDescriptorTest.kt b/sdk-core/src/test/kotlin/org/dexpace/sdk/core/http/auth/AuthDescriptorTest.kt new file mode 100644 index 00000000..7ed1e835 --- /dev/null +++ b/sdk-core/src/test/kotlin/org/dexpace/sdk/core/http/auth/AuthDescriptorTest.kt @@ -0,0 +1,138 @@ +/* + * Copyright (c) 2026 dexpace and Omar Aljarrah + * + * Licensed under the MIT License. See LICENSE in the project root. + * SPDX-License-Identifier: MIT + */ + +package org.dexpace.sdk.core.http.auth + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertNotEquals +import kotlin.test.assertTrue + +class AuthDescriptorTest { + @Test + fun `ofSchemes preserves preference order`() { + val descriptor = + AuthDescriptor.ofSchemes( + listOf(AuthScheme.OAUTH2, AuthScheme.API_KEY, AuthScheme.BASIC), + ) + assertEquals( + listOf(AuthScheme.OAUTH2, AuthScheme.API_KEY, AuthScheme.BASIC), + descriptor.schemes, + ) + } + + @Test + fun `of preserves requirement order and exposes schemes view`() { + val requirements = + listOf( + AuthRequirement(AuthScheme.OAUTH2, listOf("read")), + AuthRequirement.of(AuthScheme.API_KEY), + ) + val descriptor = AuthDescriptor.of(requirements) + assertEquals(requirements, descriptor.requirements) + assertEquals(listOf(AuthScheme.OAUTH2, AuthScheme.API_KEY), descriptor.schemes) + } + + @Test + fun `empty requirements list is rejected`() { + val ex = assertFailsWith { AuthDescriptor.of(emptyList()) } + assertEquals("requirements must not be empty", ex.message) + } + + @Test + fun `empty schemes list is rejected`() { + assertFailsWith { AuthDescriptor.ofSchemes(emptyList()) } + } + + @Test + fun `builder with no requirements is rejected on build`() { + assertFailsWith { AuthDescriptor.Builder().build() } + } + + @Test + fun `allowsAnonymous is true only when NO_AUTH is present`() { + assertFalse(AuthDescriptor.ofSchemes(listOf(AuthScheme.BASIC)).allowsAnonymous()) + assertTrue( + AuthDescriptor.ofSchemes(listOf(AuthScheme.BASIC, AuthScheme.NO_AUTH)) + .allowsAnonymous(), + ) + } + + @Test + fun `builder addScheme and addRequirement keep insertion order`() { + val descriptor = + AuthDescriptor.Builder() + .addScheme(AuthScheme.OAUTH2) + .addRequirement(AuthRequirement.of(AuthScheme.API_KEY)) + .addScheme(AuthScheme.BASIC) + .build() + assertEquals( + listOf(AuthScheme.OAUTH2, AuthScheme.API_KEY, AuthScheme.BASIC), + descriptor.schemes, + ) + } + + @Test + fun `builder requirements replaces all prior entries`() { + val descriptor = + AuthDescriptor.Builder() + .addScheme(AuthScheme.OAUTH2) + .requirements(listOf(AuthRequirement.of(AuthScheme.BASIC))) + .build() + assertEquals(listOf(AuthScheme.BASIC), descriptor.schemes) + } + + @Test + fun `newBuilder round-trips requirements`() { + val original = + AuthDescriptor.of( + listOf( + AuthRequirement(AuthScheme.OAUTH2, listOf("read")), + AuthRequirement.of(AuthScheme.API_KEY), + ), + ) + assertEquals(original, original.newBuilder().build()) + } + + @Test + fun `newBuilder can append an alternative`() { + val original = AuthDescriptor.ofSchemes(listOf(AuthScheme.OAUTH2)) + val extended = original.newBuilder().addScheme(AuthScheme.API_KEY).build() + assertEquals(listOf(AuthScheme.OAUTH2, AuthScheme.API_KEY), extended.schemes) + } + + @Test + fun `requirements view is unmodifiable and defensively copied`() { + val source = mutableListOf(AuthRequirement.of(AuthScheme.OAUTH2)) + val descriptor = AuthDescriptor.of(source) + source.add(AuthRequirement.of(AuthScheme.API_KEY)) + assertEquals(listOf(AuthScheme.OAUTH2), descriptor.schemes) + assertFailsWith { + @Suppress("UNCHECKED_CAST") + (descriptor.requirements as MutableList) + .add(AuthRequirement.of(AuthScheme.BASIC)) + } + } + + @Test + fun `equals and hashCode reflect requirement order`() { + val a = AuthDescriptor.ofSchemes(listOf(AuthScheme.OAUTH2, AuthScheme.API_KEY)) + val b = AuthDescriptor.ofSchemes(listOf(AuthScheme.OAUTH2, AuthScheme.API_KEY)) + val reordered = AuthDescriptor.ofSchemes(listOf(AuthScheme.API_KEY, AuthScheme.OAUTH2)) + assertEquals(a, b) + assertEquals(a.hashCode(), b.hashCode()) + assertNotEquals(a, reordered) + } + + @Test + fun `toString includes the requirements`() { + val text = AuthDescriptor.ofSchemes(listOf(AuthScheme.BASIC)).toString() + assertTrue(text.contains("BASIC")) + } +} diff --git a/sdk-core/src/test/kotlin/org/dexpace/sdk/core/http/auth/AuthDescriptorTierTest.kt b/sdk-core/src/test/kotlin/org/dexpace/sdk/core/http/auth/AuthDescriptorTierTest.kt new file mode 100644 index 00000000..bced8b43 --- /dev/null +++ b/sdk-core/src/test/kotlin/org/dexpace/sdk/core/http/auth/AuthDescriptorTierTest.kt @@ -0,0 +1,32 @@ +/* + * Copyright (c) 2026 dexpace and Omar Aljarrah + * + * Licensed under the MIT License. See LICENSE in the project root. + * SPDX-License-Identifier: MIT + */ + +package org.dexpace.sdk.core.http.auth + +import kotlin.test.Test +import kotlin.test.assertEquals + +class AuthDescriptorTierTest { + @Test + fun `tiers are declared from highest to lowest precedence`() { + assertEquals( + listOf( + AuthDescriptorTier.PER_CALL, + AuthDescriptorTier.OPERATION, + AuthDescriptorTier.CLIENT, + ), + AuthDescriptorTier.entries.toList(), + ) + } + + @Test + fun `valueOf round-trips each tier`() { + for (tier in AuthDescriptorTier.entries) { + assertEquals(tier, AuthDescriptorTier.valueOf(tier.name)) + } + } +} diff --git a/sdk-core/src/test/kotlin/org/dexpace/sdk/core/http/auth/AuthRequirementTest.kt b/sdk-core/src/test/kotlin/org/dexpace/sdk/core/http/auth/AuthRequirementTest.kt new file mode 100644 index 00000000..51784b76 --- /dev/null +++ b/sdk-core/src/test/kotlin/org/dexpace/sdk/core/http/auth/AuthRequirementTest.kt @@ -0,0 +1,107 @@ +/* + * Copyright (c) 2026 dexpace and Omar Aljarrah + * + * Licensed under the MIT License. See LICENSE in the project root. + * SPDX-License-Identifier: MIT + */ + +package org.dexpace.sdk.core.http.auth + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertNotEquals +import kotlin.test.assertTrue + +class AuthRequirementTest { + @Test + fun `of populates scheme with empty oauth defaults`() { + val req = AuthRequirement.of(AuthScheme.API_KEY) + assertEquals(AuthScheme.API_KEY, req.scheme) + assertTrue(req.oauthScopes.isEmpty()) + assertTrue(req.oauthParams.isEmpty()) + } + + @Test + fun `constructor accepts oauth scopes and params`() { + val req = + AuthRequirement(AuthScheme.OAUTH2, listOf("read", "write"), mapOf("claims" to "x")) + assertEquals(listOf("read", "write"), req.oauthScopes) + assertEquals(mapOf("claims" to "x"), req.oauthParams) + } + + @Test + fun `mutating source collections after construction does not affect the instance`() { + val scopes = mutableListOf("read") + val params = mutableMapOf("a" to "1") + val req = AuthRequirement(AuthScheme.OAUTH2, scopes, params) + scopes.add("write") + params["b"] = "2" + assertEquals(listOf("read"), req.oauthScopes) + assertEquals(mapOf("a" to "1"), req.oauthParams) + } + + @Test + fun `exposed collections are unmodifiable`() { + val req = AuthRequirement(AuthScheme.OAUTH2, listOf("read"), mapOf("a" to "1")) + assertFailsWith { + @Suppress("UNCHECKED_CAST") + (req.oauthScopes as MutableList).add("write") + } + assertFailsWith { + @Suppress("UNCHECKED_CAST") + (req.oauthParams as MutableMap)["b"] = "2" + } + } + + @Test + fun `builder requires a scheme`() { + val ex = assertFailsWith { AuthRequirement.Builder().build() } + assertEquals("scheme is required", ex.message) + } + + @Test + fun `builder sets all fields`() { + val req = + AuthRequirement.Builder() + .scheme(AuthScheme.OAUTH2) + .oauthScopes(listOf("read")) + .oauthParams(mapOf("a" to "1")) + .build() + assertEquals(AuthScheme.OAUTH2, req.scheme) + assertEquals(listOf("read"), req.oauthScopes) + assertEquals(mapOf("a" to "1"), req.oauthParams) + } + + @Test + fun `newBuilder round-trips state`() { + val original = AuthRequirement(AuthScheme.OAUTH2, listOf("read"), mapOf("a" to "1")) + val copy = original.newBuilder().build() + assertEquals(original, copy) + } + + @Test + fun `newBuilder allows overriding a single field`() { + val original = AuthRequirement(AuthScheme.OAUTH2, listOf("read")) + val modified = original.newBuilder().oauthScopes(listOf("write")).build() + assertEquals(AuthScheme.OAUTH2, modified.scheme) + assertEquals(listOf("write"), modified.oauthScopes) + } + + @Test + fun `equals and hashCode reflect value semantics`() { + val a = AuthRequirement(AuthScheme.OAUTH2, listOf("read")) + val b = AuthRequirement(AuthScheme.OAUTH2, listOf("read")) + val c = AuthRequirement(AuthScheme.OAUTH2, listOf("write")) + assertEquals(a, b) + assertEquals(a.hashCode(), b.hashCode()) + assertNotEquals(a, c) + } + + @Test + fun `toString includes scheme and oauth fields`() { + val text = AuthRequirement(AuthScheme.OAUTH2, listOf("read")).toString() + assertTrue(text.contains("OAUTH2")) + assertTrue(text.contains("read")) + } +} diff --git a/sdk-core/src/test/kotlin/org/dexpace/sdk/core/http/auth/AuthResolutionExceptionTest.kt b/sdk-core/src/test/kotlin/org/dexpace/sdk/core/http/auth/AuthResolutionExceptionTest.kt new file mode 100644 index 00000000..4017f799 --- /dev/null +++ b/sdk-core/src/test/kotlin/org/dexpace/sdk/core/http/auth/AuthResolutionExceptionTest.kt @@ -0,0 +1,66 @@ +/* + * Copyright (c) 2026 dexpace and Omar Aljarrah + * + * Licensed under the MIT License. See LICENSE in the project root. + * SPDX-License-Identifier: MIT + */ + +package org.dexpace.sdk.core.http.auth + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertTrue + +class AuthResolutionExceptionTest { + @Test + fun `message names required and available schemes`() { + val ex = + AuthResolutionException( + listOf(AuthScheme.OAUTH2, AuthScheme.API_KEY), + setOf(AuthScheme.BASIC), + ) + val message = ex.message ?: "" + assertTrue(message.contains("OAUTH2")) + assertTrue(message.contains("API_KEY")) + assertTrue(message.contains("BASIC")) + } + + @Test + fun `exposes required and available schemes`() { + val ex = + AuthResolutionException( + listOf(AuthScheme.OAUTH2), + setOf(AuthScheme.BASIC, AuthScheme.DIGEST), + ) + assertEquals(listOf(AuthScheme.OAUTH2), ex.requiredSchemes) + assertEquals(setOf(AuthScheme.BASIC, AuthScheme.DIGEST), ex.availableSchemes) + } + + @Test + fun `exposed collections are unmodifiable defensive copies`() { + val required = mutableListOf(AuthScheme.OAUTH2) + val available = mutableSetOf(AuthScheme.BASIC) + val ex = AuthResolutionException(required, available) + required.add(AuthScheme.API_KEY) + available.add(AuthScheme.DIGEST) + assertEquals(listOf(AuthScheme.OAUTH2), ex.requiredSchemes) + assertEquals(setOf(AuthScheme.BASIC), ex.availableSchemes) + assertFailsWith { + @Suppress("UNCHECKED_CAST") + (ex.requiredSchemes as MutableList).add(AuthScheme.DIGEST) + } + assertFailsWith { + @Suppress("UNCHECKED_CAST") + (ex.availableSchemes as MutableSet).add(AuthScheme.DIGEST) + } + } + + @Test + fun `is an unchecked RuntimeException`() { + // Assigning to a RuntimeException reference confirms the unchecked-throw contract + // without an always-true is-check (which -Werror rejects). + val ex: RuntimeException = AuthResolutionException(listOf(AuthScheme.OAUTH2), emptySet()) + assertTrue(ex.message!!.isNotBlank()) + } +} diff --git a/sdk-core/src/test/kotlin/org/dexpace/sdk/core/http/auth/AuthResolutionTest.kt b/sdk-core/src/test/kotlin/org/dexpace/sdk/core/http/auth/AuthResolutionTest.kt new file mode 100644 index 00000000..8666b98f --- /dev/null +++ b/sdk-core/src/test/kotlin/org/dexpace/sdk/core/http/auth/AuthResolutionTest.kt @@ -0,0 +1,44 @@ +/* + * Copyright (c) 2026 dexpace and Omar Aljarrah + * + * Licensed under the MIT License. See LICENSE in the project root. + * SPDX-License-Identifier: MIT + */ + +package org.dexpace.sdk.core.http.auth + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class AuthResolutionTest { + @Test + fun `scheme shorthand mirrors the requirement scheme`() { + val resolution = + AuthResolution(AuthRequirement.of(AuthScheme.API_KEY), AuthDescriptorTier.OPERATION) + assertEquals(AuthScheme.API_KEY, resolution.scheme) + } + + @Test + fun `isAnonymous is true only for NO_AUTH`() { + assertTrue( + AuthResolution(AuthRequirement.of(AuthScheme.NO_AUTH), AuthDescriptorTier.CLIENT) + .isAnonymous, + ) + assertFalse( + AuthResolution(AuthRequirement.of(AuthScheme.OAUTH2), AuthDescriptorTier.CLIENT) + .isAnonymous, + ) + } + + @Test + fun `value semantics`() { + val a = + AuthResolution(AuthRequirement.of(AuthScheme.OAUTH2), AuthDescriptorTier.PER_CALL) + val b = + AuthResolution(AuthRequirement.of(AuthScheme.OAUTH2), AuthDescriptorTier.PER_CALL) + assertEquals(a, b) + assertEquals(a.hashCode(), b.hashCode()) + } +}