mirror of
https://github.com/steveiliop56/tinyauth.git
synced 2026-04-13 09:18:10 +00:00
Compare commits
2 Commits
scottmcken
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
18c8413ea3 | ||
|
|
1117f35496 |
@@ -19,7 +19,6 @@
|
|||||||
"i18next": "^26.0.3",
|
"i18next": "^26.0.3",
|
||||||
"i18next-browser-languagedetector": "^8.2.1",
|
"i18next-browser-languagedetector": "^8.2.1",
|
||||||
"i18next-resources-to-backend": "^1.2.1",
|
"i18next-resources-to-backend": "^1.2.1",
|
||||||
"input-otp": "^1.4.2",
|
|
||||||
"lucide-react": "^1.7.0",
|
"lucide-react": "^1.7.0",
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
"radix-ui": "^1.4.3",
|
"radix-ui": "^1.4.3",
|
||||||
@@ -623,8 +622,6 @@
|
|||||||
|
|
||||||
"inline-style-parser": ["inline-style-parser@0.2.4", "", {}, "sha512-0aO8FkhNZlj/ZIbNi7Lxxr12obT7cL1moPfE4tg1LkX7LlLfC6DeX4l2ZEud1ukP9jNQyNnfzQVqwbwmAATY4Q=="],
|
"inline-style-parser": ["inline-style-parser@0.2.4", "", {}, "sha512-0aO8FkhNZlj/ZIbNi7Lxxr12obT7cL1moPfE4tg1LkX7LlLfC6DeX4l2ZEud1ukP9jNQyNnfzQVqwbwmAATY4Q=="],
|
||||||
|
|
||||||
"input-otp": ["input-otp@1.4.2", "", { "peerDependencies": { "react": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc" } }, "sha512-l3jWwYNvrEa6NTCt7BECfCm48GvwuZzkoeG3gBL2w4CHeOXW3eKFmf9UNYkNfYc3mxMrthMnxjIE07MT0zLBQA=="],
|
|
||||||
|
|
||||||
"is-alphabetical": ["is-alphabetical@2.0.1", "", {}, "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ=="],
|
"is-alphabetical": ["is-alphabetical@2.0.1", "", {}, "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ=="],
|
||||||
|
|
||||||
"is-alphanumerical": ["is-alphanumerical@2.0.1", "", { "dependencies": { "is-alphabetical": "^2.0.0", "is-decimal": "^2.0.0" } }, "sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw=="],
|
"is-alphanumerical": ["is-alphanumerical@2.0.1", "", { "dependencies": { "is-alphabetical": "^2.0.0", "is-decimal": "^2.0.0" } }, "sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw=="],
|
||||||
|
|||||||
@@ -25,7 +25,6 @@
|
|||||||
"i18next": "^26.0.3",
|
"i18next": "^26.0.3",
|
||||||
"i18next-browser-languagedetector": "^8.2.1",
|
"i18next-browser-languagedetector": "^8.2.1",
|
||||||
"i18next-resources-to-backend": "^1.2.1",
|
"i18next-resources-to-backend": "^1.2.1",
|
||||||
"input-otp": "^1.4.2",
|
|
||||||
"lucide-react": "^1.7.0",
|
"lucide-react": "^1.7.0",
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
"radix-ui": "^1.4.3",
|
"radix-ui": "^1.4.3",
|
||||||
|
|||||||
@@ -1,14 +1,10 @@
|
|||||||
import { Form, FormControl, FormField, FormItem } from "../ui/form";
|
import { Form, FormControl, FormField, FormItem } from "../ui/form";
|
||||||
import {
|
import { Input } from "../ui/input";
|
||||||
InputOTP,
|
|
||||||
InputOTPGroup,
|
|
||||||
InputOTPSeparator,
|
|
||||||
InputOTPSlot,
|
|
||||||
} from "../ui/input-otp";
|
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
import { totpSchema, TotpSchema } from "@/schemas/totp-schema";
|
import { totpSchema, TotpSchema } from "@/schemas/totp-schema";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { useRef } from "react";
|
||||||
import z from "zod";
|
import z from "zod";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -19,6 +15,7 @@ interface Props {
|
|||||||
export const TotpForm = (props: Props) => {
|
export const TotpForm = (props: Props) => {
|
||||||
const { formId, onSubmit } = props;
|
const { formId, onSubmit } = props;
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const autoSubmittedRef = useRef(false);
|
||||||
|
|
||||||
z.config({
|
z.config({
|
||||||
customError: (iss) =>
|
customError: (iss) =>
|
||||||
@@ -29,14 +26,19 @@ export const TotpForm = (props: Props) => {
|
|||||||
resolver: zodResolver(totpSchema),
|
resolver: zodResolver(totpSchema),
|
||||||
});
|
});
|
||||||
|
|
||||||
const handleChange = (value: string) => {
|
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
form.setValue("code", value, { shouldDirty: true, shouldValidate: true });
|
const value = e.target.value.replace(/\D/g, "").slice(0, 6);
|
||||||
|
form.setValue("code", value, { shouldDirty: true, shouldValidate: false });
|
||||||
if (value.length === 6) {
|
if (value.length === 6 && !autoSubmittedRef.current) {
|
||||||
onSubmit({ code: value });
|
autoSubmittedRef.current = true;
|
||||||
|
form.handleSubmit(onSubmit)();
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
autoSubmittedRef.current = false;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Note: This is not the best UX, ideally we would want https://github.com/guilhermerodz/input-otp
|
||||||
|
// but some password managers cannot autofill the inputs (see #92) so, simple input it is
|
||||||
return (
|
return (
|
||||||
<Form {...form}>
|
<Form {...form}>
|
||||||
<form id={formId} onSubmit={form.handleSubmit(onSubmit)}>
|
<form id={formId} onSubmit={form.handleSubmit(onSubmit)}>
|
||||||
@@ -46,25 +48,17 @@ export const TotpForm = (props: Props) => {
|
|||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<InputOTP
|
<Input
|
||||||
maxLength={6}
|
|
||||||
{...field}
|
{...field}
|
||||||
|
type="text"
|
||||||
|
inputMode="numeric"
|
||||||
autoComplete="one-time-code"
|
autoComplete="one-time-code"
|
||||||
autoFocus
|
autoFocus
|
||||||
|
maxLength={6}
|
||||||
|
placeholder="XXXXXX"
|
||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
>
|
className="text-center"
|
||||||
<InputOTPGroup>
|
/>
|
||||||
<InputOTPSlot index={0} />
|
|
||||||
<InputOTPSlot index={1} />
|
|
||||||
<InputOTPSlot index={2} />
|
|
||||||
</InputOTPGroup>
|
|
||||||
<InputOTPSeparator />
|
|
||||||
<InputOTPGroup>
|
|
||||||
<InputOTPSlot index={3} />
|
|
||||||
<InputOTPSlot index={4} />
|
|
||||||
<InputOTPSlot index={5} />
|
|
||||||
</InputOTPGroup>
|
|
||||||
</InputOTP>
|
|
||||||
</FormControl>
|
</FormControl>
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,75 +0,0 @@
|
|||||||
import * as React from "react";
|
|
||||||
import { OTPInput, OTPInputContext } from "input-otp";
|
|
||||||
import { MinusIcon } from "lucide-react";
|
|
||||||
|
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
|
|
||||||
function InputOTP({
|
|
||||||
className,
|
|
||||||
containerClassName,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof OTPInput> & {
|
|
||||||
containerClassName?: string;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<OTPInput
|
|
||||||
data-slot="input-otp"
|
|
||||||
containerClassName={cn(
|
|
||||||
"flex items-center gap-2 has-disabled:opacity-50",
|
|
||||||
containerClassName,
|
|
||||||
)}
|
|
||||||
className={cn("disabled:cursor-not-allowed", className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function InputOTPGroup({ className, ...props }: React.ComponentProps<"div">) {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
data-slot="input-otp-group"
|
|
||||||
className={cn("flex items-center", className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function InputOTPSlot({
|
|
||||||
index,
|
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<"div"> & {
|
|
||||||
index: number;
|
|
||||||
}) {
|
|
||||||
const inputOTPContext = React.useContext(OTPInputContext);
|
|
||||||
const { char, hasFakeCaret, isActive } = inputOTPContext?.slots[index] ?? {};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
data-slot="input-otp-slot"
|
|
||||||
data-active={isActive}
|
|
||||||
className={cn(
|
|
||||||
"data-[active=true]:border-ring data-[active=true]:ring-ring/50 data-[active=true]:aria-invalid:ring-destructive/20 dark:data-[active=true]:aria-invalid:ring-destructive/40 aria-invalid:border-destructive data-[active=true]:aria-invalid:border-destructive dark:bg-input/30 border-input relative flex h-9 w-9 items-center justify-center border-y border-r text-sm shadow-xs transition-all outline-none first:rounded-l-md first:border-l last:rounded-r-md data-[active=true]:z-10 data-[active=true]:ring-[3px]",
|
|
||||||
className,
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
{char}
|
|
||||||
{hasFakeCaret && (
|
|
||||||
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
|
|
||||||
<div className="animate-caret-blink bg-foreground h-4 w-px duration-1000" />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function InputOTPSeparator({ ...props }: React.ComponentProps<"div">) {
|
|
||||||
return (
|
|
||||||
<div data-slot="input-otp-separator" role="separator" {...props}>
|
|
||||||
<MinusIcon />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export { InputOTP, InputOTPGroup, InputOTPSlot, InputOTPSeparator };
|
|
||||||
@@ -11,6 +11,33 @@ export const oidcParamsSchema = z.object({
|
|||||||
code_challenge_method: z.string().optional(),
|
code_challenge_method: z.string().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function b64urlDecode(s: string): string {
|
||||||
|
const base64 = s.replace(/-/g, "+").replace(/_/g, "/");
|
||||||
|
return atob(base64.padEnd(base64.length + ((4 - (base64.length % 4)) % 4), "="));
|
||||||
|
}
|
||||||
|
|
||||||
|
function decodeRequestObject(jwt: string): Record<string, string> {
|
||||||
|
try {
|
||||||
|
// Must have exactly 3 parts: header, payload, signature
|
||||||
|
const parts = jwt.split(".");
|
||||||
|
if (parts.length !== 3) return {};
|
||||||
|
|
||||||
|
// Header must specify "alg": "none" and signature must be empty string
|
||||||
|
const header = JSON.parse(b64urlDecode(parts[0]));
|
||||||
|
if (!header || typeof header !== "object" || header.alg !== "none" || parts[2] !== "") return {};
|
||||||
|
|
||||||
|
const payload = JSON.parse(b64urlDecode(parts[1]));
|
||||||
|
if (!payload || typeof payload !== "object" || Array.isArray(payload)) return {};
|
||||||
|
const result: Record<string, string> = {};
|
||||||
|
for (const [k, v] of Object.entries(payload)) {
|
||||||
|
if (typeof v === "string") result[k] = v;
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
} catch {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export const useOIDCParams = (
|
export const useOIDCParams = (
|
||||||
params: URLSearchParams,
|
params: URLSearchParams,
|
||||||
): {
|
): {
|
||||||
@@ -20,6 +47,15 @@ export const useOIDCParams = (
|
|||||||
compiled: string;
|
compiled: string;
|
||||||
} => {
|
} => {
|
||||||
const obj = Object.fromEntries(params.entries());
|
const obj = Object.fromEntries(params.entries());
|
||||||
|
|
||||||
|
// RFC 9101 / OIDC Core 6.1: if `request` param present, decode JWT payload
|
||||||
|
// and merge claims over top-level params (JWT claims take precedence)
|
||||||
|
const requestJwt = params.get("request");
|
||||||
|
if (requestJwt) {
|
||||||
|
const claims = decodeRequestObject(requestJwt);
|
||||||
|
Object.assign(obj, claims);
|
||||||
|
}
|
||||||
|
|
||||||
const parsed = oidcParamsSchema.safeParse(obj);
|
const parsed = oidcParamsSchema.safeParse(obj);
|
||||||
|
|
||||||
if (parsed.success) {
|
if (parsed.success) {
|
||||||
|
|||||||
@@ -74,7 +74,7 @@ export const TotpPage = () => {
|
|||||||
<CardTitle className="text-xl">{t("totpTitle")}</CardTitle>
|
<CardTitle className="text-xl">{t("totpTitle")}</CardTitle>
|
||||||
<CardDescription>{t("totpSubtitle")}</CardDescription>
|
<CardDescription>{t("totpSubtitle")}</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="flex flex-col items-center">
|
<CardContent>
|
||||||
<TotpForm
|
<TotpForm
|
||||||
formId={formId}
|
formId={formId}
|
||||||
onSubmit={(values) => totpMutation.mutate(values)}
|
onSubmit={(values) => totpMutation.mutate(values)}
|
||||||
|
|||||||
@@ -9,19 +9,21 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type OpenIDConnectConfiguration struct {
|
type OpenIDConnectConfiguration struct {
|
||||||
Issuer string `json:"issuer"`
|
Issuer string `json:"issuer"`
|
||||||
AuthorizationEndpoint string `json:"authorization_endpoint"`
|
AuthorizationEndpoint string `json:"authorization_endpoint"`
|
||||||
TokenEndpoint string `json:"token_endpoint"`
|
TokenEndpoint string `json:"token_endpoint"`
|
||||||
UserinfoEndpoint string `json:"userinfo_endpoint"`
|
UserinfoEndpoint string `json:"userinfo_endpoint"`
|
||||||
JwksUri string `json:"jwks_uri"`
|
JwksUri string `json:"jwks_uri"`
|
||||||
ScopesSupported []string `json:"scopes_supported"`
|
ScopesSupported []string `json:"scopes_supported"`
|
||||||
ResponseTypesSupported []string `json:"response_types_supported"`
|
ResponseTypesSupported []string `json:"response_types_supported"`
|
||||||
GrantTypesSupported []string `json:"grant_types_supported"`
|
GrantTypesSupported []string `json:"grant_types_supported"`
|
||||||
SubjectTypesSupported []string `json:"subject_types_supported"`
|
SubjectTypesSupported []string `json:"subject_types_supported"`
|
||||||
IDTokenSigningAlgValuesSupported []string `json:"id_token_signing_alg_values_supported"`
|
IDTokenSigningAlgValuesSupported []string `json:"id_token_signing_alg_values_supported"`
|
||||||
TokenEndpointAuthMethodsSupported []string `json:"token_endpoint_auth_methods_supported"`
|
TokenEndpointAuthMethodsSupported []string `json:"token_endpoint_auth_methods_supported"`
|
||||||
ClaimsSupported []string `json:"claims_supported"`
|
ClaimsSupported []string `json:"claims_supported"`
|
||||||
ServiceDocumentation string `json:"service_documentation"`
|
ServiceDocumentation string `json:"service_documentation"`
|
||||||
|
RequestParameterSupported bool `json:"request_parameter_supported"`
|
||||||
|
RequestObjectSigningAlgValuesSupported []string `json:"request_object_signing_alg_values_supported"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type WellKnownControllerConfig struct{}
|
type WellKnownControllerConfig struct{}
|
||||||
@@ -48,19 +50,21 @@ func (controller *WellKnownController) SetupRoutes() {
|
|||||||
func (controller *WellKnownController) OpenIDConnectConfiguration(c *gin.Context) {
|
func (controller *WellKnownController) OpenIDConnectConfiguration(c *gin.Context) {
|
||||||
issuer := controller.oidc.GetIssuer()
|
issuer := controller.oidc.GetIssuer()
|
||||||
c.JSON(200, OpenIDConnectConfiguration{
|
c.JSON(200, OpenIDConnectConfiguration{
|
||||||
Issuer: issuer,
|
Issuer: issuer,
|
||||||
AuthorizationEndpoint: fmt.Sprintf("%s/authorize", issuer),
|
AuthorizationEndpoint: fmt.Sprintf("%s/authorize", issuer),
|
||||||
TokenEndpoint: fmt.Sprintf("%s/api/oidc/token", issuer),
|
TokenEndpoint: fmt.Sprintf("%s/api/oidc/token", issuer),
|
||||||
UserinfoEndpoint: fmt.Sprintf("%s/api/oidc/userinfo", issuer),
|
UserinfoEndpoint: fmt.Sprintf("%s/api/oidc/userinfo", issuer),
|
||||||
JwksUri: fmt.Sprintf("%s/.well-known/jwks.json", issuer),
|
JwksUri: fmt.Sprintf("%s/.well-known/jwks.json", issuer),
|
||||||
ScopesSupported: service.SupportedScopes,
|
ScopesSupported: service.SupportedScopes,
|
||||||
ResponseTypesSupported: service.SupportedResponseTypes,
|
ResponseTypesSupported: service.SupportedResponseTypes,
|
||||||
GrantTypesSupported: service.SupportedGrantTypes,
|
GrantTypesSupported: service.SupportedGrantTypes,
|
||||||
SubjectTypesSupported: []string{"pairwise"},
|
SubjectTypesSupported: []string{"pairwise"},
|
||||||
IDTokenSigningAlgValuesSupported: []string{"RS256"},
|
IDTokenSigningAlgValuesSupported: []string{"RS256"},
|
||||||
TokenEndpointAuthMethodsSupported: []string{"client_secret_basic", "client_secret_post"},
|
TokenEndpointAuthMethodsSupported: []string{"client_secret_basic", "client_secret_post"},
|
||||||
ClaimsSupported: []string{"sub", "updated_at", "name", "preferred_username", "email", "email_verified", "groups"},
|
ClaimsSupported: []string{"sub", "updated_at", "name", "preferred_username", "email", "email_verified", "groups"},
|
||||||
ServiceDocumentation: "https://tinyauth.app/docs/guides/oidc",
|
ServiceDocumentation: "https://tinyauth.app/docs/guides/oidc",
|
||||||
|
RequestParameterSupported: true,
|
||||||
|
RequestObjectSigningAlgValuesSupported: []string{"none"},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -56,19 +56,21 @@ func TestWellKnownController(t *testing.T) {
|
|||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
|
|
||||||
expected := controller.OpenIDConnectConfiguration{
|
expected := controller.OpenIDConnectConfiguration{
|
||||||
Issuer: oidcServiceCfg.Issuer,
|
Issuer: oidcServiceCfg.Issuer,
|
||||||
AuthorizationEndpoint: fmt.Sprintf("%s/authorize", oidcServiceCfg.Issuer),
|
AuthorizationEndpoint: fmt.Sprintf("%s/authorize", oidcServiceCfg.Issuer),
|
||||||
TokenEndpoint: fmt.Sprintf("%s/api/oidc/token", oidcServiceCfg.Issuer),
|
TokenEndpoint: fmt.Sprintf("%s/api/oidc/token", oidcServiceCfg.Issuer),
|
||||||
UserinfoEndpoint: fmt.Sprintf("%s/api/oidc/userinfo", oidcServiceCfg.Issuer),
|
UserinfoEndpoint: fmt.Sprintf("%s/api/oidc/userinfo", oidcServiceCfg.Issuer),
|
||||||
JwksUri: fmt.Sprintf("%s/.well-known/jwks.json", oidcServiceCfg.Issuer),
|
JwksUri: fmt.Sprintf("%s/.well-known/jwks.json", oidcServiceCfg.Issuer),
|
||||||
ScopesSupported: service.SupportedScopes,
|
ScopesSupported: service.SupportedScopes,
|
||||||
ResponseTypesSupported: service.SupportedResponseTypes,
|
ResponseTypesSupported: service.SupportedResponseTypes,
|
||||||
GrantTypesSupported: service.SupportedGrantTypes,
|
GrantTypesSupported: service.SupportedGrantTypes,
|
||||||
SubjectTypesSupported: []string{"pairwise"},
|
SubjectTypesSupported: []string{"pairwise"},
|
||||||
IDTokenSigningAlgValuesSupported: []string{"RS256"},
|
IDTokenSigningAlgValuesSupported: []string{"RS256"},
|
||||||
TokenEndpointAuthMethodsSupported: []string{"client_secret_basic", "client_secret_post"},
|
TokenEndpointAuthMethodsSupported: []string{"client_secret_basic", "client_secret_post"},
|
||||||
ClaimsSupported: []string{"sub", "updated_at", "name", "preferred_username", "email", "email_verified", "groups"},
|
ClaimsSupported: []string{"sub", "updated_at", "name", "preferred_username", "email", "email_verified", "groups"},
|
||||||
ServiceDocumentation: "https://tinyauth.app/docs/guides/oidc",
|
ServiceDocumentation: "https://tinyauth.app/docs/guides/oidc",
|
||||||
|
RequestParameterSupported: true,
|
||||||
|
RequestObjectSigningAlgValuesSupported: []string{"none"},
|
||||||
}
|
}
|
||||||
|
|
||||||
assert.Equal(t, expected, res)
|
assert.Equal(t, expected, res)
|
||||||
|
|||||||
Reference in New Issue
Block a user