diff --git a/README.md b/README.md index f3812d1d..bb4407c6 100644 --- a/README.md +++ b/README.md @@ -269,7 +269,7 @@ See [docs/pipelines.md](docs/pipelines.md) for the step-author walkthrough. | `http.context` | `CallContext` → `DispatchContext` → `RequestContext` → `ExchangeContext` chain, `ContextStore`. | | `http.pipeline` | Sync (`HttpStep` / `HttpPipeline` / `HttpPipelineBuilder` / `PipelineNext` / `Stage`) and async (`AsyncHttpStep` / `AsyncHttpPipeline` / `AsyncHttpPipelineBuilder` / `AsyncPipelineNext`) pipeline machinery, plus `AsyncPipelineBridges`. | | `http.pipeline.steps` | Concrete steps: `RetryStep`, `RedirectStep`, `AuthStep`, `KeyCredentialAuthStep`, `BearerTokenAuthStep`, `InstrumentationStep`, `SetDateStep`, and their `*Options` / `*Condition` types. | -| `http.auth` | `Credential` sealed hierarchy (`KeyCredential`, `NamedKeyCredential`, `BearerToken`), `BearerTokenProvider`, `AuthScheme`, `AuthMetadata`, RFC 7235 challenge parser, `BasicChallengeHandler`, `DigestChallengeHandler`, `CompositeChallengeHandler`. | +| `http.auth` | `Credential` sealed hierarchy (`KeyCredential`, `NamedKeyCredential`, `BearerToken`), `BearerTokenProvider`, `AuthScheme`, per-operation `AuthRequirement` / `AuthDescriptor` with `AuthDescriptorResolver` precedence ladder, RFC 7235 challenge parser, `BasicChallengeHandler`, `DigestChallengeHandler`, `CompositeChallengeHandler`. | | `http.sse` | `ServerSentEventReader` (WHATWG spec), `ServerSentEvent`, `ServerSentEventListener`, `BufferedSource.readServerSentEvents()`. | | `http.paging` | `PagedIterable`, `PagedResponse`, `PagingOptions` with `byPage()` and `stream()` accessors. | | `pagination` | `Paginator` (with a `maxPages` safety cap) over cursor / page-number / link-header `PaginationStrategy` implementations, plus `Page` / `SimplePage`. Token-style APIs use `CursorPaginationStrategy` with the query-param name set (e.g. `"page_token"`). | diff --git a/sdk-core/api/sdk-core.api b/sdk-core/api/sdk-core.api index 7ebcc312..59b5afc0 100644 --- a/sdk-core/api/sdk-core.api +++ b/sdk-core/api/sdk-core.api @@ -65,14 +65,109 @@ 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/AuthMetadata { - public fun (Ljava/util/List;)V - public fun (Ljava/util/List;Ljava/util/List;)V - public fun (Ljava/util/List;Ljava/util/List;Ljava/util/Map;)V - public synthetic fun (Ljava/util/List;Ljava/util/List;Ljava/util/Map;ILkotlin/jvm/internal/DefaultConstructorMarker;)V +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/AuthRequirement { + public static final field Companion Lorg/dexpace/sdk/core/http/auth/AuthRequirement$Companion; + public synthetic fun (Lorg/dexpace/sdk/core/http/auth/AuthScheme;Ljava/util/List;Ljava/util/Map;Lkotlin/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 getSchemes ()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 static final fun of (Lorg/dexpace/sdk/core/http/auth/AuthScheme;Ljava/util/List;)Lorg/dexpace/sdk/core/http/auth/AuthRequirement; + public static final fun of (Lorg/dexpace/sdk/core/http/auth/AuthScheme;Ljava/util/List;Ljava/util/Map;)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 fun of (Lorg/dexpace/sdk/core/http/auth/AuthScheme;Ljava/util/List;)Lorg/dexpace/sdk/core/http/auth/AuthRequirement; + public final fun of (Lorg/dexpace/sdk/core/http/auth/AuthScheme;Ljava/util/List;Ljava/util/Map;)Lorg/dexpace/sdk/core/http/auth/AuthRequirement; + public static synthetic fun of$default (Lorg/dexpace/sdk/core/http/auth/AuthRequirement$Companion;Lorg/dexpace/sdk/core/http/auth/AuthScheme;Ljava/util/List;Ljava/util/Map;ILjava/lang/Object;)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 { 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/AuthMetadata.kt b/sdk-core/src/main/kotlin/org/dexpace/sdk/core/http/auth/AuthMetadata.kt deleted file mode 100644 index 11b16b18..00000000 --- a/sdk-core/src/main/kotlin/org/dexpace/sdk/core/http/auth/AuthMetadata.kt +++ /dev/null @@ -1,54 +0,0 @@ -/* - * 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 - -/** - * Per-request auth metadata describing which [AuthScheme]s an operation supports and any - * OAuth-specific parameters (scopes / extra params) the auth step should forward to the - * [BearerTokenProvider]. - * - * **Not currently wired into [org.dexpace.sdk.core.http.pipeline.steps.AuthStep]'s lookup - * path.** [org.dexpace.sdk.core.http.context.RequestContext] is presently a fixed-field - * `data class` with no typed-key attachment mechanism, so there is no general way to - * attach an `AuthMetadata` to a request and have the step retrieve it. Wiring this into - * per-request lookup is deferred until the SDK adopts a typed-key context store on - * `RequestContext`; the type is defined here so downstream code (operation generators, - * documentation, future wiring) can reference it without a parallel refactor blocking - * Rank 6. - * - * Each collection is defensively 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 (or re-break the non-empty [schemes] invariant) after construction. - * - * @param schemes the auth schemes this operation supports, in preference order. Must not be empty. - * @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`); defaults to empty. - * @throws IllegalArgumentException if [schemes] is empty. - */ -public class AuthMetadata - @JvmOverloads - constructor( - schemes: List, - oauthScopes: List = emptyList(), - oauthParams: Map = emptyMap(), - ) { - /** The supported auth schemes, in preference order; an unmodifiable defensive copy. */ - public val schemes: List = Collections.unmodifiableList(schemes.toList()) - - /** 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()) - - init { - require(this.schemes.isNotEmpty()) { "schemes must not be empty" } - } - } 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..1279c253 --- /dev/null +++ b/sdk-core/src/main/kotlin/org/dexpace/sdk/core/http/auth/AuthRequirement.kt @@ -0,0 +1,125 @@ +/* + * 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]. + * + * Each requirement pairs a scheme with its *own* OAuth parameters, so an operation that + * accepts OAuth with `read` scopes **or** a static API key is two requirements, only one of + * which carries scopes. An ordered list of these, in preference order, 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. + * Instances are obtained via [of] or [Builder], never a public constructor. + * + * @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 private constructor( + scheme: AuthScheme, + oauthScopes: List, + oauthParams: Map, +) { + /** 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 and are defensively copied on the way in. + */ + 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 with a defensive copy of [oauthScopes]. */ + public fun oauthScopes(oauthScopes: List): Builder = + apply { + this.oauthScopes = oauthScopes.toList() + } + + /** Replaces the OAuth params with a defensive copy of [oauthParams]. */ + public fun oauthParams(oauthParams: Map): Builder = + apply { + this.oauthParams = oauthParams.toMap() + } + + override fun build(): AuthRequirement = + AuthRequirement( + scheme = checkNotNull(scheme) { "scheme is required" }, + oauthScopes = oauthScopes, + oauthParams = oauthParams, + ) + } + + public companion object { + /** + * A requirement for [scheme], optionally carrying OAuth [oauthScopes] / [oauthParams] + * (both default to empty and are meaningful only for [AuthScheme.OAUTH2]). + */ + @JvmStatic + @JvmOverloads + public fun of( + scheme: AuthScheme, + oauthScopes: List = emptyList(), + oauthParams: Map = emptyMap(), + ): AuthRequirement = AuthRequirement(scheme, oauthScopes, oauthParams) + } +} 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/main/kotlin/org/dexpace/sdk/core/http/auth/AuthScheme.kt b/sdk-core/src/main/kotlin/org/dexpace/sdk/core/http/auth/AuthScheme.kt index f9ccdd9c..00c15da9 100644 --- a/sdk-core/src/main/kotlin/org/dexpace/sdk/core/http/auth/AuthScheme.kt +++ b/sdk-core/src/main/kotlin/org/dexpace/sdk/core/http/auth/AuthScheme.kt @@ -12,8 +12,8 @@ package org.dexpace.sdk.core.http.auth /** * HTTP authentication schemes recognised by the SDK. * - * Used in [AuthMetadata] to describe the schemes a per-operation request supports, and to - * mark requests that must skip auth ([NO_AUTH]). + * Used in [AuthRequirement] / [AuthDescriptor] to describe the schemes a per-operation request + * accepts, and to mark requests that must skip auth ([NO_AUTH]). */ @Suppress("unused") public enum class AuthScheme { @@ -30,7 +30,7 @@ public enum class AuthScheme { DIGEST, /** - * Explicit "skip auth" marker — present on per-request [AuthMetadata] to bypass an + * Explicit "skip auth" marker — listed in an [AuthDescriptor] to bypass an * otherwise-configured credential step (e.g. anonymous probes against an authenticated * service). */ 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..4c69d6f5 --- /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.of(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..9cd1b20a --- /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.of(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.of(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/AuthMetadataTest.kt b/sdk-core/src/test/kotlin/org/dexpace/sdk/core/http/auth/AuthMetadataTest.kt deleted file mode 100644 index 091d7963..00000000 --- a/sdk-core/src/test/kotlin/org/dexpace/sdk/core/http/auth/AuthMetadataTest.kt +++ /dev/null @@ -1,107 +0,0 @@ -/* - * 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 AuthMetadataTest { - @Test - fun `single-scheme constructor populates schemes and uses empty defaults for oauth fields`() { - val metadata = AuthMetadata(listOf(AuthScheme.BASIC)) - assertEquals(listOf(AuthScheme.BASIC), metadata.schemes) - assertTrue(metadata.oauthScopes.isEmpty()) - assertTrue(metadata.oauthParams.isEmpty()) - } - - @Test - fun `multi-scheme constructor preserves scheme order`() { - val schemes = listOf(AuthScheme.DIGEST, AuthScheme.BASIC, AuthScheme.OAUTH2) - val metadata = AuthMetadata(schemes) - assertEquals(schemes, metadata.schemes) - } - - @Test - fun `constructor accepts oauth scopes and params`() { - val scopes = listOf("read", "write") - val params = mapOf("claims" to "{\"id_token\":{\"email\":null}}") - val metadata = AuthMetadata(listOf(AuthScheme.OAUTH2), scopes, params) - assertEquals(scopes, metadata.oauthScopes) - assertEquals(params, metadata.oauthParams) - } - - @Test - fun `empty schemes list is rejected`() { - val ex = assertFailsWith { AuthMetadata(emptyList()) } - assertEquals("schemes must not be empty", ex.message) - } - - @Test - fun `empty schemes list with non-default oauth args is also rejected`() { - // Confirms the require runs before any other init side-effects regardless of - // how many constructor arguments are populated. - assertFailsWith { - AuthMetadata(emptyList(), listOf("scope"), mapOf("x" to "y")) - } - } - - @Test - fun `no-auth marker scheme is permitted`() { - val metadata = AuthMetadata(listOf(AuthScheme.NO_AUTH)) - assertEquals(listOf(AuthScheme.NO_AUTH), metadata.schemes) - } - - @Test - fun `mutating the source schemes list after construction does not affect the instance`() { - // I-3: schemes must be defensively copied in, so a retained caller collection can't - // mutate the instance (or re-break the non-empty invariant) after construction. - val source = mutableListOf(AuthScheme.BASIC, AuthScheme.DIGEST) - val metadata = AuthMetadata(source) - source.clear() - source.add(AuthScheme.OAUTH2) - assertEquals(listOf(AuthScheme.BASIC, AuthScheme.DIGEST), metadata.schemes) - } - - @Test - fun `mutating the source oauth collections after construction does not affect the instance`() { - // I-3: oauthScopes and oauthParams are defensively copied in as well. - val scopes = mutableListOf("read") - val params = mutableMapOf("a" to "1") - val metadata = AuthMetadata(listOf(AuthScheme.OAUTH2), scopes, params) - scopes.add("write") - params["b"] = "2" - assertEquals(listOf("read"), metadata.oauthScopes) - assertEquals(mapOf("a" to "1"), metadata.oauthParams) - } - - @Test - fun `exposed schemes view is unmodifiable`() { - // I-3: the exposed collections are unmodifiable views — mutation via cast throws. - val metadata = AuthMetadata(listOf(AuthScheme.BASIC)) - assertFailsWith { - @Suppress("UNCHECKED_CAST") - (metadata.schemes as MutableList).add(AuthScheme.DIGEST) - } - } - - @Test - fun `exposed oauth views are unmodifiable`() { - val metadata = - AuthMetadata(listOf(AuthScheme.OAUTH2), listOf("read"), mapOf("a" to "1")) - assertFailsWith { - @Suppress("UNCHECKED_CAST") - (metadata.oauthScopes as MutableList).add("write") - } - assertFailsWith { - @Suppress("UNCHECKED_CAST") - (metadata.oauthParams as MutableMap)["b"] = "2" - } - } -} 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..04b4b125 --- /dev/null +++ b/sdk-core/src/test/kotlin/org/dexpace/sdk/core/http/auth/AuthRequirementTest.kt @@ -0,0 +1,124 @@ +/* + * 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 `of accepts oauth scopes and params`() { + val req = + AuthRequirement.of(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.of(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.of(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 `builder setters defensively copy so a later source mutation does not leak`() { + val scopes = mutableListOf("read") + val params = mutableMapOf("a" to "1") + val builder = + AuthRequirement.Builder() + .scheme(AuthScheme.OAUTH2) + .oauthScopes(scopes) + .oauthParams(params) + // Mutating the sources after the setter call, before build(), must not leak through. + scopes.add("write") + params["b"] = "2" + val req = builder.build() + assertEquals(listOf("read"), req.oauthScopes) + assertEquals(mapOf("a" to "1"), req.oauthParams) + } + + @Test + fun `newBuilder round-trips state`() { + val original = AuthRequirement.of(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.of(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.of(AuthScheme.OAUTH2, listOf("read")) + val b = AuthRequirement.of(AuthScheme.OAUTH2, listOf("read")) + val c = AuthRequirement.of(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.of(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()) + } +}