mirror of
https://github.com/authelia/authelia.git
synced 2026-10-28 09:34:52 +08:00
test(suites): oidc conformance (#13097)
This adds the official OpenID Connect 1.0 Conformance Suites used by the OpenID Foundation to assert conformance for certification to our test suites. This allows us to offer the quality guarantee that every single commit of Authelia conforms to the OpenID Connect 1.0 and related specifications. The tests allow the following results to indicate success automatically: `PASSED`, `REVIEW` (result that manual review must occur), `SKIPPED` (result that indicates the conformance test is not needed due to reasonable criteria). In addition only the `FINISHED` status is accepted. If the result is `WARNING` which is just an informative result which does not indicate lack of conformance it can be manually allowed on a per module basis with an explanation. This allows both detecting new `WARNING` results and necessitates documenting them for future improvements. All of the result and status checks align entirely with the standard conformance certification process, ensuring that each commit would be certifiable. The storage for the suites is able to be configured for persistence, and every plan created carries the commit sha or tag; allowing for historical evidence of conformance.
This commit is contained in:
1 parent
7591cd8077
commit
847afd67db
75 files changed
+6965
-369
No files matched your search
@@ -15,15 +15,33 @@ declare -A SUITE_AGENTS=(
|
||||
|
||||
declare -A SUITE_TIMEOUTS=(
|
||||
[Kubernetes]="30"
|
||||
[OIDCConformance]="120"
|
||||
)
|
||||
|
||||
declare -A SUITE_NO_FAILFAST=(
|
||||
[OIDCConformance]="true"
|
||||
)
|
||||
|
||||
DEBUG_REGEX='\[(debug test|test debug)\]'
|
||||
SUITE_DEBUG="false"
|
||||
|
||||
if [[ "${BUILDKITE_MESSAGE:-}" =~ ${DEBUG_REGEX} ]]; then
|
||||
SUITE_DEBUG="true"
|
||||
fi
|
||||
|
||||
for SUITE_NAME in $(authelia-scripts suites list); do
|
||||
AGENT="${SUITE_AGENTS[${SUITE_NAME}]:-all}"
|
||||
TIMEOUT="${SUITE_TIMEOUTS[${SUITE_NAME}]:-20}"
|
||||
FAILFAST="--failfast"
|
||||
|
||||
if [[ "${SUITE_NO_FAILFAST[${SUITE_NAME}]:-false}" == "true" ]]; then
|
||||
FAILFAST=""
|
||||
fi
|
||||
cat << EOF
|
||||
- label: ":selenium: ${SUITE_NAME} Suite"
|
||||
command: "authelia-scripts --log-level debug suites test ${SUITE_NAME} --failfast --headless"
|
||||
command: "authelia-scripts --log-level debug suites test ${SUITE_NAME} ${FAILFAST} --headless"
|
||||
artifact_paths:
|
||||
- "oidc-conformance-plans/*.zip"
|
||||
- "screenshots/**/*.access.log"
|
||||
- "screenshots/**/*.console.json"
|
||||
- "screenshots/**/*.containers.log"
|
||||
@@ -43,5 +61,6 @@ cat << EOF
|
||||
suite: "${AGENT}"
|
||||
env:
|
||||
SUITE: "${SUITE_NAME}"
|
||||
SUITE_DEBUG: "${SUITE_DEBUG}"
|
||||
EOF
|
||||
done
|
||||
@@ -26,6 +26,7 @@ coverage.txt
|
||||
test-results-*.json
|
||||
test-results-*.xml
|
||||
screenshots/
|
||||
oidc-conformance-plans/
|
||||
|
||||
# Generated by TypeScript compiler
|
||||
dist/
|
||||
|
||||
@@ -18,6 +18,7 @@ import (
|
||||
"go.yaml.in/yaml/v4"
|
||||
|
||||
"github.com/authelia/authelia/v4/internal/configuration/schema"
|
||||
"github.com/authelia/authelia/v4/internal/oidc/conformance"
|
||||
"github.com/authelia/authelia/v4/internal/utils"
|
||||
)
|
||||
|
||||
@@ -188,19 +189,11 @@ func miscOIDCConformance(version, token, consent, policy, brand string, authelia
|
||||
return nil
|
||||
}
|
||||
|
||||
func miscOIDCConformanceBuildSuites(version, consent, policy, brand string, suiteURL, autheliaURL *url.URL, suiteNames ...string) (suites []OpenIDConnectConformanceSuite) {
|
||||
builders := []*OpenIDConnectConformanceSuiteBuilder{
|
||||
{brand, "config", "Config", true, version, consent, policy, nil, autheliaURL},
|
||||
{brand, "basic", "Basic", true, version, consent, policy, suiteURL, autheliaURL},
|
||||
{brand, suiteNameBasicFormPost, "Basic (Form Post)", true, version, consent, policy, suiteURL, autheliaURL},
|
||||
{brand, "hybrid", "Hybrid", true, version, consent, policy, suiteURL, autheliaURL},
|
||||
{brand, suiteNameHybridFormPost, "Hybrid (Form Post)", true, version, consent, policy, suiteURL, autheliaURL},
|
||||
{brand, "implicit", "Implicit", true, version, consent, policy, suiteURL, autheliaURL},
|
||||
{brand, suiteNameImplicitFormPost, "Implicit (Form Post)", true, version, consent, policy, suiteURL, autheliaURL},
|
||||
}
|
||||
func miscOIDCConformanceBuildSuites(version, consent, policy, brand string, suiteURL, autheliaURL *url.URL, suiteNames ...string) (suites []conformance.Suite) {
|
||||
builders := conformance.Builders(version, consent, policy, brand, suiteURL, autheliaURL)
|
||||
|
||||
for _, builder := range builders {
|
||||
if len(suiteNames) != 0 && !utils.IsStringInSlice(builder.name, suiteNames) {
|
||||
if len(suiteNames) != 0 && !utils.IsStringInSlice(builder.Name, suiteNames) {
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -210,7 +203,7 @@ func miscOIDCConformanceBuildSuites(version, consent, policy, brand string, suit
|
||||
return suites
|
||||
}
|
||||
|
||||
func doOIDCConformanceSuitePostPlan(client *http.Client, base *url.URL, plan string, variant *OpenIDConnectConformanceSuitePlanVariant, body *bytes.Buffer) (err error) {
|
||||
func doOIDCConformanceSuitePostPlan(client *http.Client, base *url.URL, plan string, variant *conformance.PlanVariant, body *bytes.Buffer) (err error) {
|
||||
if client == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -51,18 +51,6 @@ const (
|
||||
fileGitHubIssueTemplateBR = ".github/ISSUE_TEMPLATE/bug-report.yml"
|
||||
)
|
||||
|
||||
const (
|
||||
suiteNameBasicFormPost = "basic-form-post"
|
||||
suiteNameHybridFormPost = "hybrid-form-post"
|
||||
suiteNameImplicitFormPost = "implicit-form-post"
|
||||
suiteConformanceBasic = "conformance-basic"
|
||||
suiteConformanceBasicFormPost = "conformance-basic-form-post"
|
||||
suiteConformanceImplicit = "conformance-implicit"
|
||||
suiteConformanceImplicitFormPost = "conformance-implicit-form-post"
|
||||
suiteConformanceHybrid = "conformance-hybrid"
|
||||
suiteConformanceHybridFormPost = "conformance-hybrid-form-post"
|
||||
)
|
||||
|
||||
const (
|
||||
pathJSONSchema = "json-schema"
|
||||
extJSON = ".json"
|
||||
|
||||
@@ -8,8 +8,6 @@ import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/authelia/authelia/v4/internal/configuration/schema"
|
||||
)
|
||||
|
||||
type tmplIssueTemplateData struct {
|
||||
@@ -230,90 +228,6 @@ type PackageJSON struct {
|
||||
Version string `json:"version"`
|
||||
}
|
||||
|
||||
// OpenIDConnectConformanceSuite represents a single OpenID Connect conformance suite.
|
||||
type OpenIDConnectConformanceSuite struct {
|
||||
Name string
|
||||
Plan OpenIDConnectConformanceSuitePlan
|
||||
Clients []schema.IdentityProvidersOpenIDConnectClient
|
||||
}
|
||||
|
||||
// OpenIDConnectConformanceSuitePlan represents the test plan for an OpenIDConnectConformanceSuite.
|
||||
type OpenIDConnectConformanceSuitePlan struct {
|
||||
Name string `json:"-"`
|
||||
Alias string `json:"alias"`
|
||||
Description string `json:"description"`
|
||||
Publish string `json:"publish"`
|
||||
|
||||
Variant *OpenIDConnectConformanceSuitePlanVariant `json:"-"`
|
||||
Server OpenIDConnectConformanceSuitePlanServer `json:"server"`
|
||||
Client *OpenIDConnectConformanceSuitePlanClient `json:"client,omitempty"`
|
||||
ClientAlternate *OpenIDConnectConformanceSuitePlanClient `json:"client2,omitempty"`
|
||||
ClientSecretPost *OpenIDConnectConformanceSuitePlanClient `json:"client_secret_post,omitempty"`
|
||||
MutualTLS *OpenIDConnectConformanceSuitePlanMutualTLS `json:"mtls,omitempty"`
|
||||
Resource *OpenIDConnectConformanceSuitePlanResource `json:"resource,omitempty"`
|
||||
|
||||
EKYCVerifiedClaimsRequest string `json:"ekyc_verified_claims_request,omitempty"`
|
||||
EKYCUserinfo string `json:"ekyc_userinfo,omitempty"`
|
||||
}
|
||||
|
||||
// OpenIDConnectConformanceSuitePlanVariant represents the variant of an OpenIDConnectConformanceSuitePlan.
|
||||
type OpenIDConnectConformanceSuitePlanVariant struct {
|
||||
ServerMetadata string `json:"server_metadata,omitempty"`
|
||||
ClientRegistration string `json:"client_registration,omitempty"`
|
||||
ClientAuthType string `json:"client_auth_type,omitempty"`
|
||||
ResponseType string `json:"response_type,omitempty"`
|
||||
ResponseMode string `json:"response_mode,omitempty"`
|
||||
CIBAMode string `json:"ciba_mode,omitempty"`
|
||||
FAPIProfile string `json:"fapi_profile,omitempty"`
|
||||
FAPIResponseMode string `json:"fapi_response_mode,omitempty"`
|
||||
FAPIAuthRequestMethod string `json:"fapi_auth_request_method,omitempty"`
|
||||
SenderConstrain string `json:"sender_constrain,omitempty"`
|
||||
AuthorizationRequestType string `json:"authorization_request_type,omitempty"`
|
||||
OpenID string `json:"openid,omitempty"`
|
||||
}
|
||||
|
||||
// OpenIDConnectConformanceSuitePlanServer represents the server details of an OpenIDConnectConformanceSuitePlan.
|
||||
type OpenIDConnectConformanceSuitePlanServer struct {
|
||||
DiscoveryURL string `json:"discoveryUrl,omitempty"`
|
||||
Issuer string `json:"issuer,omitempty"`
|
||||
JWKSURI string `json:"jwks_uri,omitempty"`
|
||||
AuthorizationEndpoint string `json:"authorization_endpoint,omitempty"`
|
||||
TokenEndpoint string `json:"token_endpoint,omitempty"`
|
||||
UserinfoEndpoint string `json:"userinfo_endpoint,omitempty"`
|
||||
ACRValues string `json:"acr_values,omitempty"`
|
||||
LoginHint string `json:"login_hint,omitempty"`
|
||||
}
|
||||
|
||||
// OpenIDConnectConformanceSuitePlanClient represents a client registered with an OpenIDConnectConformanceSuitePlan.
|
||||
type OpenIDConnectConformanceSuitePlanClient struct {
|
||||
ID string `json:"client_id,omitempty"`
|
||||
Secret string `json:"client_secret,omitempty"`
|
||||
Name string `json:"client_name,omitempty"`
|
||||
Scope string `json:"scope,omitempty"`
|
||||
SecretJWTAlgorithm string `json:"client_secret_jwt_alg,omitempty"`
|
||||
DPOPSigningAlgorithm string `json:"dpop_signing_alg,omitempty"`
|
||||
InitialAccessToken string `json:"initial_access_token,omitempty"`
|
||||
HintType string `json:"hint_type,omitempty"`
|
||||
HintValue string `json:"hint_value,omitempty"`
|
||||
JWKS string `json:"jwks,omitempty"`
|
||||
}
|
||||
|
||||
// OpenIDConnectConformanceSuitePlanMutualTLS represents the Mutual TLS details of an OpenIDConnectConformanceSuitePlan.
|
||||
type OpenIDConnectConformanceSuitePlanMutualTLS struct {
|
||||
Certificate string `json:"certificate,omitempty"`
|
||||
Key string `json:"key,omitempty"`
|
||||
CertificateAuthority string `json:"ca,omitempty"`
|
||||
}
|
||||
|
||||
// OpenIDConnectConformanceSuitePlanResource represents the protected resource details of an OpenIDConnectConformanceSuitePlan.
|
||||
type OpenIDConnectConformanceSuitePlanResource struct {
|
||||
ResourceURL string `json:"resourceUrl"`
|
||||
ResourceURLAccountRequests string `json:"resourceUrlAccountRequests,omitempty"`
|
||||
ResourceURLAccountsResource string `json:"resourceUrlAccountsResource,omitempty"`
|
||||
InstitutionID string `json:"institution_id,omitempty"`
|
||||
RichAuthorizationRequest string `json:"richAuthorizationRequest,omitempty"`
|
||||
}
|
||||
|
||||
// AllContributors represents the .all-contributorsrc configuration file.
|
||||
type AllContributors struct {
|
||||
Contributors []Contributor `json:"contributors"`
|
||||
|
||||
@@ -8,16 +8,28 @@ import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type testEvent struct {
|
||||
Action string `json:"Action"`
|
||||
Test string `json:"Test"`
|
||||
Output string `json:"Output"`
|
||||
}
|
||||
|
||||
type testOutputWriter struct {
|
||||
out io.Writer
|
||||
buf bytes.Buffer
|
||||
out io.Writer
|
||||
buf bytes.Buffer
|
||||
grouped bool
|
||||
pending []testFraming
|
||||
buildkite bool
|
||||
deferring bool
|
||||
deferred bytes.Buffer
|
||||
}
|
||||
|
||||
type testFraming struct {
|
||||
test string
|
||||
line string
|
||||
}
|
||||
|
||||
func (w *testOutputWriter) Write(p []byte) (int, error) {
|
||||
@@ -34,17 +46,136 @@ func (w *testOutputWriter) Write(p []byte) (int, error) {
|
||||
var event testEvent
|
||||
|
||||
if json.Unmarshal(line, &event) != nil {
|
||||
if _, err = w.out.Write(line); err != nil {
|
||||
if err = w.write(string(line)); err != nil {
|
||||
return len(p), err
|
||||
}
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
if event.Action == "output" {
|
||||
if _, err = io.WriteString(w.out, event.Output); err != nil {
|
||||
return len(p), err
|
||||
}
|
||||
if err = w.handle(event); err != nil {
|
||||
return len(p), err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (w *testOutputWriter) handle(event testEvent) (err error) {
|
||||
if event.Action != "output" {
|
||||
return nil
|
||||
}
|
||||
|
||||
if !w.grouped {
|
||||
return w.write(event.Output)
|
||||
}
|
||||
|
||||
switch output := event.Output; {
|
||||
case isTestFramingLine(output):
|
||||
w.pending = append(w.pending, testFraming{test: event.Test, line: output})
|
||||
|
||||
return nil
|
||||
case isBuildkiteMarkerLine(output) && !isTestSummaryLine(output):
|
||||
if err = w.release(func(framing testFraming) bool { return !isTestOrParent(framing.test, event.Test) }); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err = w.write(output); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return w.release(func(testFraming) bool { return true })
|
||||
default:
|
||||
if err = w.release(func(testFraming) bool { return true }); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return w.write(output)
|
||||
}
|
||||
}
|
||||
|
||||
func (w *testOutputWriter) write(output string) (err error) {
|
||||
if w.buildkite && !w.deferring && isTopLevelTestSummaryLine(output) {
|
||||
w.deferring = true
|
||||
}
|
||||
|
||||
if w.deferring {
|
||||
w.deferred.WriteString(output)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
_, err = io.WriteString(w.out, output)
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
func (w *testOutputWriter) Flush() (err error) {
|
||||
if err = w.release(func(testFraming) bool { return true }); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = w.out.Write(w.deferred.Bytes())
|
||||
|
||||
w.deferred.Reset()
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
func (w *testOutputWriter) release(match func(framing testFraming) bool) (err error) {
|
||||
kept := w.pending[:0]
|
||||
|
||||
for _, framing := range w.pending {
|
||||
if !match(framing) {
|
||||
kept = append(kept, framing)
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
if err = w.write(framing.line); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
w.pending = kept
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func isTestOrParent(test, child string) bool {
|
||||
return test == child || strings.HasPrefix(child, test+"/")
|
||||
}
|
||||
|
||||
func isTestFramingLine(output string) bool {
|
||||
for _, prefix := range []string{"=== RUN", "=== PAUSE", "=== CONT", "=== NAME"} {
|
||||
if strings.HasPrefix(output, prefix) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func isTestSummaryLine(output string) bool {
|
||||
trimmed := strings.TrimLeft(output, " ")
|
||||
|
||||
for _, prefix := range []string{"--- PASS: ", "--- FAIL: ", "--- SKIP: "} {
|
||||
if strings.HasPrefix(trimmed, prefix) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func isTopLevelTestSummaryLine(output string) bool {
|
||||
return isTestSummaryLine(output) && !strings.HasPrefix(output, " ")
|
||||
}
|
||||
|
||||
func isBuildkiteMarkerLine(output string) bool {
|
||||
for _, prefix := range []string{"--- ", "+++ ", "~~~ ", "^^^ +++"} {
|
||||
if strings.HasPrefix(output, prefix) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
@@ -6,9 +6,12 @@ package cmd
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestTestOutputWriterShouldReconstructConsoleOutput(t *testing.T) {
|
||||
@@ -63,3 +66,188 @@ func TestTestOutputWriterShouldReconstructConsoleOutput(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestTestOutputWriterShouldPlaceFramingInsideBuildkiteGroups(t *testing.T) {
|
||||
event := func(test, output string) string {
|
||||
return fmt.Sprintf(`{"Action":"output","Test":%q,"Output":%q}`, test, output) + "\n"
|
||||
}
|
||||
|
||||
testCases := []struct {
|
||||
name string
|
||||
events []string
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
"ShouldPlaceTheFramingOfTheTestWhichStartedAGroupInsideIt",
|
||||
[]string{
|
||||
event("TestSuite", "=== RUN TestSuite\n"),
|
||||
event("TestSuite/TestBasic", "=== RUN TestSuite/TestBasic\n"),
|
||||
event("TestSuite/TestBasic", "--- OIDC Conformance Plan: basic\n"),
|
||||
},
|
||||
"--- OIDC Conformance Plan: basic\n=== RUN TestSuite\n=== RUN TestSuite/TestBasic\n",
|
||||
},
|
||||
{
|
||||
"ShouldLeaveTheFramingOfTheLastGroupBeforeTheNextOne",
|
||||
[]string{
|
||||
event("TestSuite/TestBasic/Codereuse", "=== RUN TestSuite/TestBasic/Codereuse\n"),
|
||||
event("TestSuite/TestBasicFormPost", "=== RUN TestSuite/TestBasicFormPost\n"),
|
||||
event("TestSuite/TestBasicFormPost", "--- OIDC Conformance Plan: basic-form-post\n"),
|
||||
},
|
||||
"=== RUN TestSuite/TestBasic/Codereuse\n--- OIDC Conformance Plan: basic-form-post\n=== RUN TestSuite/TestBasicFormPost\n",
|
||||
},
|
||||
{
|
||||
"ShouldPrintTheFramingOfEveryTestBeforeTheNextOutput",
|
||||
[]string{
|
||||
event("TestSuite/TestBasic/Server", "=== RUN TestSuite/TestBasic/Server\n"),
|
||||
event("TestSuite/TestBasic/MaxAge1", "=== RUN TestSuite/TestBasic/MaxAge1\n"),
|
||||
event("TestSuite/TestBasic/MaxAge1", " suite_test.go:1: Module finished with the result 'FAILED'\n"),
|
||||
event("TestSuite/TestBasic/MaxAge1", "^^^ +++\n"),
|
||||
},
|
||||
"=== RUN TestSuite/TestBasic/Server\n=== RUN TestSuite/TestBasic/MaxAge1\n suite_test.go:1: Module finished with the result 'FAILED'\n^^^ +++\n",
|
||||
},
|
||||
{
|
||||
"ShouldPrintHeldFramingBeforeTheSummary",
|
||||
[]string{
|
||||
event("TestSuite/TestBasic/Server", "=== RUN TestSuite/TestBasic/Server\n"),
|
||||
event("TestSuite/TestBasic/Server", "--- PASS: TestSuite/TestBasic/Server (0.00s)\n"),
|
||||
},
|
||||
"=== RUN TestSuite/TestBasic/Server\n--- PASS: TestSuite/TestBasic/Server (0.00s)\n",
|
||||
},
|
||||
{
|
||||
"ShouldPrintOutputWhichBelongsToNoTest",
|
||||
[]string{
|
||||
event("TestSuite", "=== RUN TestSuite\n"),
|
||||
event("", "PASS\n"),
|
||||
},
|
||||
"=== RUN TestSuite\nPASS\n",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
out := &bytes.Buffer{}
|
||||
writer := &testOutputWriter{out: out, grouped: true}
|
||||
|
||||
for _, write := range tc.events {
|
||||
_, err := writer.Write([]byte(write))
|
||||
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
|
||||
assert.Equal(t, tc.expected, out.String())
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("ShouldPrintEverythingInPlaceWhenNotGrouped", func(t *testing.T) {
|
||||
out := &bytes.Buffer{}
|
||||
writer := &testOutputWriter{out: out}
|
||||
|
||||
for _, write := range []string{
|
||||
event("TestSuite/TestBasic", "=== RUN TestSuite/TestBasic\n"),
|
||||
event("TestSuite/TestBasic", "--- OIDC Conformance Plan: basic\n"),
|
||||
} {
|
||||
_, err := writer.Write([]byte(write))
|
||||
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
|
||||
assert.Equal(t, "=== RUN TestSuite/TestBasic\n--- OIDC Conformance Plan: basic\n", out.String())
|
||||
})
|
||||
}
|
||||
|
||||
func TestTestOutputWriterShouldDeferTheSummaryInBuildkite(t *testing.T) {
|
||||
events := `{"Action":"output","Test":"TestSuite/TestBasic","Output":" suite_test.go:1: Exported the plan log\n"}` + "\n" +
|
||||
`{"Action":"output","Test":"TestSuite","Output":"--- PASS: TestSuite (227.08s)\n"}` + "\n" +
|
||||
`{"Action":"output","Test":"TestSuite/TestBasic","Output":" --- PASS: TestSuite/TestBasic (116.14s)\n"}` + "\n" +
|
||||
`{"Action":"output","Output":"PASS\n"}` + "\n"
|
||||
|
||||
summary := "--- PASS: TestSuite (227.08s)\n --- PASS: TestSuite/TestBasic (116.14s)\nPASS\n"
|
||||
|
||||
testCases := []struct {
|
||||
name string
|
||||
buildkite bool
|
||||
grouped bool
|
||||
before string
|
||||
}{
|
||||
{"ShouldDeferTheSummaryInBuildkite", true, false, " suite_test.go:1: Exported the plan log\n"},
|
||||
{"ShouldDeferTheSummaryInBuildkiteWhenGrouped", true, true, " suite_test.go:1: Exported the plan log\n"},
|
||||
{"ShouldPrintTheSummaryInPlaceElsewhere", false, false, " suite_test.go:1: Exported the plan log\n" + summary},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
out := &bytes.Buffer{}
|
||||
writer := &testOutputWriter{out: out, buildkite: tc.buildkite, grouped: tc.grouped}
|
||||
|
||||
_, err := writer.Write([]byte(events))
|
||||
assert.NoError(t, err)
|
||||
|
||||
assert.Equal(t, tc.before, out.String())
|
||||
|
||||
out.WriteString("teardown\n")
|
||||
|
||||
assert.NoError(t, writer.Flush())
|
||||
|
||||
if tc.buildkite {
|
||||
assert.Equal(t, tc.before+"teardown\n"+summary, out.String())
|
||||
} else {
|
||||
assert.Equal(t, tc.before+"teardown\n", out.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestTestOutputWriterShouldReturnWriteErrors(t *testing.T) {
|
||||
event := func(test, output string) string {
|
||||
return fmt.Sprintf(`{"Action":"output","Test":%q,"Output":%q}`, test, output) + "\n"
|
||||
}
|
||||
|
||||
testCases := []struct {
|
||||
name string
|
||||
events []string
|
||||
}{
|
||||
{"ShouldReturnTheErrorOfAPlainLine", []string{"not an event\n"}},
|
||||
{"ShouldReturnTheErrorOfHeldFramingBeforeAGroup", []string{
|
||||
event("TestSuite/TestBasic/Codereuse", "=== RUN TestSuite/TestBasic/Codereuse\n"),
|
||||
event("TestSuite/TestBasicFormPost", "--- OIDC Conformance Plan: basic-form-post\n"),
|
||||
}},
|
||||
{"ShouldReturnTheErrorOfAGroupHeader", []string{
|
||||
event("TestSuite/TestBasic", "--- OIDC Conformance Plan: basic\n"),
|
||||
}},
|
||||
{"ShouldReturnTheErrorOfHeldFramingBeforeOutput", []string{
|
||||
event("TestSuite/TestBasic/Server", "=== RUN TestSuite/TestBasic/Server\n"),
|
||||
event("TestSuite/TestBasic/Server", " suite_test.go:1: output\n"),
|
||||
}},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
writer := &testOutputWriter{out: testFailingWriter{}, grouped: true}
|
||||
|
||||
var err error
|
||||
|
||||
for _, write := range tc.events {
|
||||
if _, err = writer.Write([]byte(write)); err != nil {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
assert.EqualError(t, err, "write failed")
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("ShouldReturnTheErrorOfHeldFramingWhenFlushing", func(t *testing.T) {
|
||||
writer := &testOutputWriter{out: testFailingWriter{}, grouped: true}
|
||||
|
||||
_, err := writer.Write([]byte(event("TestSuite/TestBasic/Server", "=== RUN TestSuite/TestBasic/Server\n")))
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.EqualError(t, writer.Flush(), "write failed")
|
||||
})
|
||||
}
|
||||
|
||||
type testFailingWriter struct{}
|
||||
|
||||
func (testFailingWriter) Write(_ []byte) (int, error) {
|
||||
return 0, errors.New("write failed")
|
||||
}
|
||||
@@ -351,8 +351,16 @@ func runSuiteTests(suiteName string, withEnv bool) error {
|
||||
|
||||
defer results.Close()
|
||||
|
||||
output := &testOutputWriter{out: os.Stdout, buildkite: os.Getenv("BUILDKITE") == "true", grouped: os.Getenv("BUILDKITE") == "true" && os.Getenv("SUITE_DEBUG") == "true"}
|
||||
|
||||
defer func() {
|
||||
if err := output.Flush(); err != nil {
|
||||
log.Errorf("Error writing the test summary: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
cmd := utils.CommandWithStdout("bash", "-c", testCmdLine)
|
||||
cmd.Stdout = io.MultiWriter(&testOutputWriter{out: os.Stdout}, results)
|
||||
cmd.Stdout = io.MultiWriter(output, results)
|
||||
cmd.Stderr = os.Stderr
|
||||
cmd.Env = os.Environ()
|
||||
|
||||
|
||||
@@ -95,16 +95,17 @@ The suites run several concurrent copies of themselves when the Docker daemon is
|
||||
optional and falls back to the value used for a single local run, so leaving them all unset gives the behavior
|
||||
described above.
|
||||
|
||||
| Variable | Default | Purpose |
|
||||
| :--------------------- | :-------------- | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `SUITE_SLOT` | _unset_ | Slot number owned by this agent or working tree. When set, `bootstrap.sh` derives `COMPOSE_PROJECT_NAME`, `SUITE_SUBNET`, `LDAP_ADMIN_PORT`, `ENVOY_ADMIN_PORT` and, outside CI, `SUITE_TMP` and `SUITE_TMP_PATH` from it. A slotted shell also leaves `/etc/hosts` alone. |
|
||||
| `SUITE_SLOT_AUTO` | _unset_ | Set to `false` to stop `bootstrap.sh` allocating a slot for this working tree. Has no effect in CI, where the slot is supplied by the agent. |
|
||||
| `COMPOSE_PROJECT_NAME` | `authelia` | Compose project name. Also scopes the Traefik Docker provider so it only discovers its own containers. |
|
||||
| `SUITE_SUBNET` | `192.168.240` | First three octets of the suite network. |
|
||||
| `SUITE_TMP` | `/tmp` | Host directory bound into the suite containers, which always see it at `/tmp`. Give each agent or working tree its own directory, because everything at its top level apart from the agent's own working files is removed when a job finishes. |
|
||||
| `SUITE_TMP_PATH` | `/tmp` | Path the test process itself reads and writes that same content through. In CI this stays `/tmp` because `SUITE_TMP` is bound there inside the agent; locally it is set to `SUITE_TMP`, since the test process runs on the host. |
|
||||
| `SUITE_IMAGE` | `authelia:dist` | Image the backend runs. |
|
||||
| `AGENT_CONTAINER` | _unset_ | Name of the container the tests run in. When set, that container is attached to the suite network on setup and detached on teardown, so Chrome can reach the portal. |
|
||||
| Variable | Default | Purpose |
|
||||
| :------------------------------------ | :-------------- | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `SUITE_SLOT` | _unset_ | Slot number owned by this agent or working tree. When set, `bootstrap.sh` derives `COMPOSE_PROJECT_NAME`, `SUITE_SUBNET`, `LDAP_ADMIN_PORT`, `ENVOY_ADMIN_PORT` and, outside CI, `SUITE_TMP` and `SUITE_TMP_PATH` from it. A slotted shell also leaves `/etc/hosts` alone. |
|
||||
| `SUITE_SLOT_AUTO` | _unset_ | Set to `false` to stop `bootstrap.sh` allocating a slot for this working tree. Has no effect in CI, where the slot is supplied by the agent. |
|
||||
| `COMPOSE_PROJECT_NAME` | `authelia` | Compose project name. Also scopes the Traefik Docker provider so it only discovers its own containers. |
|
||||
| `SUITE_SUBNET` | `192.168.240` | First three octets of the suite network. |
|
||||
| `SUITE_TMP` | `/tmp` | Host directory bound into the suite containers, which always see it at `/tmp`. Give each agent or working tree its own directory, because everything at its top level apart from the agent's own working files is removed when a job finishes. |
|
||||
| `SUITE_TMP_PATH` | `/tmp` | Path the test process itself reads and writes that same content through. In CI this stays `/tmp` because `SUITE_TMP` is bound there inside the agent; locally it is set to `SUITE_TMP`, since the test process runs on the host. |
|
||||
| `SUITE_IMAGE` | `authelia:dist` | Image the backend runs. |
|
||||
| `AGENT_CONTAINER` | _unset_ | Name of the container the tests run in. When set, that container is attached to the suite network on setup and detached on teardown, so Chrome can reach the portal. |
|
||||
| `SUITE_OIDC_CONFORMANCE_MONGODB_HOST` | _unset_ | MongoDB host the OIDCConformance suite's conformance server uses. When set, the suite does not run its own MongoDB; the host has to be reachable from the suite network. |
|
||||
|
||||
## Running Suites Concurrently
|
||||
|
||||
@@ -198,4 +199,20 @@ Creating a suite is as easy. Let's take the example of the **Standalone** suite:
|
||||
A suite can also be much more complex like setting up a complete Kubernetes ecosystem. You can check the Kubernetes
|
||||
suite as example.
|
||||
|
||||
## Commit Message Toggles
|
||||
|
||||
Our continuous integration reads the following options from the commit message. It reads the message of the commit
|
||||
being built, which for a pull request is its most recent commit, so an option has to be in that commit's message. An
|
||||
option can appear anywhere in the message; placing it in the body or the footer keeps the header within its format.
|
||||
|
||||
- `[skip test]` or `[test skip]`: skips linting, the external tests, the coverage image build, and the integration
|
||||
tests. Every other step, including the unit tests, still runs.
|
||||
- `[debug test]` or `[test debug]`: runs the integration suites with their opt-in debug output enabled. The OpenID
|
||||
Connect 1.0 conformance suite groups its build log by plan, with each group holding its tests' `=== RUN` lines and
|
||||
opening by itself when one of them fails, and logs a trace of what it did for each module, stamped with the time in
|
||||
UTC, under that module's subtest. Without this option the build log is left as `go test` writes it. The trace is
|
||||
available locally by setting the `SUITE_DEBUG` environment variable to `true` when running a suite.
|
||||
|
||||
A commit whose header begins with `docs` also skips vulnerability scanning.
|
||||
|
||||
[delve]: https://github.com/go-delve/delve
|
||||
@@ -46,6 +46,12 @@ actively perform the tests on each version of Authelia to maintain the latest co
|
||||
You can view our published conformance tests at [Certified OpenID Providers & Profiles] and
|
||||
[Certified OpenID Providers for Logout Profiles].
|
||||
|
||||
In addition to the published conformance tests, all commits that change any part of the code run integration tests
|
||||
against every module of the conformance plans that Authelia is certified for or is planning to get certified for. These
|
||||
plans and modules are all of the tests required for an implementation to become certified. This is both done as a
|
||||
quality assurance measure to ensure confidence in Authelia's implementation and also to thoroughly test the
|
||||
implementation against normal use cases.
|
||||
|
||||
### OpenID Connect Protocol Suite
|
||||
|
||||
<figure>
|
||||
|
||||
@@ -1101,6 +1101,68 @@ func TestHandleOAuth2AuthorizationConsentModeImplicitWithID(t *testing.T) {
|
||||
},
|
||||
expect: func(t *testing.T, mock *mocks.MockAutheliaCtx) {},
|
||||
},
|
||||
{
|
||||
name: "ShouldUseTheResponseAlreadyGivenOnTheConsentForm",
|
||||
issuer: mustParseURI(t, "https://auth.example.com"),
|
||||
client: clientTest,
|
||||
userSession: session.UserSession{Username: testValue, FirstFactorAuthnTimestamp: 1000000, SecondFactorAuthnTimestamp: 1000000},
|
||||
subject: sub,
|
||||
requester: &oauthelia2.AuthorizeRequest{
|
||||
Request: oauthelia2.Request{
|
||||
Client: clientTest,
|
||||
Form: url.Values{
|
||||
oidc.FormParameterMaximumAge: []string{"10"},
|
||||
},
|
||||
RequestedAt: time.Unix(1000000, 0),
|
||||
},
|
||||
},
|
||||
expected: &model.OAuth2ConsentSession{
|
||||
ID: 40,
|
||||
ClientID: "test",
|
||||
Subject: uuid.NullUUID{UUID: sub, Valid: true},
|
||||
Authorized: true,
|
||||
Form: "max_age=10",
|
||||
RequestedAt: time.Unix(1000000, 0),
|
||||
RespondedAt: sql.NullTime{Time: time.Unix(1000000, 0), Valid: true},
|
||||
},
|
||||
handled: false,
|
||||
setup: func(t *testing.T, mock *mocks.MockAutheliaCtx) {
|
||||
gomock.InOrder(
|
||||
mock.StorageMock.EXPECT().
|
||||
LoadOAuth2ConsentSessionByChallengeID(gomock.Eq(mock.Ctx), gomock.Eq(challenge)).
|
||||
Return(&model.OAuth2ConsentSession{ID: 40, ChallengeID: challenge, ClientID: "test", Subject: uuid.NullUUID{UUID: sub, Valid: true}, Authorized: true, Form: "max_age=10", RequestedAt: time.Unix(1000000, 0), RespondedAt: sql.NullTime{Time: time.Unix(1000000, 0), Valid: true}, ExpiresAt: mock.Ctx.Providers.Clock.Now().Add(time.Second * 10)}, nil),
|
||||
)
|
||||
},
|
||||
expect: func(t *testing.T, mock *mocks.MockAutheliaCtx) {},
|
||||
},
|
||||
{
|
||||
name: "ShouldDenyWhenTheConsentFormWasRejected",
|
||||
issuer: mustParseURI(t, "https://auth.example.com"),
|
||||
client: clientTest,
|
||||
userSession: session.UserSession{Username: testValue, FirstFactorAuthnTimestamp: 1000000, SecondFactorAuthnTimestamp: 1000000},
|
||||
subject: sub,
|
||||
requester: &oauthelia2.AuthorizeRequest{
|
||||
Request: oauthelia2.Request{
|
||||
Client: clientTest,
|
||||
Form: url.Values{
|
||||
oidc.FormParameterMaximumAge: []string{"10"},
|
||||
},
|
||||
RequestedAt: time.Unix(1000000, 0),
|
||||
},
|
||||
},
|
||||
expected: nil,
|
||||
handled: true,
|
||||
setup: func(t *testing.T, mock *mocks.MockAutheliaCtx) {
|
||||
gomock.InOrder(
|
||||
mock.StorageMock.EXPECT().
|
||||
LoadOAuth2ConsentSessionByChallengeID(gomock.Eq(mock.Ctx), gomock.Eq(challenge)).
|
||||
Return(&model.OAuth2ConsentSession{ID: 40, ChallengeID: challenge, ClientID: "test", Subject: uuid.NullUUID{UUID: sub, Valid: true}, Authorized: false, Form: "max_age=10", RequestedAt: time.Unix(1000000, 0), RespondedAt: sql.NullTime{Time: time.Unix(1000000, 0), Valid: true}, ExpiresAt: mock.Ctx.Providers.Clock.Now().Add(time.Second * 10)}, nil),
|
||||
)
|
||||
},
|
||||
expect: func(t *testing.T, mock *mocks.MockAutheliaCtx) {
|
||||
mock.AssertLastLogMessageRegexp(t, regexp.MustCompile(`^Authorization Request with id '[a-zA-Z0-9]{8}-[a-zA-Z0-9]{4}-[a-zA-Z0-9]{4}-[a-zA-Z0-9]{4}-[a-zA-Z0-9]{12}' on client with id 'test' using consent mode 'implicit' could not be processed: error occurred performing consent for consent session with id '11303e1f-f8af-436a-9a72-c7361bfc9f37': the user explicitly rejected this consent session$`), nil)
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
|
||||
@@ -97,6 +97,18 @@ func handleOAuth2AuthorizationConsentModeImplicitWithID(ctx *middlewares.Autheli
|
||||
return nil, true
|
||||
}
|
||||
|
||||
if consent.Responded() {
|
||||
if !consent.IsAuthorized() {
|
||||
ctx.GetLogger().Errorf(logFmtErrConsentCantGrantRejected, requester.GetID(), client.GetID(), client.GetConsentPolicy(), consent.ChallengeID)
|
||||
|
||||
ctx.Providers.OpenIDConnect.WriteDynamicAuthorizeError(ctx, rw, requester, oauthelia2.ErrAccessDenied)
|
||||
|
||||
return nil, true
|
||||
}
|
||||
|
||||
return consent, false
|
||||
}
|
||||
|
||||
var requests *oidc.ClaimsRequests
|
||||
|
||||
if requests, err = oidc.NewClaimRequests(requester.GetRequestForm()); err == nil && requests != nil {
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
//
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package main
|
||||
package conformance
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
@@ -17,80 +17,87 @@ import (
|
||||
"github.com/authelia/authelia/v4/internal/random"
|
||||
)
|
||||
|
||||
// OpenIDConnectConformanceSuiteBuilder builds an OpenIDConnectConformanceSuite.
|
||||
type OpenIDConnectConformanceSuiteBuilder struct {
|
||||
brand string
|
||||
name string
|
||||
friendly string
|
||||
certification bool
|
||||
version string
|
||||
consent string
|
||||
policy string
|
||||
suiteURL *url.URL
|
||||
autheliaURL *url.URL
|
||||
// SuiteBuilder builds a Suite.
|
||||
type SuiteBuilder struct {
|
||||
Brand string
|
||||
Name string
|
||||
Friendly string
|
||||
Certification bool
|
||||
Version string
|
||||
Consent string
|
||||
Policy string
|
||||
SuiteURL *url.URL
|
||||
AutheliaURL *url.URL
|
||||
}
|
||||
|
||||
// Build returns the OpenIDConnectConformanceSuite for this builder.
|
||||
func (b *OpenIDConnectConformanceSuiteBuilder) Build() OpenIDConnectConformanceSuite {
|
||||
var (
|
||||
apiname, namePrefix, clientIDPrefix, descriptionSuffix string
|
||||
variant *OpenIDConnectConformanceSuitePlanVariant
|
||||
)
|
||||
|
||||
if b.certification {
|
||||
namePrefix = "conformance-"
|
||||
clientIDPrefix = "conformance-certification"
|
||||
descriptionSuffix = "Certification Profile"
|
||||
} else {
|
||||
clientIDPrefix = "conformance-test"
|
||||
descriptionSuffix = "Test Profile"
|
||||
// Description returns the plan description naming release as the version of Authelia under test.
|
||||
func (b *SuiteBuilder) Description(release string) string {
|
||||
if b.Certification {
|
||||
return fmt.Sprintf("Authelia %s %s Certification Profile", release, b.Friendly)
|
||||
}
|
||||
|
||||
aliasSuffix := fmt.Sprintf("%s-%s", strings.ReplaceAll(strings.ToLower(b.name), ".", "-"), b.brand+strings.ReplaceAll(strings.ToLower(b.version), ".", ""))
|
||||
return fmt.Sprintf("Authelia %s %s Test Profile", release, b.Friendly)
|
||||
}
|
||||
|
||||
name := fmt.Sprintf("%s%s", namePrefix, b.name)
|
||||
description := fmt.Sprintf("Authelia %s %s %s", b.version, b.friendly, descriptionSuffix)
|
||||
// Build returns the Suite for this builder.
|
||||
func (b *SuiteBuilder) Build() Suite {
|
||||
var (
|
||||
apiname, namePrefix, clientIDPrefix string
|
||||
variant *PlanVariant
|
||||
)
|
||||
|
||||
switch name {
|
||||
case suiteConformanceBasic, suiteConformanceBasicFormPost, suiteConformanceHybrid, suiteConformanceHybridFormPost, suiteConformanceImplicit, suiteConformanceImplicitFormPost:
|
||||
variant = &OpenIDConnectConformanceSuitePlanVariant{
|
||||
if b.Certification {
|
||||
namePrefix = "conformance-"
|
||||
clientIDPrefix = "conformance-certification"
|
||||
} else {
|
||||
clientIDPrefix = "conformance-test"
|
||||
}
|
||||
|
||||
aliasSuffix := fmt.Sprintf("%s-%s", strings.ReplaceAll(strings.ToLower(b.Name), ".", "-"), b.Brand+strings.ReplaceAll(strings.ToLower(b.Version), ".", ""))
|
||||
|
||||
name := fmt.Sprintf("%s%s", namePrefix, b.Name)
|
||||
description := b.Description(b.Version)
|
||||
|
||||
switch b.Name {
|
||||
case NameBasic, NameBasicFormPost, NameHybrid, NameHybridFormPost, NameImplicit, NameImplicitFormPost:
|
||||
variant = &PlanVariant{
|
||||
ServerMetadata: "discovery",
|
||||
ClientRegistration: "static_client",
|
||||
}
|
||||
}
|
||||
|
||||
switch name {
|
||||
case "conformance-config":
|
||||
switch b.Name {
|
||||
case NameConfig:
|
||||
apiname = "oidcc-config-certification-test-plan"
|
||||
case suiteConformanceBasic:
|
||||
case NameBasic:
|
||||
apiname = "oidcc-basic-certification-test-plan"
|
||||
case suiteConformanceBasicFormPost:
|
||||
case NameBasicFormPost:
|
||||
apiname = "oidcc-formpost-basic-certification-test-plan"
|
||||
case suiteConformanceHybrid:
|
||||
case NameHybrid:
|
||||
apiname = "oidcc-hybrid-certification-test-plan"
|
||||
case suiteConformanceHybridFormPost:
|
||||
case NameHybridFormPost:
|
||||
apiname = "oidcc-formpost-hybrid-certification-test-plan"
|
||||
case suiteConformanceImplicit:
|
||||
case NameImplicit:
|
||||
apiname = "oidcc-implicit-certification-test-plan"
|
||||
case suiteConformanceImplicitFormPost:
|
||||
case NameImplicitFormPost:
|
||||
apiname = "oidcc-formpost-implicit-certification-test-plan"
|
||||
}
|
||||
|
||||
suite := OpenIDConnectConformanceSuite{
|
||||
suite := Suite{
|
||||
Name: name,
|
||||
Plan: OpenIDConnectConformanceSuitePlan{
|
||||
Plan: Plan{
|
||||
Name: apiname,
|
||||
Variant: variant,
|
||||
Alias: fmt.Sprintf("%s%s", namePrefix, aliasSuffix),
|
||||
Publish: "summary",
|
||||
Description: description,
|
||||
Server: OpenIDConnectConformanceSuitePlanServer{
|
||||
DiscoveryURL: b.autheliaURL.JoinPath(".well-known/openid-configuration").String(),
|
||||
Server: PlanServer{
|
||||
DiscoveryURL: b.AutheliaURL.JoinPath(".well-known/openid-configuration").String(),
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
if b.suiteURL == nil {
|
||||
if b.SuiteURL == nil {
|
||||
return suite
|
||||
}
|
||||
|
||||
@@ -100,17 +107,17 @@ func (b *OpenIDConnectConformanceSuiteBuilder) Build() OpenIDConnectConformanceS
|
||||
secretAlternate := r.StringCustom(80, random.CharSetAlphaNumeric)
|
||||
secretPost := r.StringCustom(80, random.CharSetAlphaNumeric)
|
||||
|
||||
suite.Plan.Client = &OpenIDConnectConformanceSuitePlanClient{
|
||||
suite.Plan.Client = &PlanClient{
|
||||
ID: fmt.Sprintf("%s-%s", clientIDPrefix, aliasSuffix),
|
||||
Secret: secret,
|
||||
}
|
||||
|
||||
suite.Plan.ClientAlternate = &OpenIDConnectConformanceSuitePlanClient{
|
||||
suite.Plan.ClientAlternate = &PlanClient{
|
||||
ID: fmt.Sprintf("%s-%s-alt", clientIDPrefix, aliasSuffix),
|
||||
Secret: secretAlternate,
|
||||
}
|
||||
|
||||
suite.Plan.ClientSecretPost = &OpenIDConnectConformanceSuitePlanClient{
|
||||
suite.Plan.ClientSecretPost = &PlanClient{
|
||||
ID: fmt.Sprintf("%s-%s-post", clientIDPrefix, aliasSuffix),
|
||||
Secret: secretPost,
|
||||
}
|
||||
@@ -121,11 +128,11 @@ func (b *OpenIDConnectConformanceSuiteBuilder) Build() OpenIDConnectConformanceS
|
||||
responseModes []string
|
||||
)
|
||||
|
||||
switch b.name {
|
||||
case "implicit", suiteNameImplicitFormPost:
|
||||
switch b.Name {
|
||||
case NameImplicit, NameImplicitFormPost:
|
||||
grantTypes = []string{oidc.GrantTypeAuthorizationCode, oidc.GrantTypeImplicit, oidc.GrantTypeRefreshToken}
|
||||
responseTypes = []string{oidc.ResponseTypeAuthorizationCodeFlow, oidc.ResponseTypeImplicitFlowIDToken, oidc.ResponseTypeImplicitFlowToken, oidc.ResponseTypeImplicitFlowBoth}
|
||||
case "hybrid", suiteNameHybridFormPost:
|
||||
case NameHybrid, NameHybridFormPost:
|
||||
grantTypes = []string{oidc.GrantTypeAuthorizationCode, oidc.GrantTypeImplicit, oidc.GrantTypeRefreshToken}
|
||||
responseTypes = []string{oidc.ResponseTypeAuthorizationCodeFlow, oidc.ResponseTypeHybridFlowIDToken, oidc.ResponseTypeHybridFlowToken, oidc.ResponseTypeHybridFlowBoth}
|
||||
default:
|
||||
@@ -133,8 +140,8 @@ func (b *OpenIDConnectConformanceSuiteBuilder) Build() OpenIDConnectConformanceS
|
||||
responseTypes = []string{oidc.ResponseTypeAuthorizationCodeFlow}
|
||||
}
|
||||
|
||||
switch b.name {
|
||||
case suiteNameBasicFormPost, suiteNameHybridFormPost, suiteNameImplicitFormPost:
|
||||
switch b.Name {
|
||||
case NameBasicFormPost, NameHybridFormPost, NameImplicitFormPost:
|
||||
responseModes = []string{oidc.ResponseModeFormPost, oidc.ResponseModeFormPostJWT}
|
||||
default:
|
||||
responseModes = []string{oidc.ResponseModeQuery, oidc.ResponseModeQueryJWT}
|
||||
@@ -145,9 +152,9 @@ func (b *OpenIDConnectConformanceSuiteBuilder) Build() OpenIDConnectConformanceS
|
||||
ID: suite.Plan.Client.ID,
|
||||
Name: description,
|
||||
Secret: MustHash(suite.Plan.Client.Secret),
|
||||
RedirectURIs: []string{b.suiteURL.JoinPath("test", "a", suite.Plan.Alias, "callback").String()},
|
||||
AuthorizationPolicy: b.policy,
|
||||
ConsentMode: b.consent,
|
||||
RedirectURIs: []string{b.SuiteURL.JoinPath("test", "a", suite.Plan.Alias, "callback").String()},
|
||||
AuthorizationPolicy: b.Policy,
|
||||
ConsentMode: b.Consent,
|
||||
Public: false,
|
||||
Scopes: []string{oidc.ScopeOpenID, oidc.ScopeOfflineAccess, oidc.ScopeProfile, oidc.ScopeEmail, oidc.ScopePhone, oidc.ScopeAddress, "all"},
|
||||
ResponseTypes: responseTypes,
|
||||
@@ -160,9 +167,9 @@ func (b *OpenIDConnectConformanceSuiteBuilder) Build() OpenIDConnectConformanceS
|
||||
ID: suite.Plan.ClientAlternate.ID,
|
||||
Name: fmt.Sprintf("%s (Alternate)", description),
|
||||
Secret: MustHash(suite.Plan.ClientAlternate.Secret),
|
||||
RedirectURIs: []string{b.suiteURL.JoinPath("test", "a", suite.Plan.Alias, "callback").String()},
|
||||
AuthorizationPolicy: b.policy,
|
||||
ConsentMode: b.consent,
|
||||
RedirectURIs: []string{b.SuiteURL.JoinPath("test", "a", suite.Plan.Alias, "callback").String()},
|
||||
AuthorizationPolicy: b.Policy,
|
||||
ConsentMode: b.Consent,
|
||||
Public: false,
|
||||
Scopes: []string{oidc.ScopeOpenID, oidc.ScopeOfflineAccess, oidc.ScopeProfile, oidc.ScopeEmail, oidc.ScopePhone, oidc.ScopeAddress, "all"},
|
||||
ResponseTypes: responseTypes,
|
||||
@@ -175,9 +182,9 @@ func (b *OpenIDConnectConformanceSuiteBuilder) Build() OpenIDConnectConformanceS
|
||||
ID: suite.Plan.ClientSecretPost.ID,
|
||||
Name: fmt.Sprintf("%s (Secret Post)", description),
|
||||
Secret: MustHash(suite.Plan.ClientSecretPost.Secret),
|
||||
RedirectURIs: []string{b.suiteURL.JoinPath("test", "a", suite.Plan.Alias, "callback").String()},
|
||||
AuthorizationPolicy: b.policy,
|
||||
ConsentMode: b.consent,
|
||||
RedirectURIs: []string{b.SuiteURL.JoinPath("test", "a", suite.Plan.Alias, "callback").String()},
|
||||
AuthorizationPolicy: b.Policy,
|
||||
ConsentMode: b.Consent,
|
||||
Public: false,
|
||||
Scopes: []string{oidc.ScopeOpenID, oidc.ScopeOfflineAccess, oidc.ScopeProfile, oidc.ScopeEmail, oidc.ScopePhone, oidc.ScopeAddress, "all"},
|
||||
ResponseTypes: responseTypes,
|
||||
@@ -206,3 +213,16 @@ func MustHash(value string) *schema.PasswordDigest {
|
||||
|
||||
return schema.NewPasswordDigest(digest)
|
||||
}
|
||||
|
||||
// Builders returns the conformance suite builders for every profile Authelia is certified for, in a fixed order.
|
||||
func Builders(version, consent, policy, brand string, suiteURL, autheliaURL *url.URL) []*SuiteBuilder {
|
||||
return []*SuiteBuilder{
|
||||
{brand, NameConfig, "Config", true, version, consent, policy, nil, autheliaURL},
|
||||
{brand, NameBasic, "Basic", true, version, consent, policy, suiteURL, autheliaURL},
|
||||
{brand, NameBasicFormPost, "Basic (Form Post)", true, version, consent, policy, suiteURL, autheliaURL},
|
||||
{brand, NameHybrid, "Hybrid", true, version, consent, policy, suiteURL, autheliaURL},
|
||||
{brand, NameHybridFormPost, "Hybrid (Form Post)", true, version, consent, policy, suiteURL, autheliaURL},
|
||||
{brand, NameImplicit, "Implicit", true, version, consent, policy, suiteURL, autheliaURL},
|
||||
{brand, NameImplicitFormPost, "Implicit (Form Post)", true, version, consent, policy, suiteURL, autheliaURL},
|
||||
}
|
||||
}
|
||||
+124
-59
@@ -2,7 +2,7 @@
|
||||
//
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package main
|
||||
package conformance
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
@@ -20,7 +20,16 @@ func TestMustHash(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestOpenIDConnectConformanceSuiteBuilder_Build(t *testing.T) {
|
||||
func TestSuiteBuilder_Description(t *testing.T) {
|
||||
certification := &SuiteBuilder{Friendly: "Basic", Certification: true, Version: "k3j2x9ab", AutheliaURL: &url.URL{Scheme: "https", Host: "auth.example.com"}}
|
||||
test := &SuiteBuilder{Friendly: "Basic (Form Post)", Version: "k3j2x9ab"}
|
||||
|
||||
assert.Equal(t, "Authelia v4.39.24 (commit 7498f635) Basic Certification Profile", certification.Description("v4.39.24 (commit 7498f635)"))
|
||||
assert.Equal(t, "Authelia commit 7498f635 Basic (Form Post) Test Profile", test.Description("commit 7498f635"))
|
||||
assert.Equal(t, certification.Build().Plan.Description, certification.Description(certification.Version), "the build describes the plan by its version")
|
||||
}
|
||||
|
||||
func TestSuiteBuilder_Build(t *testing.T) {
|
||||
suiteURL := &url.URL{
|
||||
Scheme: "https",
|
||||
Host: "conformance.example.com",
|
||||
@@ -35,20 +44,40 @@ func TestOpenIDConnectConformanceSuiteBuilder_Build(t *testing.T) {
|
||||
|
||||
testCases := []struct {
|
||||
name string
|
||||
have *OpenIDConnectConformanceSuiteBuilder
|
||||
expected OpenIDConnectConformanceSuite
|
||||
have *SuiteBuilder
|
||||
expected Suite
|
||||
}{
|
||||
{
|
||||
"ShouldHandleConfig",
|
||||
&OpenIDConnectConformanceSuiteBuilder{"authelia", "config", "Config", true, "4.40", "implicit", "one_factor", nil, autheliaURL},
|
||||
OpenIDConnectConformanceSuite{
|
||||
&SuiteBuilder{"authelia", "config", "Config", true, "4.40", "implicit", "one_factor", nil, autheliaURL},
|
||||
Suite{
|
||||
Name: "conformance-config",
|
||||
Plan: OpenIDConnectConformanceSuitePlan{
|
||||
Plan: Plan{
|
||||
Name: "oidcc-config-certification-test-plan",
|
||||
Alias: "conformance-config-authelia440",
|
||||
Description: "Authelia 4.40 Config Certification Profile",
|
||||
Publish: "summary",
|
||||
Server: OpenIDConnectConformanceSuitePlanServer{
|
||||
Server: PlanServer{
|
||||
DiscoveryURL: "https://auth.example.com/.well-known/openid-configuration",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"ShouldHandleBasicTestProfile",
|
||||
&SuiteBuilder{"authelia", "basic", "Basic", false, "4.40", "implicit", "one_factor", nil, autheliaURL},
|
||||
Suite{
|
||||
Name: "basic",
|
||||
Plan: Plan{
|
||||
Name: "oidcc-basic-certification-test-plan",
|
||||
Alias: "basic-authelia440",
|
||||
Description: "Authelia 4.40 Basic Test Profile",
|
||||
Publish: "summary",
|
||||
Variant: &PlanVariant{
|
||||
ServerMetadata: "discovery",
|
||||
ClientRegistration: "static_client",
|
||||
},
|
||||
Server: PlanServer{
|
||||
DiscoveryURL: "https://auth.example.com/.well-known/openid-configuration",
|
||||
},
|
||||
},
|
||||
@@ -56,30 +85,30 @@ func TestOpenIDConnectConformanceSuiteBuilder_Build(t *testing.T) {
|
||||
},
|
||||
{
|
||||
"ShouldHandleBasic",
|
||||
&OpenIDConnectConformanceSuiteBuilder{"authelia", "basic", "Basic", true, "4.40", "implicit", "one_factor", suiteURL, autheliaURL},
|
||||
OpenIDConnectConformanceSuite{
|
||||
&SuiteBuilder{"authelia", "basic", "Basic", true, "4.40", "implicit", "one_factor", suiteURL, autheliaURL},
|
||||
Suite{
|
||||
Name: "conformance-basic",
|
||||
Plan: OpenIDConnectConformanceSuitePlan{
|
||||
Plan: Plan{
|
||||
Name: "oidcc-basic-certification-test-plan",
|
||||
Alias: "conformance-basic-authelia440",
|
||||
Description: "Authelia 4.40 Basic Certification Profile",
|
||||
Publish: "summary",
|
||||
Variant: &OpenIDConnectConformanceSuitePlanVariant{
|
||||
Variant: &PlanVariant{
|
||||
ServerMetadata: "discovery",
|
||||
ClientRegistration: "static_client",
|
||||
},
|
||||
Server: OpenIDConnectConformanceSuitePlanServer{
|
||||
Server: PlanServer{
|
||||
DiscoveryURL: "https://auth.example.com/.well-known/openid-configuration",
|
||||
},
|
||||
Client: &OpenIDConnectConformanceSuitePlanClient{
|
||||
Client: &PlanClient{
|
||||
ID: "conformance-certification-basic-authelia440",
|
||||
Secret: "present",
|
||||
},
|
||||
ClientAlternate: &OpenIDConnectConformanceSuitePlanClient{
|
||||
ClientAlternate: &PlanClient{
|
||||
ID: "conformance-certification-basic-authelia440-alt",
|
||||
Secret: "present",
|
||||
},
|
||||
ClientSecretPost: &OpenIDConnectConformanceSuitePlanClient{
|
||||
ClientSecretPost: &PlanClient{
|
||||
ID: "conformance-certification-basic-authelia440-post",
|
||||
Secret: "present",
|
||||
},
|
||||
@@ -126,30 +155,30 @@ func TestOpenIDConnectConformanceSuiteBuilder_Build(t *testing.T) {
|
||||
},
|
||||
{
|
||||
"ShouldHandleBasicFormPost",
|
||||
&OpenIDConnectConformanceSuiteBuilder{"authelia", "basic-form-post", "Basic (Form Post)", true, "4.40", "implicit", "one_factor", suiteURL, autheliaURL},
|
||||
OpenIDConnectConformanceSuite{
|
||||
&SuiteBuilder{"authelia", "basic-form-post", "Basic (Form Post)", true, "4.40", "implicit", "one_factor", suiteURL, autheliaURL},
|
||||
Suite{
|
||||
Name: "conformance-basic-form-post",
|
||||
Plan: OpenIDConnectConformanceSuitePlan{
|
||||
Name: "oidcc-basic-form-post-certification-test-plan",
|
||||
Plan: Plan{
|
||||
Name: "oidcc-formpost-basic-certification-test-plan",
|
||||
Alias: "conformance-basic-form-post-authelia440",
|
||||
Description: "Authelia 4.40 Basic (Form Post) Certification Profile",
|
||||
Publish: "summary",
|
||||
Variant: &OpenIDConnectConformanceSuitePlanVariant{
|
||||
Variant: &PlanVariant{
|
||||
ServerMetadata: "discovery",
|
||||
ClientRegistration: "static_client",
|
||||
},
|
||||
Server: OpenIDConnectConformanceSuitePlanServer{
|
||||
Server: PlanServer{
|
||||
DiscoveryURL: "https://auth.example.com/.well-known/openid-configuration",
|
||||
},
|
||||
Client: &OpenIDConnectConformanceSuitePlanClient{
|
||||
Client: &PlanClient{
|
||||
ID: "conformance-certification-basic-form-post-authelia440",
|
||||
Secret: "present",
|
||||
},
|
||||
ClientAlternate: &OpenIDConnectConformanceSuitePlanClient{
|
||||
ClientAlternate: &PlanClient{
|
||||
ID: "conformance-certification-basic-form-post-authelia440-alt",
|
||||
Secret: "present",
|
||||
},
|
||||
ClientSecretPost: &OpenIDConnectConformanceSuitePlanClient{
|
||||
ClientSecretPost: &PlanClient{
|
||||
ID: "conformance-certification-basic-form-post-authelia440-post",
|
||||
Secret: "present",
|
||||
},
|
||||
@@ -196,30 +225,30 @@ func TestOpenIDConnectConformanceSuiteBuilder_Build(t *testing.T) {
|
||||
},
|
||||
{
|
||||
"ShouldHandleImplicit",
|
||||
&OpenIDConnectConformanceSuiteBuilder{"authelia", "implicit", "Implicit", true, "4.40", "implicit", "one_factor", suiteURL, autheliaURL},
|
||||
OpenIDConnectConformanceSuite{
|
||||
&SuiteBuilder{"authelia", "implicit", "Implicit", true, "4.40", "implicit", "one_factor", suiteURL, autheliaURL},
|
||||
Suite{
|
||||
Name: "conformance-implicit",
|
||||
Plan: OpenIDConnectConformanceSuitePlan{
|
||||
Plan: Plan{
|
||||
Name: "oidcc-implicit-certification-test-plan",
|
||||
Alias: "conformance-implicit-authelia440",
|
||||
Description: "Authelia 4.40 Implicit Certification Profile",
|
||||
Publish: "summary",
|
||||
Variant: &OpenIDConnectConformanceSuitePlanVariant{
|
||||
Variant: &PlanVariant{
|
||||
ServerMetadata: "discovery",
|
||||
ClientRegistration: "static_client",
|
||||
},
|
||||
Server: OpenIDConnectConformanceSuitePlanServer{
|
||||
Server: PlanServer{
|
||||
DiscoveryURL: "https://auth.example.com/.well-known/openid-configuration",
|
||||
},
|
||||
Client: &OpenIDConnectConformanceSuitePlanClient{
|
||||
Client: &PlanClient{
|
||||
ID: "conformance-certification-implicit-authelia440",
|
||||
Secret: "present",
|
||||
},
|
||||
ClientAlternate: &OpenIDConnectConformanceSuitePlanClient{
|
||||
ClientAlternate: &PlanClient{
|
||||
ID: "conformance-certification-implicit-authelia440-alt",
|
||||
Secret: "present",
|
||||
},
|
||||
ClientSecretPost: &OpenIDConnectConformanceSuitePlanClient{
|
||||
ClientSecretPost: &PlanClient{
|
||||
ID: "conformance-certification-implicit-authelia440-post",
|
||||
Secret: "present",
|
||||
},
|
||||
@@ -266,30 +295,30 @@ func TestOpenIDConnectConformanceSuiteBuilder_Build(t *testing.T) {
|
||||
},
|
||||
{
|
||||
"ShouldHandleImplicitFormPost",
|
||||
&OpenIDConnectConformanceSuiteBuilder{"authelia", "implicit-form-post", "Implicit (Form Post)", true, "4.40", "implicit", "one_factor", suiteURL, autheliaURL},
|
||||
OpenIDConnectConformanceSuite{
|
||||
&SuiteBuilder{"authelia", "implicit-form-post", "Implicit (Form Post)", true, "4.40", "implicit", "one_factor", suiteURL, autheliaURL},
|
||||
Suite{
|
||||
Name: "conformance-implicit-form-post",
|
||||
Plan: OpenIDConnectConformanceSuitePlan{
|
||||
Name: "oidcc-implicit-form-post-certification-test-plan",
|
||||
Plan: Plan{
|
||||
Name: "oidcc-formpost-implicit-certification-test-plan",
|
||||
Alias: "conformance-implicit-form-post-authelia440",
|
||||
Description: "Authelia 4.40 Implicit (Form Post) Certification Profile",
|
||||
Publish: "summary",
|
||||
Variant: &OpenIDConnectConformanceSuitePlanVariant{
|
||||
Variant: &PlanVariant{
|
||||
ServerMetadata: "discovery",
|
||||
ClientRegistration: "static_client",
|
||||
},
|
||||
Server: OpenIDConnectConformanceSuitePlanServer{
|
||||
Server: PlanServer{
|
||||
DiscoveryURL: "https://auth.example.com/.well-known/openid-configuration",
|
||||
},
|
||||
Client: &OpenIDConnectConformanceSuitePlanClient{
|
||||
Client: &PlanClient{
|
||||
ID: "conformance-certification-implicit-form-post-authelia440",
|
||||
Secret: "present",
|
||||
},
|
||||
ClientAlternate: &OpenIDConnectConformanceSuitePlanClient{
|
||||
ClientAlternate: &PlanClient{
|
||||
ID: "conformance-certification-implicit-form-post-authelia440-alt",
|
||||
Secret: "present",
|
||||
},
|
||||
ClientSecretPost: &OpenIDConnectConformanceSuitePlanClient{
|
||||
ClientSecretPost: &PlanClient{
|
||||
ID: "conformance-certification-implicit-form-post-authelia440-post",
|
||||
Secret: "present",
|
||||
},
|
||||
@@ -336,30 +365,30 @@ func TestOpenIDConnectConformanceSuiteBuilder_Build(t *testing.T) {
|
||||
},
|
||||
{
|
||||
"ShouldHandleHybrid",
|
||||
&OpenIDConnectConformanceSuiteBuilder{"authelia", "hybrid", "Hybrid", true, "4.40", "implicit", "one_factor", suiteURL, autheliaURL},
|
||||
OpenIDConnectConformanceSuite{
|
||||
&SuiteBuilder{"authelia", "hybrid", "Hybrid", true, "4.40", "implicit", "one_factor", suiteURL, autheliaURL},
|
||||
Suite{
|
||||
Name: "conformance-hybrid",
|
||||
Plan: OpenIDConnectConformanceSuitePlan{
|
||||
Plan: Plan{
|
||||
Name: "oidcc-hybrid-certification-test-plan",
|
||||
Alias: "conformance-hybrid-authelia440",
|
||||
Description: "Authelia 4.40 Hybrid Certification Profile",
|
||||
Publish: "summary",
|
||||
Variant: &OpenIDConnectConformanceSuitePlanVariant{
|
||||
Variant: &PlanVariant{
|
||||
ServerMetadata: "discovery",
|
||||
ClientRegistration: "static_client",
|
||||
},
|
||||
Server: OpenIDConnectConformanceSuitePlanServer{
|
||||
Server: PlanServer{
|
||||
DiscoveryURL: "https://auth.example.com/.well-known/openid-configuration",
|
||||
},
|
||||
Client: &OpenIDConnectConformanceSuitePlanClient{
|
||||
Client: &PlanClient{
|
||||
ID: "conformance-certification-hybrid-authelia440",
|
||||
Secret: "present",
|
||||
},
|
||||
ClientAlternate: &OpenIDConnectConformanceSuitePlanClient{
|
||||
ClientAlternate: &PlanClient{
|
||||
ID: "conformance-certification-hybrid-authelia440-alt",
|
||||
Secret: "present",
|
||||
},
|
||||
ClientSecretPost: &OpenIDConnectConformanceSuitePlanClient{
|
||||
ClientSecretPost: &PlanClient{
|
||||
ID: "conformance-certification-hybrid-authelia440-post",
|
||||
Secret: "present",
|
||||
},
|
||||
@@ -406,30 +435,30 @@ func TestOpenIDConnectConformanceSuiteBuilder_Build(t *testing.T) {
|
||||
},
|
||||
{
|
||||
"ShouldHandleHybridFormPost",
|
||||
&OpenIDConnectConformanceSuiteBuilder{"authelia", "hybrid-form-post", "Hybrid (Form Post)", true, "4.40", "implicit", "one_factor", suiteURL, autheliaURL},
|
||||
OpenIDConnectConformanceSuite{
|
||||
&SuiteBuilder{"authelia", "hybrid-form-post", "Hybrid (Form Post)", true, "4.40", "implicit", "one_factor", suiteURL, autheliaURL},
|
||||
Suite{
|
||||
Name: "conformance-hybrid-form-post",
|
||||
Plan: OpenIDConnectConformanceSuitePlan{
|
||||
Name: "oidcc-hybrid-form-post-certification-test-plan",
|
||||
Plan: Plan{
|
||||
Name: "oidcc-formpost-hybrid-certification-test-plan",
|
||||
Alias: "conformance-hybrid-form-post-authelia440",
|
||||
Description: "Authelia 4.40 Hybrid (Form Post) Certification Profile",
|
||||
Publish: "summary",
|
||||
Variant: &OpenIDConnectConformanceSuitePlanVariant{
|
||||
Variant: &PlanVariant{
|
||||
ServerMetadata: "discovery",
|
||||
ClientRegistration: "static_client",
|
||||
},
|
||||
Server: OpenIDConnectConformanceSuitePlanServer{
|
||||
Server: PlanServer{
|
||||
DiscoveryURL: "https://auth.example.com/.well-known/openid-configuration",
|
||||
},
|
||||
Client: &OpenIDConnectConformanceSuitePlanClient{
|
||||
Client: &PlanClient{
|
||||
ID: "conformance-certification-hybrid-form-post-authelia440",
|
||||
Secret: "present",
|
||||
},
|
||||
ClientAlternate: &OpenIDConnectConformanceSuitePlanClient{
|
||||
ClientAlternate: &PlanClient{
|
||||
ID: "conformance-certification-hybrid-form-post-authelia440-alt",
|
||||
Secret: "present",
|
||||
},
|
||||
ClientSecretPost: &OpenIDConnectConformanceSuitePlanClient{
|
||||
ClientSecretPost: &PlanClient{
|
||||
ID: "conformance-certification-hybrid-form-post-authelia440-post",
|
||||
Secret: "present",
|
||||
},
|
||||
@@ -481,6 +510,14 @@ func TestOpenIDConnectConformanceSuiteBuilder_Build(t *testing.T) {
|
||||
actual := tc.have.Build()
|
||||
|
||||
assert.Equal(t, tc.expected.Name, actual.Name)
|
||||
assert.Equal(t, tc.expected.Plan.Name, actual.Plan.Name)
|
||||
assert.Equal(t, tc.expected.Plan.Variant, actual.Plan.Variant)
|
||||
|
||||
assert.Equal(t, tc.expected.Plan.Alias, actual.Plan.Alias)
|
||||
assert.Equal(t, tc.expected.Plan.Description, actual.Plan.Description)
|
||||
assert.Equal(t, tc.expected.Plan.Publish, actual.Plan.Publish)
|
||||
assert.Equal(t, tc.expected.Plan.Server, actual.Plan.Server)
|
||||
|
||||
require.Equal(t, len(tc.expected.Clients), len(actual.Clients))
|
||||
|
||||
for i, expected := range tc.expected.Clients {
|
||||
@@ -524,3 +561,31 @@ func TestOpenIDConnectConformanceSuiteBuilder_Build(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuilders(t *testing.T) {
|
||||
suiteURL := &url.URL{Scheme: "https", Host: "conformance.example.com"}
|
||||
autheliaURL := &url.URL{Scheme: "https", Host: "auth.example.com"}
|
||||
|
||||
builders := Builders("4.40", "implicit", "one_factor", "authelia", suiteURL, autheliaURL)
|
||||
|
||||
names := make([]string, len(builders))
|
||||
|
||||
for i, builder := range builders {
|
||||
names[i] = builder.Name
|
||||
|
||||
assert.True(t, builder.Certification, builder.Name)
|
||||
assert.Equal(t, "authelia", builder.Brand, builder.Name)
|
||||
assert.Equal(t, "4.40", builder.Version, builder.Name)
|
||||
assert.Equal(t, "implicit", builder.Consent, builder.Name)
|
||||
assert.Equal(t, "one_factor", builder.Policy, builder.Name)
|
||||
assert.Equal(t, autheliaURL, builder.AutheliaURL, builder.Name)
|
||||
|
||||
if builder.Name == NameConfig {
|
||||
assert.Nil(t, builder.SuiteURL, "the config profile registers no clients, so it has no suite to redirect to")
|
||||
} else {
|
||||
assert.Equal(t, suiteURL, builder.SuiteURL, builder.Name)
|
||||
}
|
||||
}
|
||||
|
||||
assert.Equal(t, []string{NameConfig, NameBasic, NameBasicFormPost, NameHybrid, NameHybridFormPost, NameImplicit, NameImplicitFormPost}, names)
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
// SPDX-FileCopyrightText: 2026 Authelia
|
||||
//
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
// Package conformance provides the OpenID Connect 1.0 conformance suite plan and client definitions Authelia is
|
||||
// certified against. It is shared by the authelia-gen conformance generator and the OIDCConformance integration suite
|
||||
// so that a plan's client identifiers, secrets and redirect URIs cannot drift from the clients Authelia is configured
|
||||
// with.
|
||||
package conformance
|
||||
|
||||
const (
|
||||
// NameConfig is the builder name of the Config OP profile.
|
||||
NameConfig = "config"
|
||||
|
||||
// NameBasic is the builder name of the Basic OP profile.
|
||||
NameBasic = "basic"
|
||||
|
||||
// NameBasicFormPost is the builder name of the Basic OP profile using the form post response mode.
|
||||
NameBasicFormPost = "basic-form-post"
|
||||
|
||||
// NameHybrid is the builder name of the Hybrid OP profile.
|
||||
NameHybrid = "hybrid"
|
||||
|
||||
// NameHybridFormPost is the builder name of the Hybrid OP profile using the form post response mode.
|
||||
NameHybridFormPost = "hybrid-form-post"
|
||||
|
||||
// NameImplicit is the builder name of the Implicit OP profile.
|
||||
NameImplicit = "implicit"
|
||||
|
||||
// NameImplicitFormPost is the builder name of the Implicit OP profile using the form post response mode.
|
||||
NameImplicitFormPost = "implicit-form-post"
|
||||
)
|
||||
@@ -0,0 +1,93 @@
|
||||
// SPDX-FileCopyrightText: 2026 Authelia
|
||||
//
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package conformance
|
||||
|
||||
import (
|
||||
"github.com/authelia/authelia/v4/internal/configuration/schema"
|
||||
)
|
||||
|
||||
// Suite represents a single OpenID Connect conformance suite.
|
||||
type Suite struct {
|
||||
Name string
|
||||
Plan Plan
|
||||
Clients []schema.IdentityProvidersOpenIDConnectClient
|
||||
}
|
||||
|
||||
// Plan represents the test plan for a Suite.
|
||||
type Plan struct {
|
||||
Name string `json:"-"`
|
||||
Alias string `json:"alias"`
|
||||
Description string `json:"description"`
|
||||
Publish string `json:"publish"`
|
||||
|
||||
Variant *PlanVariant `json:"-"`
|
||||
Server PlanServer `json:"server"`
|
||||
Client *PlanClient `json:"client,omitempty"`
|
||||
ClientAlternate *PlanClient `json:"client2,omitempty"`
|
||||
ClientSecretPost *PlanClient `json:"client_secret_post,omitempty"`
|
||||
MutualTLS *PlanMutualTLS `json:"mtls,omitempty"`
|
||||
Resource *PlanResource `json:"resource,omitempty"`
|
||||
|
||||
EKYCVerifiedClaimsRequest string `json:"ekyc_verified_claims_request,omitempty"`
|
||||
EKYCUserinfo string `json:"ekyc_userinfo,omitempty"`
|
||||
}
|
||||
|
||||
// PlanVariant represents the variant of a Plan.
|
||||
type PlanVariant struct {
|
||||
ServerMetadata string `json:"server_metadata,omitempty"`
|
||||
ClientRegistration string `json:"client_registration,omitempty"`
|
||||
ClientAuthType string `json:"client_auth_type,omitempty"`
|
||||
ResponseType string `json:"response_type,omitempty"`
|
||||
ResponseMode string `json:"response_mode,omitempty"`
|
||||
CIBAMode string `json:"ciba_mode,omitempty"`
|
||||
FAPIProfile string `json:"fapi_profile,omitempty"`
|
||||
FAPIResponseMode string `json:"fapi_response_mode,omitempty"`
|
||||
FAPIAuthRequestMethod string `json:"fapi_auth_request_method,omitempty"`
|
||||
SenderConstrain string `json:"sender_constrain,omitempty"`
|
||||
AuthorizationRequestType string `json:"authorization_request_type,omitempty"`
|
||||
OpenID string `json:"openid,omitempty"`
|
||||
}
|
||||
|
||||
// PlanServer represents the server details of a Plan.
|
||||
type PlanServer struct {
|
||||
DiscoveryURL string `json:"discoveryUrl,omitempty"`
|
||||
Issuer string `json:"issuer,omitempty"`
|
||||
JWKSURI string `json:"jwks_uri,omitempty"`
|
||||
AuthorizationEndpoint string `json:"authorization_endpoint,omitempty"`
|
||||
TokenEndpoint string `json:"token_endpoint,omitempty"`
|
||||
UserinfoEndpoint string `json:"userinfo_endpoint,omitempty"`
|
||||
ACRValues string `json:"acr_values,omitempty"`
|
||||
LoginHint string `json:"login_hint,omitempty"`
|
||||
}
|
||||
|
||||
// PlanClient represents a client registered with a Plan.
|
||||
type PlanClient struct {
|
||||
ID string `json:"client_id,omitempty"`
|
||||
Secret string `json:"client_secret,omitempty"`
|
||||
Name string `json:"client_name,omitempty"`
|
||||
Scope string `json:"scope,omitempty"`
|
||||
SecretJWTAlgorithm string `json:"client_secret_jwt_alg,omitempty"`
|
||||
DPOPSigningAlgorithm string `json:"dpop_signing_alg,omitempty"`
|
||||
InitialAccessToken string `json:"initial_access_token,omitempty"`
|
||||
HintType string `json:"hint_type,omitempty"`
|
||||
HintValue string `json:"hint_value,omitempty"`
|
||||
JWKS string `json:"jwks,omitempty"`
|
||||
}
|
||||
|
||||
// PlanMutualTLS represents the Mutual TLS details of a Plan.
|
||||
type PlanMutualTLS struct {
|
||||
Certificate string `json:"certificate,omitempty"`
|
||||
Key string `json:"key,omitempty"`
|
||||
CertificateAuthority string `json:"ca,omitempty"`
|
||||
}
|
||||
|
||||
// PlanResource represents the protected resource details of a Plan.
|
||||
type PlanResource struct {
|
||||
ResourceURL string `json:"resourceUrl"`
|
||||
ResourceURLAccountRequests string `json:"resourceUrlAccountRequests,omitempty"`
|
||||
ResourceURLAccountsResource string `json:"resourceUrlAccountsResource,omitempty"`
|
||||
InstitutionID string `json:"institution_id,omitempty"`
|
||||
RichAuthorizationRequest string `json:"richAuthorizationRequest,omitempty"`
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
# SPDX-FileCopyrightText: 2026 Authelia
|
||||
#
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
FROM registry.gitlab.com/openid/conformance-suite:release-v5.2.4
|
||||
|
||||
# The conformance server fetches Authelia's discovery document, JWKS, token endpoint and userinfo endpoint over TLS
|
||||
# signed by the suite development CA, which no public root vouches for. Importing into the JDK's own cacerts adds that
|
||||
# CA while leaving the public roots in place, which a -Djavax.net.ssl.trustStore override would not.
|
||||
COPY --link common/pki/ca.public.crt /tmp/authelia-ca.crt
|
||||
|
||||
RUN keytool -importcert -noprompt -alias authelia-development-ca -file /tmp/authelia-ca.crt -cacerts -storepass changeit
|
||||
@@ -0,0 +1,17 @@
|
||||
# SPDX-FileCopyrightText: 2026 Authelia
|
||||
#
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
---
|
||||
services:
|
||||
conformance-mongodb:
|
||||
image: 'mongo:6.0.13'
|
||||
cpus: 2
|
||||
mem_limit: 2g
|
||||
networks:
|
||||
authelianet: {}
|
||||
|
||||
conformance-server:
|
||||
depends_on:
|
||||
- 'conformance-mongodb'
|
||||
...
|
||||
@@ -0,0 +1,39 @@
|
||||
# SPDX-FileCopyrightText: 2026 Authelia
|
||||
#
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
---
|
||||
services:
|
||||
authelia-backend:
|
||||
environment:
|
||||
X_AUTHELIA_CONFIG_FILTERS: 'template'
|
||||
X_AUTHELIA_CONFIG: '/config/configuration.yml,/tmp/conformance-clients.yml'
|
||||
volumes:
|
||||
- './OIDCConformance/configuration.yml:/config/configuration.yml'
|
||||
- './OIDCConformance/users.yml:/config/users.yml'
|
||||
- './common/pki:/pki'
|
||||
- './common/pki/public.crt:/certs/public.crt'
|
||||
- '${SUITE_TMP:-/tmp}:/tmp'
|
||||
|
||||
conformance-server:
|
||||
build:
|
||||
context: '.'
|
||||
dockerfile: 'OIDCConformance/Dockerfile.server'
|
||||
cpus: 4
|
||||
mem_limit: 4g
|
||||
environment:
|
||||
BASE_URL: 'https://conformance.example.com:8443'
|
||||
MONGODB_HOST: '${SUITE_OIDC_CONFORMANCE_MONGODB_HOST:-conformance-mongodb}'
|
||||
JAVA_EXTRA_ARGS: '-Dfintechlabs.devmode=true'
|
||||
OIDC_GOOGLE_CLIENTID: 'google-client'
|
||||
OIDC_GOOGLE_SECRET: 'google-secret'
|
||||
OIDC_GITLAB_CLIENTID: 'gitlab-client'
|
||||
OIDC_GITLAB_SECRET: 'gitlab-secret'
|
||||
depends_on:
|
||||
- 'authelia-backend'
|
||||
expose:
|
||||
- 8080
|
||||
networks:
|
||||
authelianet: {}
|
||||
|
||||
...
|
||||
@@ -0,0 +1,110 @@
|
||||
# SPDX-FileCopyrightText: 2026 Authelia
|
||||
#
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
---
|
||||
certificates_directory: '/certs/'
|
||||
|
||||
server:
|
||||
address: 'tcp://:9091'
|
||||
timeouts:
|
||||
read: '30s'
|
||||
tls:
|
||||
certificate: '/pki/public.backend.crt'
|
||||
key: '/pki/private.backend.pem'
|
||||
|
||||
log:
|
||||
level: 'debug'
|
||||
|
||||
storage:
|
||||
encryption_key: 'a_not_so_secure_encryption_key'
|
||||
postgres:
|
||||
address: 'tcp://postgres:5432'
|
||||
database: 'authelia'
|
||||
username: 'admin'
|
||||
password: 'password'
|
||||
|
||||
notifier:
|
||||
smtp:
|
||||
address: 'smtp://mail.example.com:1025'
|
||||
sender: 'admin@example.com'
|
||||
|
||||
identity_validation:
|
||||
reset_password:
|
||||
jwt_secret: 'unsecure_secret'
|
||||
|
||||
|
||||
# Every plan in this suite authenticates as the same user against this one Authelia, concurrently, and a module which
|
||||
# leaves the sign in form on screen has its credentials resubmitted. Three failures within the default two minute
|
||||
# window would ban the account for the default five minutes, failing unrelated modules in the other six plans with
|
||||
# errors which point nowhere near the cause. Regulation is not what this suite tests, so it is disabled outright.
|
||||
regulation:
|
||||
max_retries: 0
|
||||
|
||||
session:
|
||||
secret: 'unsecure_session_secret'
|
||||
cookies:
|
||||
- domain: 'example.com'
|
||||
authelia_url: 'https://login.example.com:8080'
|
||||
expiration: '1 hour'
|
||||
inactivity: '5 minutes'
|
||||
remember_me: '1 year'
|
||||
redis:
|
||||
host: 'redis'
|
||||
port: 6379
|
||||
|
||||
authentication_backend:
|
||||
file:
|
||||
path: '/config/users.yml'
|
||||
|
||||
totp:
|
||||
disable_reuse_security_policy: true
|
||||
|
||||
webauthn:
|
||||
disable: false
|
||||
enable_passkey_login: true
|
||||
display_name: 'Authelia'
|
||||
attestation_conveyance_preference: 'indirect'
|
||||
timeout: '60 seconds'
|
||||
filtering:
|
||||
permitted_aaguids: []
|
||||
prohibited_aaguids: []
|
||||
prohibit_backup_eligibility: false
|
||||
selection_criteria:
|
||||
attachment: ''
|
||||
discoverability: 'required'
|
||||
user_verification: 'preferred'
|
||||
metadata:
|
||||
enabled: false
|
||||
validate_trust_anchor: true
|
||||
validate_entry: false
|
||||
validate_entry_permit_zero_aaguid: true
|
||||
validate_status: true
|
||||
validate_status_permitted: []
|
||||
validate_status_prohibited: []
|
||||
|
||||
identity_providers:
|
||||
oidc:
|
||||
enable_client_debug_messages: true
|
||||
hmac_secret: 'IVPWBkAdJHje3uz7LtFTDU2pFUfh39Xm'
|
||||
jwks:
|
||||
- key: {{ secret "/pki/private.oidc.pem" | mindent 10 "|" | msquote }}
|
||||
certificate_chain: {{ secret "/pki/public.oidc.chain.pem" | mindent 10 "|" | msquote }}
|
||||
access_control:
|
||||
default_policy: 'deny'
|
||||
rules:
|
||||
- domain: 'home.example.com'
|
||||
policy: 'bypass'
|
||||
- domain: 'public.example.com'
|
||||
policy: 'bypass'
|
||||
- domain: 'admin.example.com'
|
||||
policy: 'two_factor'
|
||||
- domain: 'secure.example.com'
|
||||
policy: 'two_factor'
|
||||
- domain: 'singlefactor.example.com'
|
||||
policy: 'one_factor'
|
||||
- domain: 'oidc.example.com'
|
||||
policy: 'two_factor'
|
||||
- domain: 'oidc-public.example.com'
|
||||
policy: 'bypass'
|
||||
...
|
||||
@@ -0,0 +1,51 @@
|
||||
# SPDX-FileCopyrightText: 2026 Authelia
|
||||
#
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
---
|
||||
users:
|
||||
john:
|
||||
displayname: "John Doe"
|
||||
password: "$6$rounds=500000$jgiCMRyGXzoqpxS3$w2pJeZnnH8bwW3zzvoMWtTRfQYsHbWbD/hquuQ5vUeIyl9gdwBIt6RWk2S6afBA0DPakbeWgD/4SZPiS0hYtU/" # yamllint disable-line rule:line-length
|
||||
given_name: "John"
|
||||
middle_name: "Richard"
|
||||
family_name: "Doe"
|
||||
nickname: "Johnny"
|
||||
gender: "male"
|
||||
birthdate: "1985-07-31"
|
||||
website: "https://john.example.com"
|
||||
profile: "https://john.example.com/profile"
|
||||
picture: "https://john.example.com/picture.png"
|
||||
zoneinfo: "Australia/Melbourne"
|
||||
locale: "en-AU"
|
||||
phone_number: "+610000000000"
|
||||
phone_extension: "1234"
|
||||
email: john.doe@authelia.com
|
||||
address:
|
||||
street_address: "123 Example Street"
|
||||
locality: "Melbourne"
|
||||
region: "Victoria"
|
||||
postal_code: "3000"
|
||||
country: "Australia"
|
||||
groups:
|
||||
- admins
|
||||
- dev
|
||||
|
||||
harry:
|
||||
displayname: "Harry Potter"
|
||||
password: "$6$rounds=500000$jgiCMRyGXzoqpxS3$w2pJeZnnH8bwW3zzvoMWtTRfQYsHbWbD/hquuQ5vUeIyl9gdwBIt6RWk2S6afBA0DPakbeWgD/4SZPiS0hYtU/" # yamllint disable-line rule:line-length
|
||||
email: harry.potter@authelia.com
|
||||
groups: []
|
||||
|
||||
bob:
|
||||
displayname: "Bob Dylan"
|
||||
password: "$6$rounds=500000$jgiCMRyGXzoqpxS3$w2pJeZnnH8bwW3zzvoMWtTRfQYsHbWbD/hquuQ5vUeIyl9gdwBIt6RWk2S6afBA0DPakbeWgD/4SZPiS0hYtU/" # yamllint disable-line rule:line-length
|
||||
email: bob.dylan@authelia.com
|
||||
groups:
|
||||
- dev
|
||||
|
||||
james:
|
||||
displayname: "James Dean"
|
||||
password: "$6$rounds=500000$jgiCMRyGXzoqpxS3$w2pJeZnnH8bwW3zzvoMWtTRfQYsHbWbD/hquuQ5vUeIyl9gdwBIt6RWk2S6afBA0DPakbeWgD/4SZPiS0hYtU/" # yamllint disable-line rule:line-length
|
||||
email: james.dean@authelia.com
|
||||
...
|
||||
@@ -0,0 +1,439 @@
|
||||
// SPDX-FileCopyrightText: 2026 Authelia
|
||||
//
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package suites
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/authelia/authelia/v4/internal/oidc/conformance"
|
||||
"github.com/authelia/authelia/v4/internal/utils"
|
||||
)
|
||||
|
||||
const (
|
||||
conformanceWaitStateTimeout = time.Second * 30
|
||||
conformanceReadyInterval = time.Second * 2
|
||||
conformanceWaitStateRetryInterval = time.Millisecond * 500
|
||||
)
|
||||
|
||||
// ConformancePlanModule is one entry in a conformance test plan's module list.
|
||||
type ConformancePlanModule struct {
|
||||
TestModule string `json:"testModule"`
|
||||
Variant map[string]string `json:"variant"`
|
||||
}
|
||||
|
||||
// ConformanceCreatedPlan is the response to creating a conformance test plan.
|
||||
type ConformanceCreatedPlan struct {
|
||||
ID string `json:"id"`
|
||||
Modules []ConformancePlanModule `json:"modules"`
|
||||
}
|
||||
|
||||
// ConformanceTestInfo is the persisted state of a single conformance test module instance.
|
||||
type ConformanceTestInfo struct {
|
||||
ID string `json:"_id"`
|
||||
TestName string `json:"testName"`
|
||||
Status string `json:"status"`
|
||||
Result string `json:"result"`
|
||||
}
|
||||
|
||||
// ConformanceBrowserStatus describes the URLs a module is waiting for a browser to visit.
|
||||
type ConformanceBrowserStatus struct {
|
||||
URLs []string `json:"urls"`
|
||||
Visited []string `json:"visited"`
|
||||
}
|
||||
|
||||
// ConformanceLogEntry is one entry of a module's log. An entry with a non-empty Upload is an image placeholder which
|
||||
// has not been filled, and which is holding the module in WAITING.
|
||||
type ConformanceLogEntry struct {
|
||||
Msg string `json:"msg"`
|
||||
Result string `json:"result"`
|
||||
Src string `json:"src"`
|
||||
Upload string `json:"upload"`
|
||||
|
||||
Fields map[string]any `json:"-"`
|
||||
}
|
||||
|
||||
// UnmarshalJSON decodes the typed fields and retains the entry whole.
|
||||
func (e *ConformanceLogEntry) UnmarshalJSON(data []byte) (err error) {
|
||||
type entry ConformanceLogEntry
|
||||
|
||||
if err = json.Unmarshal(data, (*entry)(e)); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return json.Unmarshal(data, &e.Fields)
|
||||
}
|
||||
|
||||
// ConformanceClient talks to the OpenID Foundation conformance suite's HTTP API. The suite runs with
|
||||
// fintechlabs.devmode enabled, which disables its OAuth login, so no credentials are sent.
|
||||
type ConformanceClient struct {
|
||||
base *url.URL
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
// NewConformanceClient returns a ConformanceClient for the conformance suite at base.
|
||||
func NewConformanceClient(base string) (client *ConformanceClient, err error) {
|
||||
var parsed *url.URL
|
||||
|
||||
if parsed, err = url.ParseRequestURI(base); err != nil {
|
||||
return nil, fmt.Errorf("error parsing conformance base url: %w", err)
|
||||
}
|
||||
|
||||
return &ConformanceClient{
|
||||
base: parsed,
|
||||
client: &http.Client{
|
||||
Transport: NewHTTPTransport(),
|
||||
Timeout: conformanceWaitStateTimeout + time.Second*10,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (c *ConformanceClient) uri(query url.Values, elem ...string) string {
|
||||
uri := c.base.JoinPath(append([]string{"api"}, elem...)...)
|
||||
|
||||
if query != nil {
|
||||
uri.RawQuery = query.Encode()
|
||||
}
|
||||
|
||||
return uri.String()
|
||||
}
|
||||
|
||||
// LogDetailURL returns the human readable log page for a module, for inclusion in failure output.
|
||||
func (c *ConformanceClient) LogDetailURL(id string) string {
|
||||
uri := c.base.JoinPath("log-detail.html")
|
||||
|
||||
query := uri.Query()
|
||||
query.Set("log", id)
|
||||
uri.RawQuery = query.Encode()
|
||||
|
||||
return uri.String()
|
||||
}
|
||||
|
||||
type conformanceUnexpectedStatusError struct {
|
||||
Method string
|
||||
URI string
|
||||
Body []byte
|
||||
Expected int
|
||||
Status int
|
||||
}
|
||||
|
||||
// Error implements the error interface.
|
||||
func (e *conformanceUnexpectedStatusError) Error() string {
|
||||
return fmt.Sprintf("%s %s: expected status %d but got %d: %s", e.Method, e.URI, e.Expected, e.Status, e.Body)
|
||||
}
|
||||
|
||||
func (c *ConformanceClient) do(ctx context.Context, method, uri string, body io.Reader, contentType string, expected int, out any) (err error) {
|
||||
data, err := c.doRaw(ctx, method, uri, body, contentType, expected)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if out == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
return json.Unmarshal(data, out)
|
||||
}
|
||||
|
||||
func (c *ConformanceClient) doRaw(ctx context.Context, method, uri string, body io.Reader, contentType string, expected int) (data []byte, err error) {
|
||||
var req *http.Request
|
||||
|
||||
if req, err = http.NewRequestWithContext(ctx, method, uri, body); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if contentType != "" {
|
||||
req.Header.Set("Content-Type", contentType)
|
||||
}
|
||||
|
||||
var resp *http.Response
|
||||
|
||||
if resp, err = c.client.Do(req); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
defer resp.Body.Close()
|
||||
|
||||
if data, err = io.ReadAll(resp.Body); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if resp.StatusCode != expected {
|
||||
return nil, &conformanceUnexpectedStatusError{Method: method, URI: uri, Body: data, Expected: expected, Status: resp.StatusCode}
|
||||
}
|
||||
|
||||
return data, nil
|
||||
}
|
||||
|
||||
// WaitReady blocks until the conformance server answers an API request or ctx expires. The JVM binds its port well
|
||||
// before Spring has finished starting, so a connection check is not sufficient.
|
||||
func (c *ConformanceClient) WaitReady(ctx context.Context) (err error) {
|
||||
query := url.Values{}
|
||||
query.Set("length", "1")
|
||||
|
||||
for {
|
||||
if err = c.do(ctx, http.MethodGet, c.uri(query, "plan"), nil, "", http.StatusOK, nil); err == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return fmt.Errorf("conformance suite was not ready: %w (last error: %v)", ctx.Err(), err)
|
||||
case <-time.After(conformanceReadyInterval):
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// CreatePlan creates a conformance test plan and returns its id and module list. The plan name and the variant are
|
||||
// parameters rather than being read off plan because [conformance.Plan] deliberately excludes both from its JSON
|
||||
// representation: they belong in the query string, not the body, and the caller is the one which knows them.
|
||||
func (c *ConformanceClient) CreatePlan(ctx context.Context, name string, variant *conformance.PlanVariant, plan *conformance.Plan) (created *ConformanceCreatedPlan, err error) {
|
||||
if name == "" {
|
||||
return nil, fmt.Errorf("error creating plan '%s': the conformance plan name is empty", plan.Alias)
|
||||
}
|
||||
|
||||
body, err := json.Marshal(plan)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
query := url.Values{}
|
||||
query.Set("planName", name)
|
||||
|
||||
if variant != nil {
|
||||
var data []byte
|
||||
|
||||
if data, err = json.Marshal(variant); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
query.Set("variant", string(data))
|
||||
}
|
||||
|
||||
created = &ConformanceCreatedPlan{}
|
||||
|
||||
if err = c.do(ctx, http.MethodPost, c.uri(query, "plan"), bytes.NewReader(body), "application/json", http.StatusCreated, created); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if created.ID == "" {
|
||||
return nil, fmt.Errorf("error creating plan '%s': the conformance suite returned no plan identifier", plan.Alias)
|
||||
}
|
||||
|
||||
return created, nil
|
||||
}
|
||||
|
||||
// CreateTest creates an instance of a plan's module and returns its id. The variant is sent alongside the plan even
|
||||
// though the endpoint documents it as applying to standalone tests: the upstream scripts/conformance.py sends both
|
||||
// together for plan modules, and it is what makes plans which repeat a module across variants work.
|
||||
func (c *ConformanceClient) CreateTest(ctx context.Context, planID, module string, variant map[string]string) (id string, err error) {
|
||||
query := url.Values{}
|
||||
query.Set("test", module)
|
||||
query.Set("plan", planID)
|
||||
|
||||
if len(variant) != 0 {
|
||||
var data []byte
|
||||
|
||||
if data, err = json.Marshal(variant); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
query.Set("variant", string(data))
|
||||
}
|
||||
|
||||
out := struct {
|
||||
ID string `json:"id"`
|
||||
}{}
|
||||
|
||||
if err = c.do(ctx, http.MethodPost, c.uri(query, "runner"), nil, "application/json", http.StatusCreated, &out); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if out.ID == "" {
|
||||
return "", fmt.Errorf("error creating module '%s': the conformance suite returned no test identifier", module)
|
||||
}
|
||||
|
||||
return out.ID, nil
|
||||
}
|
||||
|
||||
// StartTest starts a module which is waiting in the CONFIGURED state.
|
||||
func (c *ConformanceClient) StartTest(ctx context.Context, id string) error {
|
||||
return c.do(ctx, http.MethodPost, c.uri(nil, "runner", id), nil, "", http.StatusOK, nil)
|
||||
}
|
||||
|
||||
// WaitState long polls until the module reaches one of states, or ctx expires. The server clamps each poll to 30
|
||||
// seconds and answers {"timeout":true} when it expires, so this loops. Once a module finishes it leaves the running
|
||||
// registry and the endpoint answers 404; the persisted status from Info is authoritative at that point. A transport
|
||||
// level failure - a 5xx response, a network error - is treated as transient and retried until ctx expires, so a
|
||||
// single blip during a long unattended run does not fail the poll outright. Any other status will not change on retry,
|
||||
// so it fails immediately.
|
||||
//
|
||||
// A 200 whose body is not the documented shape is not retried. A body which does not decode at all, and a body which
|
||||
// decodes but carries neither a state nor the timeout flag - an empty body, a proxy's own 200 page, a schema change
|
||||
// on a suite version bump - are both permanent for as long as the run lasts, so retrying either would only spin
|
||||
// against the server for the rest of the module budget from every plan at once. Both fail immediately with the body
|
||||
// echoed, which is also the only way the reason for a schema change ever reaches the run's output.
|
||||
func (c *ConformanceClient) WaitState(ctx context.Context, id string, states ...string) (state string, err error) {
|
||||
query := url.Values{}
|
||||
query.Set("states", strings.Join(states, ","))
|
||||
query.Set("timeoutMs", strconv.FormatInt(conformanceWaitStateTimeout.Milliseconds(), 10))
|
||||
|
||||
for {
|
||||
data, err := c.doRaw(ctx, http.MethodGet, c.uri(query, "runner", id, "wait-state"), nil, "", http.StatusOK)
|
||||
if err != nil {
|
||||
if ctx.Err() != nil {
|
||||
return "", fmt.Errorf("module '%s' did not reach one of %v: %w", id, states, ctx.Err())
|
||||
}
|
||||
|
||||
var statusErr *conformanceUnexpectedStatusError
|
||||
|
||||
if errors.As(err, &statusErr) {
|
||||
switch {
|
||||
case statusErr.Status == http.StatusNotFound:
|
||||
return c.waitStateFromInfo(ctx, id, states)
|
||||
case statusErr.Status < http.StatusInternalServerError:
|
||||
return "", fmt.Errorf("module '%s' did not reach one of %v: %w", id, states, err)
|
||||
}
|
||||
}
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return "", fmt.Errorf("module '%s' did not reach one of %v: %w", id, states, ctx.Err())
|
||||
case <-time.After(conformanceWaitStateRetryInterval):
|
||||
}
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
out := struct {
|
||||
State string `json:"state"`
|
||||
Timeout bool `json:"timeout"`
|
||||
}{}
|
||||
|
||||
if err = json.Unmarshal(data, &out); err != nil {
|
||||
return "", fmt.Errorf("module '%s' wait-state poll answered with a body which is not JSON: %w: %s", id, err, data)
|
||||
}
|
||||
|
||||
switch {
|
||||
case out.State != "":
|
||||
return out.State, nil
|
||||
case out.Timeout:
|
||||
// The poll expired without a transition. Fall through to the context check and poll again.
|
||||
default:
|
||||
return "", fmt.Errorf("module '%s' wait-state poll answered with neither a state nor a timeout: %s", id, data)
|
||||
}
|
||||
|
||||
if ctx.Err() != nil {
|
||||
return "", fmt.Errorf("module '%s' did not reach one of %v: %w", id, states, ctx.Err())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (c *ConformanceClient) waitStateFromInfo(ctx context.Context, id string, states []string) (state string, err error) {
|
||||
var info *ConformanceTestInfo
|
||||
|
||||
if info, err = c.Info(ctx, id); err != nil {
|
||||
if ctx.Err() != nil {
|
||||
return "", fmt.Errorf("module '%s' did not reach one of %v: %w", id, states, ctx.Err())
|
||||
}
|
||||
|
||||
return "", err
|
||||
}
|
||||
|
||||
if utils.IsStringInSlice(info.Status, states) {
|
||||
return info.Status, nil
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("module '%s' is no longer running and its persisted status is '%s', which is not one of %v", id, info.Status, states)
|
||||
}
|
||||
|
||||
// Info returns the persisted status and result of a module.
|
||||
func (c *ConformanceClient) Info(ctx context.Context, id string) (info *ConformanceTestInfo, err error) {
|
||||
info = &ConformanceTestInfo{}
|
||||
|
||||
if err = c.do(ctx, http.MethodGet, c.uri(nil, "info", id), nil, "", http.StatusOK, info); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return info, nil
|
||||
}
|
||||
|
||||
// BrowserStatus returns the URLs a module is waiting to have visited.
|
||||
func (c *ConformanceClient) BrowserStatus(ctx context.Context, id string) (status *ConformanceBrowserStatus, err error) {
|
||||
status = &ConformanceBrowserStatus{}
|
||||
|
||||
if err = c.do(ctx, http.MethodGet, c.uri(nil, "runner", "browser", id), nil, "", http.StatusOK, status); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return status, nil
|
||||
}
|
||||
|
||||
// Visit marks a URL as visited. The server matches by exact string equality, so uri must be the URL as it was given.
|
||||
func (c *ConformanceClient) Visit(ctx context.Context, id, uri string) error {
|
||||
query := url.Values{}
|
||||
query.Set("url", uri)
|
||||
|
||||
return c.do(ctx, http.MethodPost, c.uri(query, "runner", "browser", id, "visit"), nil, "", http.StatusNoContent, nil)
|
||||
}
|
||||
|
||||
// Log returns a module's log entries.
|
||||
func (c *ConformanceClient) Log(ctx context.Context, id string) (entries []ConformanceLogEntry, err error) {
|
||||
if err = c.do(ctx, http.MethodGet, c.uri(nil, "log", id), nil, "", http.StatusOK, &entries); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return entries, nil
|
||||
}
|
||||
|
||||
// UploadPlaceholder fills an image placeholder, releasing a module which is blocked in waitForPlaceholders().
|
||||
func (c *ConformanceClient) UploadPlaceholder(ctx context.Context, id, placeholder, dataURI string) error {
|
||||
return c.do(ctx, http.MethodPost, c.uri(nil, "log", id, "images", placeholder), bytes.NewReader([]byte(dataURI)), "text/plain", http.StatusOK, nil)
|
||||
}
|
||||
|
||||
// ExportPlanHTML writes a plan's HTML export to path, for collection as a CI artifact.
|
||||
func (c *ConformanceClient) ExportPlanHTML(ctx context.Context, planID, path string) (err error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.uri(nil, "plan", "exporthtml", planID), nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
resp, err := c.client.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("unexpected status %d exporting plan '%s'", resp.StatusCode, planID)
|
||||
}
|
||||
|
||||
if err = os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
f, err := os.Create(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
defer f.Close()
|
||||
|
||||
_, err = io.Copy(f, resp.Body)
|
||||
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,651 @@
|
||||
// SPDX-FileCopyrightText: 2026 Authelia
|
||||
//
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package suites
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/authelia/authelia/v4/internal/oidc/conformance"
|
||||
)
|
||||
|
||||
const conformanceTestScreenshot = "data:image/png;base64,iVBORw0KGgo="
|
||||
|
||||
func TestConformanceClient_CreatePlan(t *testing.T) {
|
||||
var query string
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, "/api/plan", r.URL.Path)
|
||||
|
||||
query = r.URL.RawQuery
|
||||
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
_, _ = w.Write([]byte(`{"name":"oidcc-basic-certification-test-plan","id":"plan1","modules":[{"testModule":"oidcc-server","variant":{"response_type":"code"}}]}`))
|
||||
}))
|
||||
|
||||
defer server.Close()
|
||||
|
||||
client, err := NewConformanceClient(server.URL)
|
||||
require.NoError(t, err)
|
||||
|
||||
plan := &conformance.Plan{Alias: "alias"}
|
||||
|
||||
created, err := client.CreatePlan(context.Background(), "oidcc-basic-certification-test-plan", &conformance.PlanVariant{ServerMetadata: "discovery"}, plan)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, "plan1", created.ID)
|
||||
require.Len(t, created.Modules, 1)
|
||||
assert.Equal(t, "oidcc-server", created.Modules[0].TestModule)
|
||||
assert.Equal(t, "code", created.Modules[0].Variant["response_type"])
|
||||
assert.Contains(t, query, "planName=oidcc-basic-certification-test-plan")
|
||||
assert.Contains(t, query, "variant=")
|
||||
}
|
||||
|
||||
func TestConformanceClient_CreatePlanRejectsAResponseWithoutAnIdentifier(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
_, _ = w.Write([]byte(`{"name":"oidcc-basic-certification-test-plan","modules":[{"testModule":"oidcc-server"}]}`))
|
||||
}))
|
||||
|
||||
defer server.Close()
|
||||
|
||||
client, err := NewConformanceClient(server.URL)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = client.CreatePlan(context.Background(), "oidcc-basic-certification-test-plan", nil, &conformance.Plan{Alias: "the-alias"})
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "the-alias")
|
||||
assert.Contains(t, err.Error(), "returned no plan identifier")
|
||||
}
|
||||
|
||||
func TestConformanceClient_WaitStateReturnsPersistedStatusWhenTestNoLongerRunning(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/api/runner/abc/wait-state":
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
_, _ = w.Write([]byte(`{"error":"test not found"}`))
|
||||
case "/api/info/abc":
|
||||
_, _ = w.Write([]byte(`{"_id":"abc","testName":"oidcc-server","status":"FINISHED","result":"PASSED"}`))
|
||||
default:
|
||||
t.Errorf("unexpected path %s", r.URL.Path)
|
||||
}
|
||||
}))
|
||||
|
||||
defer server.Close()
|
||||
|
||||
client, err := NewConformanceClient(server.URL)
|
||||
require.NoError(t, err)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Second*5)
|
||||
defer cancel()
|
||||
|
||||
state, err := client.WaitState(ctx, "abc", "FINISHED")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "FINISHED", state)
|
||||
}
|
||||
|
||||
func TestConformanceClient_WaitStateRetriesOnTimeout(t *testing.T) {
|
||||
var calls int
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, "/api/runner/abc/wait-state", r.URL.Path)
|
||||
|
||||
calls++
|
||||
|
||||
if calls == 1 {
|
||||
_, _ = w.Write([]byte(`{"timeout":true}`))
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
_, _ = w.Write([]byte(`{"state":"WAITING"}`))
|
||||
}))
|
||||
|
||||
defer server.Close()
|
||||
|
||||
client, err := NewConformanceClient(server.URL)
|
||||
require.NoError(t, err)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Second*5)
|
||||
defer cancel()
|
||||
|
||||
state, err := client.WaitState(ctx, "abc", "WAITING")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "WAITING", state)
|
||||
assert.Equal(t, 2, calls)
|
||||
}
|
||||
|
||||
func TestConformanceClient_WaitStateRetriesOnTransientError(t *testing.T) {
|
||||
var calls int
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/api/runner/abc/wait-state":
|
||||
calls++
|
||||
|
||||
if calls == 1 {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
_, _ = w.Write([]byte(`{"error":"internal error"}`))
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
_, _ = w.Write([]byte(`{"state":"FINISHED"}`))
|
||||
case "/api/info/abc":
|
||||
t.Errorf("unexpected call to %s: a transient 5xx must not fall back to Info", r.URL.Path)
|
||||
default:
|
||||
t.Errorf("unexpected path %s", r.URL.Path)
|
||||
}
|
||||
}))
|
||||
|
||||
defer server.Close()
|
||||
|
||||
client, err := NewConformanceClient(server.URL)
|
||||
require.NoError(t, err)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Second*5)
|
||||
defer cancel()
|
||||
|
||||
state, err := client.WaitState(ctx, "abc", "FINISHED")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "FINISHED", state)
|
||||
assert.Equal(t, 2, calls)
|
||||
}
|
||||
|
||||
func TestConformanceClient_WaitStateReturnsFramedErrorOnPersistentTransientFailure(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, "/api/runner/abc/wait-state", r.URL.Path)
|
||||
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
_, _ = w.Write([]byte(`{"error":"internal error"}`))
|
||||
}))
|
||||
|
||||
defer server.Close()
|
||||
|
||||
client, err := NewConformanceClient(server.URL)
|
||||
require.NoError(t, err)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Millisecond*750)
|
||||
defer cancel()
|
||||
|
||||
start := time.Now()
|
||||
|
||||
_, err = client.WaitState(ctx, "abc", "FINISHED")
|
||||
|
||||
elapsed := time.Since(start)
|
||||
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "abc")
|
||||
assert.Contains(t, err.Error(), "FINISHED")
|
||||
assert.Less(t, elapsed, time.Second*5, "WaitState should return promptly once ctx expires rather than hang")
|
||||
}
|
||||
|
||||
func TestConformanceClient_CreateTestReportsAliasCollision(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusConflict)
|
||||
_, _ = w.Write([]byte(`{"error":"alias already in use"}`))
|
||||
}))
|
||||
|
||||
defer server.Close()
|
||||
|
||||
client, err := NewConformanceClient(server.URL)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = client.CreateTest(context.Background(), "plan1", "oidcc-server", nil)
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "alias already in use")
|
||||
}
|
||||
|
||||
func TestConformanceClient_CreateTestRejectsAResponseWithoutAnIdentifier(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
_, _ = w.Write([]byte(`{"name":"oidcc-server"}`))
|
||||
}))
|
||||
|
||||
defer server.Close()
|
||||
|
||||
client, err := NewConformanceClient(server.URL)
|
||||
require.NoError(t, err)
|
||||
|
||||
id, err := client.CreateTest(context.Background(), "plan1", "oidcc-server", nil)
|
||||
require.Error(t, err)
|
||||
assert.Empty(t, id)
|
||||
assert.Contains(t, err.Error(), "oidcc-server")
|
||||
assert.Contains(t, err.Error(), "no test identifier")
|
||||
}
|
||||
|
||||
func TestConformanceClient_WaitStateFailsFastOnAClientError(t *testing.T) {
|
||||
for _, status := range []int{http.StatusBadRequest, http.StatusUnauthorized, http.StatusForbidden} {
|
||||
t.Run(http.StatusText(status), func(t *testing.T) {
|
||||
var calls atomic.Int32
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
calls.Add(1)
|
||||
w.WriteHeader(status)
|
||||
}))
|
||||
|
||||
defer server.Close()
|
||||
|
||||
client, err := NewConformanceClient(server.URL)
|
||||
require.NoError(t, err)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Second*10)
|
||||
defer cancel()
|
||||
|
||||
_, err = client.WaitState(ctx, "m1", "FINISHED")
|
||||
require.Error(t, err)
|
||||
require.NoError(t, ctx.Err(), "a client error is not going to change on retry, so it must not wait out the context")
|
||||
|
||||
var statusErr *conformanceUnexpectedStatusError
|
||||
|
||||
require.ErrorAs(t, err, &statusErr)
|
||||
assert.Equal(t, status, statusErr.Status)
|
||||
assert.Contains(t, err.Error(), "module 'm1'")
|
||||
assert.Equal(t, int32(1), calls.Load())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestConformanceClient_LogExposesUnfilledPlaceholders(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, "/api/log/abc", r.URL.Path)
|
||||
|
||||
_, _ = w.Write([]byte(`[{"msg":"a"},{"msg":"upload me","upload":"ph1","result":"REVIEW"}]`))
|
||||
}))
|
||||
|
||||
defer server.Close()
|
||||
|
||||
client, err := NewConformanceClient(server.URL)
|
||||
require.NoError(t, err)
|
||||
|
||||
entries, err := client.Log(context.Background(), "abc")
|
||||
require.NoError(t, err)
|
||||
require.Len(t, entries, 2)
|
||||
assert.Equal(t, "", entries[0].Upload)
|
||||
assert.Equal(t, "ph1", entries[1].Upload)
|
||||
}
|
||||
|
||||
func TestConformanceClient_UploadPlaceholder(t *testing.T) {
|
||||
var body []byte
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, "/api/log/abc/images/ph1", r.URL.Path)
|
||||
|
||||
body, _ = io.ReadAll(r.Body)
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte(`{}`))
|
||||
}))
|
||||
|
||||
defer server.Close()
|
||||
|
||||
client, err := NewConformanceClient(server.URL)
|
||||
require.NoError(t, err)
|
||||
|
||||
require.NoError(t, client.UploadPlaceholder(context.Background(), "abc", "ph1", conformanceTestScreenshot))
|
||||
assert.Equal(t, conformanceTestScreenshot, string(body))
|
||||
}
|
||||
|
||||
func TestConformanceClient_CreatePlanRejectsAnEmptyPlanName(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
t.Errorf("the client must not post a nameless plan, got %s", r.URL.String())
|
||||
}))
|
||||
|
||||
defer server.Close()
|
||||
|
||||
client, err := NewConformanceClient(server.URL)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = client.CreatePlan(context.Background(), "", nil, &conformance.Plan{Alias: "alias"})
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "alias")
|
||||
assert.Contains(t, err.Error(), "plan name is empty")
|
||||
}
|
||||
|
||||
func TestConformanceClient_WaitStateFailsFastOnAnUnrecognizedBody(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
body string
|
||||
expected string
|
||||
}{
|
||||
{"ShouldFailOnAnEmptyJSONObject", `{}`, "neither a state nor a timeout"},
|
||||
{"ShouldFailOnAFalseTimeout", `{"timeout":false}`, "neither a state nor a timeout"},
|
||||
{"ShouldFailOnASchemaChange", `{"status":"FINISHED"}`, "neither a state nor a timeout"},
|
||||
{"ShouldFailOnAMalformedBody", `<html>gateway</html>`, "is not JSON"},
|
||||
{"ShouldFailOnAnEmptyBody", ``, "is not JSON"},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
var calls int
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, "/api/runner/abc/wait-state", r.URL.Path)
|
||||
|
||||
calls++
|
||||
|
||||
_, _ = w.Write([]byte(tc.body))
|
||||
}))
|
||||
|
||||
defer server.Close()
|
||||
|
||||
client, err := NewConformanceClient(server.URL)
|
||||
require.NoError(t, err)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Second*5)
|
||||
defer cancel()
|
||||
|
||||
start := time.Now()
|
||||
|
||||
_, err = client.WaitState(ctx, "abc", "FINISHED")
|
||||
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), tc.expected)
|
||||
assert.Contains(t, err.Error(), "abc")
|
||||
assert.Equal(t, 1, calls, "an unrecognized body must not be retried")
|
||||
assert.Less(t, time.Since(start), time.Second, "an unrecognized body must fail fast rather than spin")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewConformanceClientRejectsAnInvalidURL(t *testing.T) {
|
||||
_, err := NewConformanceClient("not a url")
|
||||
|
||||
assert.ErrorContains(t, err, "error parsing conformance base url")
|
||||
}
|
||||
|
||||
func TestConformanceClient_WaitReady(t *testing.T) {
|
||||
t.Run("ShouldReturnOnceTheServerAnswers", func(t *testing.T) {
|
||||
var calls atomic.Int32
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, "/api/plan", r.URL.Path)
|
||||
assert.Equal(t, "1", r.URL.Query().Get("length"))
|
||||
|
||||
if calls.Add(1) == 1 {
|
||||
w.WriteHeader(http.StatusServiceUnavailable)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
_, _ = w.Write([]byte(`{"data":[]}`))
|
||||
}))
|
||||
|
||||
defer server.Close()
|
||||
|
||||
client, err := NewConformanceClient(server.URL)
|
||||
require.NoError(t, err)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Second*10)
|
||||
defer cancel()
|
||||
|
||||
require.NoError(t, client.WaitReady(ctx))
|
||||
assert.Equal(t, int32(2), calls.Load(), "the server is asked again after it could not answer")
|
||||
})
|
||||
|
||||
t.Run("ShouldGiveUpWhenTheContextExpires", func(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusServiceUnavailable)
|
||||
}))
|
||||
|
||||
defer server.Close()
|
||||
|
||||
client, err := NewConformanceClient(server.URL)
|
||||
require.NoError(t, err)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Millisecond*100)
|
||||
defer cancel()
|
||||
|
||||
err = client.WaitReady(ctx)
|
||||
|
||||
assert.ErrorContains(t, err, "conformance suite was not ready")
|
||||
assert.ErrorContains(t, err, "expected status 200 but got 503")
|
||||
})
|
||||
}
|
||||
|
||||
func TestConformanceClient_Requests(t *testing.T) {
|
||||
var (
|
||||
mu sync.Mutex
|
||||
requests []string
|
||||
)
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
mu.Lock()
|
||||
|
||||
requests = append(requests, r.Method+" "+r.URL.RequestURI())
|
||||
mu.Unlock()
|
||||
|
||||
switch r.URL.Path {
|
||||
case "/api/runner":
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
_, _ = w.Write([]byte(`{"id":"m1"}`))
|
||||
case "/api/runner/m1":
|
||||
_, _ = w.Write([]byte(`{}`))
|
||||
case "/api/runner/browser/m1/visit":
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
case "/api/plan/exporthtml/p1":
|
||||
_, _ = w.Write([]byte("PK archive"))
|
||||
default:
|
||||
t.Errorf("unexpected path %s", r.URL.Path)
|
||||
}
|
||||
}))
|
||||
|
||||
defer server.Close()
|
||||
|
||||
client, err := NewConformanceClient(server.URL)
|
||||
require.NoError(t, err)
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
id, err := client.CreateTest(ctx, "p1", "oidcc-server", map[string]string{"response_type": "code"})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "m1", id)
|
||||
|
||||
require.NoError(t, client.StartTest(ctx, "m1"))
|
||||
require.NoError(t, client.Visit(ctx, "m1", "https://login.example.com:8080/api/oidc/authorization?client_id=a&state=b"))
|
||||
|
||||
archive := filepath.Join(t.TempDir(), "plans", "conformance-basic.zip")
|
||||
|
||||
require.NoError(t, client.ExportPlanHTML(ctx, "p1", archive))
|
||||
|
||||
data, err := os.ReadFile(archive)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "PK archive", string(data))
|
||||
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
|
||||
assert.Equal(t, []string{
|
||||
"POST /api/runner?plan=p1&test=oidcc-server&variant=%7B%22response_type%22%3A%22code%22%7D",
|
||||
"POST /api/runner/m1",
|
||||
"POST /api/runner/browser/m1/visit?url=https%3A%2F%2Flogin.example.com%3A8080%2Fapi%2Foidc%2Fauthorization%3Fclient_id%3Da%26state%3Db",
|
||||
"GET /api/plan/exporthtml/p1",
|
||||
}, requests)
|
||||
}
|
||||
|
||||
func TestConformanceClient_ReportsServerErrors(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
_, _ = w.Write([]byte(`{"error":"broken"}`))
|
||||
}))
|
||||
|
||||
defer server.Close()
|
||||
|
||||
client, err := NewConformanceClient(server.URL)
|
||||
require.NoError(t, err)
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
_, err = client.CreatePlan(ctx, "oidcc-basic-certification-test-plan", nil, &conformance.Plan{Alias: "alias"})
|
||||
assert.ErrorContains(t, err, "broken")
|
||||
|
||||
_, err = client.Info(ctx, "m1")
|
||||
assert.ErrorContains(t, err, "broken")
|
||||
|
||||
_, err = client.BrowserStatus(ctx, "m1")
|
||||
assert.ErrorContains(t, err, "broken")
|
||||
|
||||
_, err = client.Log(ctx, "m1")
|
||||
assert.ErrorContains(t, err, "broken")
|
||||
|
||||
err = client.ExportPlanHTML(ctx, "p1", filepath.Join(t.TempDir(), "conformance-basic.zip"))
|
||||
assert.EqualError(t, err, "unexpected status 500 exporting plan 'p1'")
|
||||
}
|
||||
|
||||
func TestConformanceClient_ExportPlanHTMLReportsADirectoryItCannotCreate(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = w.Write([]byte("PK archive"))
|
||||
}))
|
||||
|
||||
defer server.Close()
|
||||
|
||||
client, err := NewConformanceClient(server.URL)
|
||||
require.NoError(t, err)
|
||||
|
||||
file := filepath.Join(t.TempDir(), "file")
|
||||
require.NoError(t, os.WriteFile(file, nil, 0600))
|
||||
|
||||
assert.Error(t, client.ExportPlanHTML(context.Background(), "p1", filepath.Join(file, "conformance-basic.zip")))
|
||||
}
|
||||
|
||||
func TestConformanceClient_LogRejectsAMalformedEntry(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = w.Write([]byte(`[{"msg":5}]`))
|
||||
}))
|
||||
|
||||
defer server.Close()
|
||||
|
||||
client, err := NewConformanceClient(server.URL)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = client.Log(context.Background(), "m1")
|
||||
assert.Error(t, err)
|
||||
}
|
||||
|
||||
func TestConformanceClient_WaitStateFromInfo(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
info func(w http.ResponseWriter)
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
"ShouldReportAPersistedStatusWhichIsNotWaitedFor",
|
||||
func(w http.ResponseWriter) { _, _ = w.Write([]byte(`{"_id":"m1","status":"INTERRUPTED"}`)) },
|
||||
"module 'm1' is no longer running and its persisted status is 'INTERRUPTED', which is not one of [FINISHED]",
|
||||
},
|
||||
{
|
||||
"ShouldReportThatThePersistedStatusCouldNotBeRead",
|
||||
func(w http.ResponseWriter) { w.WriteHeader(http.StatusInternalServerError) },
|
||||
"expected status 200 but got 500",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/api/runner/m1/wait-state":
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
case "/api/info/m1":
|
||||
tc.info(w)
|
||||
default:
|
||||
t.Errorf("unexpected path %s", r.URL.Path)
|
||||
}
|
||||
}))
|
||||
|
||||
defer server.Close()
|
||||
|
||||
client, err := NewConformanceClient(server.URL)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = client.WaitState(context.Background(), "m1", "FINISHED")
|
||||
|
||||
assert.ErrorContains(t, err, tc.expected)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestConformanceClient_WaitStateStopsWhenTheContextExpiresBetweenPolls(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
time.Sleep(time.Millisecond * 20)
|
||||
|
||||
_, _ = w.Write([]byte(`{"timeout":true}`))
|
||||
}))
|
||||
|
||||
defer server.Close()
|
||||
|
||||
client, err := NewConformanceClient(server.URL)
|
||||
require.NoError(t, err)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Millisecond*100)
|
||||
defer cancel()
|
||||
|
||||
_, err = client.WaitState(ctx, "m1", "FINISHED")
|
||||
|
||||
assert.ErrorContains(t, err, "module 'm1' did not reach one of [FINISHED]")
|
||||
}
|
||||
|
||||
func TestConformanceClient_ExportPlanHTMLReportsWhatFailed(t *testing.T) {
|
||||
t.Run("ShouldReportAServerWhichCannotBeReached", func(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}))
|
||||
|
||||
client, err := NewConformanceClient(server.URL)
|
||||
require.NoError(t, err)
|
||||
|
||||
server.Close()
|
||||
|
||||
assert.Error(t, client.ExportPlanHTML(context.Background(), "p1", filepath.Join(t.TempDir(), "conformance-basic.zip")))
|
||||
})
|
||||
|
||||
t.Run("ShouldReportAnArchiveWhichCannotBeCreated", func(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = w.Write([]byte("PK archive"))
|
||||
}))
|
||||
|
||||
defer server.Close()
|
||||
|
||||
client, err := NewConformanceClient(server.URL)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Error(t, client.ExportPlanHTML(context.Background(), "p1", t.TempDir()), "the path is a directory")
|
||||
})
|
||||
}
|
||||
|
||||
func TestConformanceClient_WaitStateFromInfoStopsWhenTheContextExpires(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == "/api/runner/m1/wait-state" {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
time.Sleep(time.Millisecond * 200)
|
||||
|
||||
_, _ = w.Write([]byte(`{"_id":"m1","status":"FINISHED"}`))
|
||||
}))
|
||||
|
||||
defer server.Close()
|
||||
|
||||
client, err := NewConformanceClient(server.URL)
|
||||
require.NoError(t, err)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Millisecond*100)
|
||||
defer cancel()
|
||||
|
||||
_, err = client.WaitState(ctx, "m1", "FINISHED")
|
||||
|
||||
assert.ErrorContains(t, err, "module 'm1' did not reach one of [FINISHED]")
|
||||
}
|
||||
@@ -0,0 +1,556 @@
|
||||
// SPDX-FileCopyrightText: 2026 Authelia
|
||||
//
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package suites
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/go-rod/rod"
|
||||
"github.com/go-rod/rod/lib/proto"
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
// ConformancePageState is what the browser is looking at part way through a conformance module's authorization flow.
|
||||
type ConformancePageState int
|
||||
|
||||
const (
|
||||
// ConformancePageUnknown is a page the driver has no action for.
|
||||
ConformancePageUnknown ConformancePageState = iota
|
||||
|
||||
// ConformancePageFirstFactor is Authelia's first factor sign in form.
|
||||
ConformancePageFirstFactor
|
||||
|
||||
// ConformancePageConsent is Authelia's OpenID Connect 1.0 consent decision stage.
|
||||
ConformancePageConsent
|
||||
|
||||
// ConformancePageAutheliaError is Authelia reporting an error rather than presenting a stage.
|
||||
ConformancePageAutheliaError
|
||||
|
||||
// ConformancePageCallback is the conformance suite's own callback, which ends a leg of the flow.
|
||||
ConformancePageCallback
|
||||
)
|
||||
|
||||
const (
|
||||
conformanceSelectorFirstFactor = "#first-factor-stage"
|
||||
conformanceSelectorConsent = "#openid-consent-decision-stage"
|
||||
conformanceSelectorConsentAccept = "#openid-consent-accept"
|
||||
conformanceSelectorConsentReauthentication = "#openid-consent-prompt-login"
|
||||
conformanceSelectorConsentPassword = "#openid-consent-prompt-login #password-textfield"
|
||||
conformanceSelectorConsentAuthenticate = "#openid-consent-authenticate"
|
||||
conformanceSelectorUsername = "#username-textfield"
|
||||
conformanceSelectorPassword = "#password-textfield"
|
||||
conformanceSelectorSignIn = "#sign-in-button"
|
||||
conformanceCallbackPathPrefix = "/test/a/"
|
||||
conformanceSelectorAutheliaError = `.notification[data-type="error"]`
|
||||
conformanceSelectorCompletionError = `[data-testid="openid-completion-outcome"][data-outcome="error"]`
|
||||
conformanceElementTimeout = time.Second * 2
|
||||
conformanceDriveInterval = time.Millisecond * 250
|
||||
conformanceSettleTimeout = time.Second * 30
|
||||
conformanceLegPatience = time.Second * 30
|
||||
conformancePageTimeout = time.Second * 30
|
||||
conformanceSignInAttempts = 3
|
||||
conformanceConsentAttempts = 3
|
||||
conformanceScreenshotLimit = 500 * 1024
|
||||
conformanceCoverageBinding = "__autheliaConformanceCoverage"
|
||||
conformanceCoverageScript = `addEventListener('beforeunload', () => { if (window.__coverage__) window.` + conformanceCoverageBinding + `(JSON.stringify(window.__coverage__)); });`
|
||||
)
|
||||
|
||||
// ConformanceClassifyPage decides what the browser is looking at. It takes the observations rather than the page so
|
||||
// that the decision is testable without a browser. The callback wins over every stage selector, because a stage left
|
||||
// in the DOM of the document being navigated away from would otherwise be read as the current page.
|
||||
func ConformanceClassifyPage(uri string, hasFirstFactor, hasConsent, hasError bool) ConformancePageState {
|
||||
switch {
|
||||
case conformanceIsCallback(uri):
|
||||
return ConformancePageCallback
|
||||
case hasConsent:
|
||||
return ConformancePageConsent
|
||||
case hasFirstFactor:
|
||||
return ConformancePageFirstFactor
|
||||
case hasError:
|
||||
return ConformancePageAutheliaError
|
||||
default:
|
||||
return ConformancePageUnknown
|
||||
}
|
||||
}
|
||||
|
||||
func conformanceIsCallback(uri string) bool {
|
||||
u, err := url.Parse(uri)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
return strings.HasPrefix(u.Path, conformanceCallbackPathPrefix)
|
||||
}
|
||||
|
||||
// ConformanceLeg is what the driver saw over the course of one browser leg of a module's authorization flow. It is
|
||||
// recorded as the leg is driven rather than probed afterwards: [ConformanceBrowser.Drive] only returns once the flow
|
||||
// has left Authelia for the conformance suite's callback, so anything asked of the live DOM at that point is a
|
||||
// question about the callback page and not about what Authelia did. Being a plain value also makes the module
|
||||
// overrides which consume it testable without a browser.
|
||||
type ConformanceLeg struct {
|
||||
// Index is the zero based position of this leg among the module's browser legs. Modules such as
|
||||
// oidcc-prompt-login perform two authorization round trips, and only the second one carries the parameter under
|
||||
// test, so an expectation about re-authentication belongs to Index 1 rather than to every leg.
|
||||
Index int
|
||||
|
||||
// FirstFactor is whether Authelia presented its first factor sign in form at any point during the leg.
|
||||
FirstFactor bool
|
||||
|
||||
// Consent is whether Authelia presented its OpenID Connect 1.0 consent decision stage at any point during the leg.
|
||||
Consent bool
|
||||
|
||||
// Reauthentication is whether Authelia asked for the password again during the leg. This is the signal a
|
||||
// prompt=login or max_age expectation turns on, not FirstFactor: Authelia asks on the consent decision form
|
||||
// rather than by returning to the sign in page.
|
||||
Reauthentication bool
|
||||
|
||||
// AutheliaError is whether the leg ended on Authelia reporting an error rather than reaching the callback.
|
||||
AutheliaError bool
|
||||
|
||||
// ErrorURL is the page the leg ended on when AutheliaError is set. Authelia's completion view carries the error in
|
||||
// its query, so this is what describes the error to a report.
|
||||
ErrorURL string
|
||||
|
||||
// LoginScreenshot is the last page during the leg which asked for the password, captured with it entered and
|
||||
// before it was submitted: the consent form's re-authentication step, or the sign in form.
|
||||
LoginScreenshot string
|
||||
|
||||
// ErrorScreenshot is the Authelia error page the leg ended on.
|
||||
ErrorScreenshot string
|
||||
}
|
||||
|
||||
// ConformanceBrowser is one conformance plan's isolated browser context. Every method returns an error rather than
|
||||
// failing a test, because the driver runs on a goroutine which is not the test's.
|
||||
type ConformanceBrowser struct {
|
||||
session *RodSession
|
||||
incognito *rod.Browser
|
||||
page *rod.Page
|
||||
username string
|
||||
password string
|
||||
|
||||
trace *ConformanceTrace
|
||||
stopCoverage func()
|
||||
}
|
||||
|
||||
// NewConformanceBrowser returns a ConformanceBrowser with its own cookie jar.
|
||||
func NewConformanceBrowser(session *RodSession) (browser *ConformanceBrowser, err error) {
|
||||
incognito, err := session.WebDriver.Incognito()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error creating an isolated browser context: %w", err)
|
||||
}
|
||||
|
||||
page, err := incognito.Page(proto.TargetCreateTarget{})
|
||||
if err != nil {
|
||||
_ = incognito.Close()
|
||||
|
||||
return nil, fmt.Errorf("error creating a tab: %w", err)
|
||||
}
|
||||
|
||||
browser = &ConformanceBrowser{
|
||||
session: session,
|
||||
incognito: incognito,
|
||||
page: page,
|
||||
username: testUsername,
|
||||
password: testPassword,
|
||||
}
|
||||
|
||||
if err = browser.collectCoverage(); err != nil {
|
||||
_ = incognito.Close()
|
||||
|
||||
return nil, fmt.Errorf("error arranging for frontend coverage to be collected: %w", err)
|
||||
}
|
||||
|
||||
return browser, nil
|
||||
}
|
||||
|
||||
func (b *ConformanceBrowser) collectCoverage() (err error) {
|
||||
if err = (proto.RuntimeAddBinding{Name: conformanceCoverageBinding}).Call(b.page); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if _, err = b.page.EvalOnNewDocument(conformanceCoverageScript); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
page, cancel := b.page.WithCancel()
|
||||
|
||||
wait := page.EachEvent(func(e *proto.RuntimeBindingCalled) {
|
||||
if e.Name != conformanceCoverageBinding {
|
||||
return
|
||||
}
|
||||
|
||||
if err := writeCoverage(coverageDir, e.Payload); err != nil {
|
||||
log.Errorf("Error writing coverage: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
done := make(chan struct{})
|
||||
|
||||
go func() {
|
||||
wait()
|
||||
close(done)
|
||||
}()
|
||||
|
||||
b.stopCoverage = func() {
|
||||
cancel()
|
||||
<-done
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Close disposes of the browser context. The document the browser ends on is never left, so its coverage is collected
|
||||
// directly, and any coverage still being written is finished before the context goes.
|
||||
func (b *ConformanceBrowser) Close() {
|
||||
if b.incognito == nil {
|
||||
return
|
||||
}
|
||||
|
||||
b.session.collectCoverage(b.page.Timeout(conformancePageTimeout))
|
||||
|
||||
if b.stopCoverage != nil {
|
||||
b.stopCoverage()
|
||||
}
|
||||
|
||||
_ = b.incognito.Close()
|
||||
|
||||
b.incognito = nil
|
||||
}
|
||||
|
||||
// ClearCookies discards every cookie in this context, for the modules whose instructions are to remove any cookies
|
||||
// received from the provider before proceeding.
|
||||
func (b *ConformanceBrowser) ClearCookies() error {
|
||||
return proto.NetworkClearBrowserCookies{}.Call(b.page.Timeout(conformancePageTimeout))
|
||||
}
|
||||
|
||||
func (b *ConformanceBrowser) has(selector string) bool {
|
||||
has, _, err := b.page.Timeout(conformanceElementTimeout).Has(selector)
|
||||
|
||||
return err == nil && has
|
||||
}
|
||||
|
||||
func (b *ConformanceBrowser) hasAutheliaError() bool {
|
||||
return b.has(conformanceSelectorAutheliaError) || b.has(conformanceSelectorCompletionError)
|
||||
}
|
||||
|
||||
func (b *ConformanceBrowser) url() string {
|
||||
info, err := b.page.Timeout(conformancePageTimeout).Info()
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
return info.URL
|
||||
}
|
||||
|
||||
func (b *ConformanceBrowser) title() string {
|
||||
info, err := b.page.Timeout(conformancePageTimeout).Info()
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
return info.Title
|
||||
}
|
||||
|
||||
func (b *ConformanceBrowser) settle(ctx context.Context, selector, startURL string) error {
|
||||
deadline := time.Now().Add(conformanceSettleTimeout)
|
||||
|
||||
for time.Now().Before(deadline) {
|
||||
if ctx.Err() != nil {
|
||||
return ctx.Err()
|
||||
}
|
||||
|
||||
if !b.has(selector) || b.url() != startURL {
|
||||
return nil
|
||||
}
|
||||
|
||||
time.Sleep(conformanceDriveInterval)
|
||||
}
|
||||
|
||||
return fmt.Errorf("the form at '%s' was submitted but '%s' was still on screen %s later", startURL, selector, conformanceSettleTimeout)
|
||||
}
|
||||
|
||||
// Drive navigates to uri and works the flow through to the conformance suite's callback. It is deliberately reactive:
|
||||
// it acts on whatever page appears rather than on a script per module, so that a module added by a future conformance
|
||||
// suite release runs instead of failing.
|
||||
func (b *ConformanceBrowser) Drive(ctx context.Context, index int, uri string) (leg ConformanceLeg, err error) {
|
||||
leg = ConformanceLeg{Index: index}
|
||||
|
||||
loaded := b.page.Timeout(conformancePageTimeout).WaitNavigation(proto.PageLifecycleEventNameDOMContentLoaded)
|
||||
|
||||
if err = b.session.doNavigate(b.page.Timeout(conformancePageTimeout), uri); err != nil {
|
||||
return leg, fmt.Errorf("error navigating to '%s': %w", uri, err)
|
||||
}
|
||||
|
||||
loaded()
|
||||
|
||||
attempts, consents := 0, 0
|
||||
|
||||
progress := newConformanceProgress(conformanceLegPatience, time.Now())
|
||||
|
||||
for {
|
||||
if ctx.Err() != nil {
|
||||
return leg, fmt.Errorf("the flow beginning at '%s' did not reach the conformance callback, the last page was '%s': %w", uri, b.url(), ctx.Err())
|
||||
}
|
||||
|
||||
pageURL := b.url()
|
||||
|
||||
switch ConformanceClassifyPage(pageURL, b.has(conformanceSelectorFirstFactor), b.has(conformanceSelectorConsent), b.hasAutheliaError()) {
|
||||
case ConformancePageCallback:
|
||||
return leg, nil
|
||||
case ConformancePageFirstFactor:
|
||||
leg.FirstFactor = true
|
||||
|
||||
if attempts++; attempts > conformanceSignInAttempts {
|
||||
return leg, fmt.Errorf("the sign in form at '%s' was still present after %d attempts", pageURL, conformanceSignInAttempts)
|
||||
}
|
||||
|
||||
var screenshot string
|
||||
|
||||
if screenshot, err = b.submitSignIn(ctx, pageURL, attempts); err != nil {
|
||||
return leg, err
|
||||
}
|
||||
|
||||
leg.LoginScreenshot = conformanceLatest(leg.LoginScreenshot, screenshot)
|
||||
|
||||
progress.observe(time.Now(), pageURL, true)
|
||||
case ConformancePageConsent:
|
||||
leg.Consent = true
|
||||
|
||||
if consents++; consents > conformanceConsentAttempts {
|
||||
return leg, fmt.Errorf("the consent decision form at '%s' was still present after %d attempts", pageURL, conformanceConsentAttempts)
|
||||
}
|
||||
|
||||
var (
|
||||
reauthentication bool
|
||||
screenshot string
|
||||
)
|
||||
|
||||
if reauthentication, screenshot, err = b.submitConsent(ctx, pageURL, consents); err != nil {
|
||||
return leg, err
|
||||
}
|
||||
|
||||
leg.Reauthentication = leg.Reauthentication || reauthentication
|
||||
leg.LoginScreenshot = conformanceLatest(leg.LoginScreenshot, screenshot)
|
||||
|
||||
progress.observe(time.Now(), pageURL, true)
|
||||
case ConformancePageAutheliaError:
|
||||
leg.AutheliaError, leg.ErrorURL, leg.ErrorScreenshot = true, pageURL, b.screenshot()
|
||||
|
||||
return leg, nil
|
||||
case ConformancePageUnknown:
|
||||
if progress.observe(time.Now(), pageURL, false) {
|
||||
return leg, fmt.Errorf("the flow beginning at '%s' stopped at '%s' (%q), a page the driver has no action for, and it did not change for %s",
|
||||
uri, pageURL, b.title(), conformanceLegPatience)
|
||||
}
|
||||
|
||||
time.Sleep(conformanceDriveInterval)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (b *ConformanceBrowser) signIn() (screenshot string, err error) {
|
||||
if err = b.input(conformanceSelectorUsername, b.username); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if err = b.input(conformanceSelectorPassword, b.password); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
screenshot = b.screenshot()
|
||||
|
||||
return screenshot, b.click(conformanceSelectorSignIn)
|
||||
}
|
||||
|
||||
func (b *ConformanceBrowser) screenshot() string {
|
||||
uri, err := conformanceScreenshotDataURI(func(format proto.PageCaptureScreenshotFormat, quality int) ([]byte, error) {
|
||||
req := &proto.PageCaptureScreenshot{Format: format}
|
||||
|
||||
if quality != 0 {
|
||||
req.Quality = &quality
|
||||
}
|
||||
|
||||
return b.page.Timeout(conformancePageTimeout).Screenshot(false, req)
|
||||
})
|
||||
if err != nil {
|
||||
log.Warnf("Conformance driver could not capture a screenshot of '%s': %v", b.url(), err)
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
return uri
|
||||
}
|
||||
|
||||
func conformanceScreenshotDataURI(capture func(format proto.PageCaptureScreenshotFormat, quality int) ([]byte, error)) (uri string, err error) {
|
||||
for _, attempt := range []struct {
|
||||
format proto.PageCaptureScreenshotFormat
|
||||
quality int
|
||||
mime string
|
||||
}{
|
||||
{proto.PageCaptureScreenshotFormatPng, 0, "image/png"},
|
||||
{proto.PageCaptureScreenshotFormatJpeg, 80, "image/jpeg"},
|
||||
{proto.PageCaptureScreenshotFormatJpeg, 50, "image/jpeg"},
|
||||
} {
|
||||
var data []byte
|
||||
|
||||
if data, err = capture(attempt.format, attempt.quality); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if len(data) <= conformanceScreenshotLimit {
|
||||
return "data:" + attempt.mime + ";base64," + base64.StdEncoding.EncodeToString(data), nil
|
||||
}
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("the screenshot does not fit the conformance suite's %dKB upload limit in any format", conformanceScreenshotLimit/1024)
|
||||
}
|
||||
|
||||
func conformanceLatest(current, next string) string {
|
||||
if next == "" {
|
||||
return current
|
||||
}
|
||||
|
||||
return next
|
||||
}
|
||||
|
||||
func (b *ConformanceBrowser) input(selector, value string) (err error) {
|
||||
element, err := b.page.Timeout(elementLocateTimeout).Element(selector)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error locating '%s': %w", selector, err)
|
||||
}
|
||||
|
||||
if err = element.SelectAllText(); err != nil {
|
||||
return fmt.Errorf("error selecting the text of '%s': %w", selector, err)
|
||||
}
|
||||
|
||||
if err = element.Input(value); err != nil {
|
||||
return fmt.Errorf("error typing into '%s': %w", selector, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *ConformanceBrowser) click(selector string) (err error) {
|
||||
element, err := b.page.Timeout(elementLocateTimeout).Element(selector)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error locating '%s': %w", selector, err)
|
||||
}
|
||||
|
||||
if err = element.Click(proto.InputMouseButtonLeft, 1); err != nil {
|
||||
return fmt.Errorf("error clicking '%s': %w", selector, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
type conformanceProgress struct {
|
||||
patience time.Duration
|
||||
url string
|
||||
deadline time.Time
|
||||
}
|
||||
|
||||
func newConformanceProgress(patience time.Duration, now time.Time) *conformanceProgress {
|
||||
return &conformanceProgress{patience: patience, deadline: now.Add(patience)}
|
||||
}
|
||||
|
||||
func (p *conformanceProgress) observe(now time.Time, url string, recognized bool) (stalled bool) {
|
||||
if url != p.url {
|
||||
p.url, p.deadline = url, now.Add(p.patience)
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
if recognized {
|
||||
p.deadline = now.Add(p.patience)
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
return now.After(p.deadline)
|
||||
}
|
||||
|
||||
func (b *ConformanceBrowser) submitSignIn(ctx context.Context, pageURL string, attempt int) (screenshot string, err error) {
|
||||
b.trace.Logf("Signing in at '%s' (attempt %d)", pageURL, attempt)
|
||||
|
||||
if screenshot, err = b.signIn(); err != nil {
|
||||
return "", fmt.Errorf("error signing in at '%s': %w", pageURL, err)
|
||||
}
|
||||
|
||||
if err = b.settle(ctx, conformanceSelectorFirstFactor, pageURL); err != nil {
|
||||
return "", fmt.Errorf("error signing in at '%s': %w", pageURL, err)
|
||||
}
|
||||
|
||||
return screenshot, nil
|
||||
}
|
||||
|
||||
func (b *ConformanceBrowser) submitConsent(ctx context.Context, pageURL string, attempt int) (reauthentication bool, screenshot string, err error) {
|
||||
b.trace.Logf("Accepting the consent decision form at '%s' (attempt %d)", pageURL, attempt)
|
||||
|
||||
if err = b.click(conformanceSelectorConsentAccept); err != nil {
|
||||
return false, "", fmt.Errorf("error accepting consent at '%s': %w", pageURL, err)
|
||||
}
|
||||
|
||||
if reauthentication, err = b.awaitConsentAccepted(ctx, pageURL); err != nil {
|
||||
return false, "", err
|
||||
}
|
||||
|
||||
if !reauthentication {
|
||||
return false, "", nil
|
||||
}
|
||||
|
||||
b.trace.Logf("Answering the consent form's re-authentication step at '%s'", pageURL)
|
||||
|
||||
if err = b.input(conformanceSelectorConsentPassword, b.password); err != nil {
|
||||
return true, "", fmt.Errorf("error answering the re-authentication step at '%s': %w", pageURL, err)
|
||||
}
|
||||
|
||||
screenshot = b.screenshot()
|
||||
|
||||
if err = b.click(conformanceSelectorConsentAuthenticate); err != nil {
|
||||
return true, "", fmt.Errorf("error answering the re-authentication step at '%s': %w", pageURL, err)
|
||||
}
|
||||
|
||||
if err = b.settle(ctx, conformanceSelectorConsentReauthentication, pageURL); err != nil {
|
||||
return true, "", fmt.Errorf("error answering the re-authentication step at '%s': %w", pageURL, err)
|
||||
}
|
||||
|
||||
return true, screenshot, nil
|
||||
}
|
||||
|
||||
func (b *ConformanceBrowser) awaitConsentAccepted(ctx context.Context, pageURL string) (reauthentication bool, err error) {
|
||||
deadline := time.Now().Add(conformanceSettleTimeout)
|
||||
|
||||
for time.Now().Before(deadline) {
|
||||
if ctx.Err() != nil {
|
||||
return false, ctx.Err()
|
||||
}
|
||||
|
||||
reauthentication, done := ConformanceConsentProgress(b.has(conformanceSelectorConsentReauthentication), b.has(conformanceSelectorConsent), b.url() != pageURL)
|
||||
if done {
|
||||
return reauthentication, nil
|
||||
}
|
||||
|
||||
time.Sleep(conformanceDriveInterval)
|
||||
}
|
||||
|
||||
return false, fmt.Errorf("the consent decision form at '%s' was accepted but neither left nor asked for the password %s later", pageURL, conformanceSettleTimeout)
|
||||
}
|
||||
|
||||
// ConformanceConsentProgress reports what accepting the consent decision achieved.
|
||||
func ConformanceConsentProgress(hasReauthentication, hasStage, urlChanged bool) (reauthentication, done bool) {
|
||||
switch {
|
||||
case hasReauthentication:
|
||||
return true, true
|
||||
case !hasStage || urlChanged:
|
||||
return false, true
|
||||
default:
|
||||
return false, false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,372 @@
|
||||
// SPDX-FileCopyrightText: 2026 Authelia
|
||||
//
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package suites
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/go-rod/rod"
|
||||
"github.com/go-rod/rod/lib/launcher"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestConformanceBrowser_Drive(t *testing.T) {
|
||||
driver := newConformanceTestBrowser(t)
|
||||
portal := conformanceFakePortal(t)
|
||||
|
||||
drive := func(t *testing.T, index int, path string) ConformanceLeg {
|
||||
t.Helper()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Second*30)
|
||||
defer cancel()
|
||||
|
||||
leg, err := driver.Drive(ctx, index, portal.URL+path)
|
||||
require.NoError(t, err)
|
||||
|
||||
return leg
|
||||
}
|
||||
|
||||
t.Run("ShouldSignInThenAnswerTheConsentFormsReauthentication", func(t *testing.T) {
|
||||
leg := drive(t, 1, "/authorize-reauthenticate")
|
||||
|
||||
assert.Equal(t, 1, leg.Index)
|
||||
assert.True(t, leg.FirstFactor)
|
||||
assert.True(t, leg.Consent)
|
||||
assert.True(t, leg.Reauthentication)
|
||||
assert.False(t, leg.AutheliaError)
|
||||
assert.True(t, strings.HasPrefix(leg.LoginScreenshot, "data:image/png;base64,"), "the password step is captured")
|
||||
assert.Empty(t, leg.ErrorScreenshot)
|
||||
})
|
||||
|
||||
t.Run("ShouldAcceptConsentWhichAsksForNoPassword", func(t *testing.T) {
|
||||
leg := drive(t, 0, "/consent")
|
||||
|
||||
assert.True(t, leg.Consent)
|
||||
assert.False(t, leg.Reauthentication)
|
||||
assert.False(t, leg.FirstFactor)
|
||||
assert.Empty(t, leg.LoginScreenshot)
|
||||
})
|
||||
|
||||
t.Run("ShouldDriveALegWhichStartsOnTheLastLegsCallback", func(t *testing.T) {
|
||||
drive(t, 0, "/consent")
|
||||
|
||||
leg := drive(t, 1, "/error")
|
||||
|
||||
assert.True(t, leg.AutheliaError)
|
||||
})
|
||||
|
||||
t.Run("ShouldEndTheLegOnAnAutheliaErrorPage", func(t *testing.T) {
|
||||
leg := drive(t, 0, "/error")
|
||||
|
||||
assert.True(t, leg.AutheliaError)
|
||||
assert.Equal(t, portal.URL+"/error", leg.ErrorURL)
|
||||
assert.True(t, strings.HasPrefix(leg.ErrorScreenshot, "data:image/png;base64,"), "the error page is captured")
|
||||
})
|
||||
|
||||
t.Run("ShouldHandCoverageOverAsThePageIsLeft", func(t *testing.T) {
|
||||
before, _ := os.ReadDir(coverageDir)
|
||||
|
||||
drive(t, 0, "/coverage")
|
||||
|
||||
assert.Eventually(t, func() bool {
|
||||
after, _ := os.ReadDir(coverageDir)
|
||||
|
||||
return len(after) > len(before)
|
||||
}, time.Second*5, time.Millisecond*50)
|
||||
})
|
||||
|
||||
t.Run("ShouldClearTheContextsCookies", func(t *testing.T) {
|
||||
drive(t, 0, "/authorize")
|
||||
|
||||
cookies, err := driver.page.Cookies(nil)
|
||||
require.NoError(t, err)
|
||||
require.NotEmpty(t, cookies)
|
||||
|
||||
require.NoError(t, driver.ClearCookies())
|
||||
|
||||
cookies, err = driver.page.Cookies(nil)
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, cookies)
|
||||
})
|
||||
|
||||
t.Run("ShouldReportANavigationWhichFailed", func(t *testing.T) {
|
||||
_, err := driver.Drive(context.Background(), 0, "http://127.0.0.1:1/")
|
||||
|
||||
assert.ErrorContains(t, err, "error navigating to 'http://127.0.0.1:1/'")
|
||||
})
|
||||
|
||||
t.Run("ShouldStopWhenTheContextHasExpired", func(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
|
||||
_, err := driver.Drive(ctx, 0, portal.URL+"/error")
|
||||
|
||||
assert.ErrorContains(t, err, "did not reach the conformance callback")
|
||||
})
|
||||
}
|
||||
|
||||
func TestConformanceRunner_VisitURLsDrivesEachLeg(t *testing.T) {
|
||||
driver := newConformanceTestBrowser(t)
|
||||
portal := conformanceFakePortal(t)
|
||||
|
||||
var (
|
||||
mu sync.Mutex
|
||||
visited []string
|
||||
)
|
||||
|
||||
visit := func(status int) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
mu.Lock()
|
||||
|
||||
visited = append(visited, r.URL.Query().Get("url"))
|
||||
mu.Unlock()
|
||||
|
||||
w.WriteHeader(status)
|
||||
}
|
||||
}
|
||||
|
||||
t.Run("ShouldVisitALegOnceItIsDriven", func(t *testing.T) {
|
||||
client := newConformanceFakeClient(t, map[string]http.HandlerFunc{"/api/runner/browser/m1/visit": visit(http.StatusNoContent)})
|
||||
|
||||
runner := NewConformanceRunner(client, driver, "plan1", nil)
|
||||
runner.trace = NewConformanceTrace(true)
|
||||
|
||||
legs := &conformanceLegs{visited: map[string]bool{}}
|
||||
|
||||
progressed, err := runner.visitURLs(context.Background(), "m1", ConformanceOverride{Screenshot: ConformanceScreenshotErrorPage}, legs, []string{portal.URL + "/error"})
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.True(t, progressed)
|
||||
assert.Equal(t, portal.URL+"/error", legs.errorURL)
|
||||
assert.True(t, strings.HasPrefix(legs.screenshot, "data:image/png;base64,"))
|
||||
assert.Equal(t, []string{portal.URL + "/error"}, visited)
|
||||
assert.Len(t, runner.trace.Lines(), 2, "the leg is traced as it starts and as it ends")
|
||||
})
|
||||
|
||||
t.Run("ShouldNotVisitALegWhoseAssertionFailed", func(t *testing.T) {
|
||||
client := newConformanceFakeClient(t, map[string]http.HandlerFunc{})
|
||||
|
||||
legs := &conformanceLegs{visited: map[string]bool{}}
|
||||
|
||||
_, err := NewConformanceRunner(client, driver, "plan1", nil).visitURLs(context.Background(), "m1", ConformanceOverride{Assert: conformanceAssertErrorPage}, legs, []string{portal.URL + "/consent"})
|
||||
|
||||
assert.EqualError(t, err, "expected Authelia to show an error page rather than redirect to the client but it did not")
|
||||
})
|
||||
|
||||
t.Run("ShouldReportAVisitWhichFailed", func(t *testing.T) {
|
||||
client := newConformanceFakeClient(t, map[string]http.HandlerFunc{"/api/runner/browser/m1/visit": visit(http.StatusInternalServerError)})
|
||||
|
||||
legs := &conformanceLegs{visited: map[string]bool{}}
|
||||
|
||||
_, err := NewConformanceRunner(client, driver, "plan1", nil).visitURLs(context.Background(), "m1", ConformanceOverride{}, legs, []string{portal.URL + "/consent-reauthenticate"})
|
||||
|
||||
assert.ErrorContains(t, err, "expected status 204 but got 500")
|
||||
})
|
||||
|
||||
t.Run("ShouldReportALegWhichCouldNotBeDriven", func(t *testing.T) {
|
||||
client := newConformanceFakeClient(t, map[string]http.HandlerFunc{})
|
||||
|
||||
legs := &conformanceLegs{visited: map[string]bool{}}
|
||||
|
||||
_, err := NewConformanceRunner(client, driver, "plan1", nil).visitURLs(context.Background(), "m1", ConformanceOverride{}, legs, []string{"http://127.0.0.1:1/"})
|
||||
|
||||
assert.ErrorContains(t, err, "error navigating")
|
||||
})
|
||||
}
|
||||
|
||||
func TestConformanceBrowser_AfterClose(t *testing.T) {
|
||||
driver := newConformanceTestBrowser(t)
|
||||
|
||||
driver.Close()
|
||||
|
||||
assert.Empty(t, driver.url())
|
||||
assert.Empty(t, driver.title())
|
||||
assert.False(t, driver.has(conformanceSelectorFirstFactor))
|
||||
assert.Empty(t, driver.screenshot())
|
||||
|
||||
assert.NotPanics(t, driver.Close, "closing twice is harmless")
|
||||
}
|
||||
|
||||
func TestConformanceRunner_WithABrowser(t *testing.T) {
|
||||
driver := newConformanceTestBrowser(t)
|
||||
|
||||
t.Run("ShouldClearCookiesForAModuleWhichAsksForIt", func(t *testing.T) {
|
||||
client := newConformanceFakeClient(t, map[string]http.HandlerFunc{
|
||||
"/api/runner": conformanceReply(http.StatusCreated, `{"id":"m1"}`),
|
||||
"/api/runner/m1/wait-state": conformanceReply(http.StatusOK, `{"state":"FINISHED"}`),
|
||||
"/api/info/m1": conformanceReply(http.StatusOK, `{"_id":"m1","status":"FINISHED","result":"PASSED"}`),
|
||||
})
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Second*10)
|
||||
defer cancel()
|
||||
|
||||
var outcomes []ConformanceOutcome
|
||||
|
||||
for outcome := range NewConformanceRunner(client, driver, "plan1", []ConformancePlanModule{{TestModule: "oidcc-display-page"}}).Run(ctx) {
|
||||
outcomes = append(outcomes, outcome)
|
||||
}
|
||||
|
||||
require.Len(t, outcomes, 1)
|
||||
require.NoError(t, outcomes[0].Err)
|
||||
assert.Equal(t, "PASSED", outcomes[0].Result)
|
||||
})
|
||||
|
||||
t.Run("ShouldReportALegWhichCouldNotBeDriven", func(t *testing.T) {
|
||||
client := newConformanceFakeClient(t, map[string]http.HandlerFunc{
|
||||
"/api/runner/browser/m1": conformanceReply(http.StatusOK, `{"urls":["http://127.0.0.1:1/"]}`),
|
||||
})
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Second*10)
|
||||
defer cancel()
|
||||
|
||||
err := NewConformanceRunner(client, driver, "plan1", nil).interact(ctx, "m1", ConformanceOverride{})
|
||||
|
||||
assert.ErrorContains(t, err, "error navigating to 'http://127.0.0.1:1/'")
|
||||
})
|
||||
}
|
||||
|
||||
func conformanceFakePortal(t *testing.T) *httptest.Server {
|
||||
t.Helper()
|
||||
|
||||
const callback = "/test/a/alias/callback?code=abc"
|
||||
|
||||
page := func(w http.ResponseWriter, body string) {
|
||||
w.Header().Set("Content-Type", "text/html")
|
||||
_, _ = fmt.Fprintf(w, "<!DOCTYPE html><html><head><title>Login - Authelia</title></head><body>%s</body></html>", body)
|
||||
}
|
||||
|
||||
mux := http.NewServeMux()
|
||||
|
||||
for _, variant := range []struct {
|
||||
suffix string
|
||||
reauthentication bool
|
||||
}{
|
||||
{"", false},
|
||||
{"-reauthenticate", true},
|
||||
} {
|
||||
mux.HandleFunc("/authorize"+variant.suffix, func(w http.ResponseWriter, r *http.Request) {
|
||||
http.Redirect(w, r, "/login"+variant.suffix, http.StatusFound)
|
||||
})
|
||||
|
||||
mux.HandleFunc("/login"+variant.suffix, func(w http.ResponseWriter, _ *http.Request) {
|
||||
page(w, fmt.Sprintf(`<div id="first-factor-stage">
|
||||
<input id="username-textfield"><input id="password-textfield" type="password">
|
||||
<button id="sign-in-button" onclick="signIn()">Sign in</button></div>
|
||||
<script>
|
||||
document.cookie = 'authelia_session=session; path=/';
|
||||
|
||||
function signIn() {
|
||||
if (document.getElementById('username-textfield').value === %q &&
|
||||
document.getElementById('password-textfield').value === %q) location.href = %q;
|
||||
}
|
||||
</script>`, testUsername, testPassword, "/consent"+variant.suffix))
|
||||
})
|
||||
|
||||
mux.HandleFunc("/consent"+variant.suffix, func(w http.ResponseWriter, _ *http.Request) {
|
||||
page(w, fmt.Sprintf(`<div id="openid-consent-decision-stage">
|
||||
<button id="openid-consent-accept" onclick="accept()">Accept</button></div>
|
||||
<script>
|
||||
function accept() {
|
||||
if (%t) {
|
||||
const stage = document.getElementById('openid-consent-decision-stage');
|
||||
stage.innerHTML = '<div id="openid-consent-prompt-login"><input id="password-textfield" type="password"></div>' +
|
||||
'<button id="openid-consent-authenticate" onclick="authenticate()">Authenticate</button>';
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
location.href = %q;
|
||||
}
|
||||
|
||||
function authenticate() {
|
||||
if (document.querySelector('#openid-consent-prompt-login #password-textfield').value === %q) location.href = %q;
|
||||
}
|
||||
</script>`, variant.reauthentication, callback, testPassword, callback))
|
||||
})
|
||||
}
|
||||
|
||||
mux.HandleFunc("/error", func(w http.ResponseWriter, _ *http.Request) {
|
||||
page(w, `<div data-testid="openid-completion-outcome" data-outcome="error">An error occurred processing the request</div>`)
|
||||
})
|
||||
|
||||
mux.HandleFunc("/coverage", func(w http.ResponseWriter, _ *http.Request) {
|
||||
page(w, fmt.Sprintf(`<script>window.__coverage__ = {"/node/src/app/src/index.tsx": {"s": {"0": 1}}};
|
||||
setTimeout(() => { location.href = %q; }, 100);</script>`, callback))
|
||||
})
|
||||
|
||||
mux.HandleFunc("/test/a/alias/callback", func(w http.ResponseWriter, _ *http.Request) {
|
||||
page(w, "callback")
|
||||
})
|
||||
|
||||
server := httptest.NewServer(mux)
|
||||
|
||||
t.Cleanup(server.Close)
|
||||
|
||||
return server
|
||||
}
|
||||
|
||||
func newConformanceTestBrowser(t *testing.T) *ConformanceBrowser {
|
||||
t.Helper()
|
||||
|
||||
if testing.Short() {
|
||||
t.Skip("skipping browser test in short mode")
|
||||
}
|
||||
|
||||
preserveCoverageDir(t)
|
||||
|
||||
path, err := GetBrowserPath()
|
||||
require.NoError(t, err)
|
||||
|
||||
l := launcher.New().Bin(path).Headless(true)
|
||||
t.Cleanup(l.Cleanup)
|
||||
|
||||
browser := rod.New().ControlURL(l.MustLaunch()).MustConnect()
|
||||
|
||||
t.Cleanup(func() { _ = browser.Close() })
|
||||
|
||||
driver, err := NewConformanceBrowser(&RodSession{WebDriver: browser})
|
||||
require.NoError(t, err)
|
||||
|
||||
t.Cleanup(driver.Close)
|
||||
|
||||
return driver
|
||||
}
|
||||
|
||||
func preserveCoverageDir(t *testing.T) {
|
||||
t.Helper()
|
||||
|
||||
existed := map[string]bool{}
|
||||
|
||||
entries, err := os.ReadDir(coverageDir)
|
||||
dirExisted := err == nil
|
||||
|
||||
for _, entry := range entries {
|
||||
existed[entry.Name()] = true
|
||||
}
|
||||
|
||||
t.Cleanup(func() {
|
||||
entries, _ := os.ReadDir(coverageDir)
|
||||
|
||||
for _, entry := range entries {
|
||||
if !existed[entry.Name()] {
|
||||
_ = os.Remove(filepath.Join(coverageDir, entry.Name()))
|
||||
}
|
||||
}
|
||||
|
||||
if !dirExisted {
|
||||
_ = os.Remove(coverageDir)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
// SPDX-FileCopyrightText: 2026 Authelia
|
||||
//
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package suites
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/go-rod/rod/lib/proto"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestConformanceClassifyPage(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
url string
|
||||
firstFactor, consent, autheliaError bool
|
||||
expected ConformancePageState
|
||||
}{
|
||||
{
|
||||
"ShouldDetectTheCallbackByPath",
|
||||
"https://conformance.example.com:8443/test/a/conformance-basic-authelia1a2b3c4d/callback?code=abc",
|
||||
false, false, false,
|
||||
ConformancePageCallback,
|
||||
},
|
||||
{
|
||||
"ShouldPreferTheCallbackOverAnyStageStillInTheDOM",
|
||||
"https://conformance.example.com:8443/test/a/alias/callback",
|
||||
true, false, false,
|
||||
ConformancePageCallback,
|
||||
},
|
||||
{
|
||||
"ShouldNotMistakeAnAuthorizationRequestCarryingTheCallbackForTheCallback",
|
||||
"https://login.example.com:8080/api/oidc/authorization?client_id=abc&redirect_uri=https://conformance.example.com:8443/test/a/alias/callback",
|
||||
false, false, false,
|
||||
ConformancePageUnknown,
|
||||
},
|
||||
{
|
||||
"ShouldDetectTheFirstFactorPage",
|
||||
"https://login.example.com:8080/",
|
||||
true, false, false,
|
||||
ConformancePageFirstFactor,
|
||||
},
|
||||
{
|
||||
"ShouldDetectTheConsentPage",
|
||||
"https://login.example.com:8080/consent/openid/decision",
|
||||
false, true, false,
|
||||
ConformancePageConsent,
|
||||
},
|
||||
{
|
||||
"ShouldDetectAnAutheliaError",
|
||||
"https://login.example.com:8080/",
|
||||
false, false, true,
|
||||
ConformancePageAutheliaError,
|
||||
},
|
||||
{
|
||||
"ShouldPreferTheFirstFactorPageOverACoexistingErrorToast",
|
||||
"https://login.example.com:8080/",
|
||||
true, false, true,
|
||||
ConformancePageFirstFactor,
|
||||
},
|
||||
{
|
||||
"ShouldPreferTheConsentPageOverACoexistingErrorToast",
|
||||
"https://login.example.com:8080/consent/openid/decision",
|
||||
false, true, true,
|
||||
ConformancePageConsent,
|
||||
},
|
||||
{
|
||||
"ShouldReportUnknownWhenNothingMatches",
|
||||
"https://login.example.com:8080/",
|
||||
false, false, false,
|
||||
ConformancePageUnknown,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
assert.Equal(t, tc.expected, ConformanceClassifyPage(tc.url, tc.firstFactor, tc.consent, tc.autheliaError))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestConformanceConsentProgress(t *testing.T) {
|
||||
t.Run("ShouldTreatTheRevealedPasswordFieldAsTheReauthenticationStep", func(t *testing.T) {
|
||||
reauthentication, done := ConformanceConsentProgress(true, true, false)
|
||||
assert.True(t, reauthentication)
|
||||
assert.True(t, done)
|
||||
})
|
||||
|
||||
t.Run("ShouldTreatTheStageClearingAsTheFlowLeaving", func(t *testing.T) {
|
||||
reauthentication, done := ConformanceConsentProgress(false, false, false)
|
||||
assert.False(t, reauthentication)
|
||||
assert.True(t, done)
|
||||
})
|
||||
|
||||
t.Run("ShouldTreatAChangedURLAsTheFlowLeaving", func(t *testing.T) {
|
||||
reauthentication, done := ConformanceConsentProgress(false, true, true)
|
||||
assert.False(t, reauthentication)
|
||||
assert.True(t, done)
|
||||
})
|
||||
|
||||
t.Run("ShouldKeepWaitingWhileTheFormIsStillOnItsDecisionStep", func(t *testing.T) {
|
||||
reauthentication, done := ConformanceConsentProgress(false, true, false)
|
||||
assert.False(t, reauthentication)
|
||||
assert.False(t, done)
|
||||
})
|
||||
|
||||
t.Run("ShouldPreferTheReauthenticationStepOverTheFormLeaving", func(t *testing.T) {
|
||||
reauthentication, done := ConformanceConsentProgress(true, false, true)
|
||||
assert.True(t, reauthentication)
|
||||
assert.True(t, done)
|
||||
})
|
||||
}
|
||||
|
||||
func TestConformanceScreenshotDataURI(t *testing.T) {
|
||||
small, large := []byte("small"), make([]byte, conformanceScreenshotLimit+1)
|
||||
|
||||
t.Run("ShouldPreferAPNGWhichFits", func(t *testing.T) {
|
||||
uri, err := conformanceScreenshotDataURI(func(format proto.PageCaptureScreenshotFormat, quality int) ([]byte, error) {
|
||||
assert.Equal(t, proto.PageCaptureScreenshotFormatPng, format)
|
||||
|
||||
return small, nil
|
||||
})
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "data:image/png;base64,"+base64.StdEncoding.EncodeToString(small), uri)
|
||||
})
|
||||
|
||||
t.Run("ShouldFallBackToAJPEGWhenThePNGIsTooLarge", func(t *testing.T) {
|
||||
var asked []string
|
||||
|
||||
uri, err := conformanceScreenshotDataURI(func(format proto.PageCaptureScreenshotFormat, quality int) ([]byte, error) {
|
||||
asked = append(asked, fmt.Sprintf("%s:%d", format, quality))
|
||||
|
||||
if format == proto.PageCaptureScreenshotFormatPng {
|
||||
return large, nil
|
||||
}
|
||||
|
||||
return small, nil
|
||||
})
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "data:image/jpeg;base64,"+base64.StdEncoding.EncodeToString(small), uri)
|
||||
assert.Equal(t, []string{"png:0", "jpeg:80"}, asked)
|
||||
})
|
||||
|
||||
t.Run("ShouldFailWhenNothingFits", func(t *testing.T) {
|
||||
_, err := conformanceScreenshotDataURI(func(format proto.PageCaptureScreenshotFormat, quality int) ([]byte, error) {
|
||||
return large, nil
|
||||
})
|
||||
|
||||
assert.ErrorContains(t, err, "500KB")
|
||||
})
|
||||
|
||||
t.Run("ShouldFailWhenTheCaptureFails", func(t *testing.T) {
|
||||
_, err := conformanceScreenshotDataURI(func(format proto.PageCaptureScreenshotFormat, quality int) ([]byte, error) {
|
||||
return nil, errors.New("target closed")
|
||||
})
|
||||
|
||||
assert.ErrorContains(t, err, "target closed")
|
||||
})
|
||||
}
|
||||
|
||||
func TestConformanceIsCallbackRejectsAnUnparsableURL(t *testing.T) {
|
||||
assert.False(t, conformanceIsCallback("%zz/test/a/alias/callback"))
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
// SPDX-FileCopyrightText: 2026 Authelia
|
||||
//
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package suites
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.yaml.in/yaml/v4"
|
||||
)
|
||||
|
||||
func TestOIDCConformanceComposeSuppliesOIDCLoginPlaceholders(t *testing.T) {
|
||||
data, err := os.ReadFile("OIDCConformance/compose.yml")
|
||||
require.NoError(t, err)
|
||||
|
||||
compose := struct {
|
||||
Services map[string]struct {
|
||||
Environment map[string]string `yaml:"environment"`
|
||||
} `yaml:"services"`
|
||||
}{}
|
||||
|
||||
require.NoError(t, yaml.Unmarshal(data, &compose))
|
||||
|
||||
server, ok := compose.Services["conformance-server"]
|
||||
require.True(t, ok, "the compose file must define a conformance-server service")
|
||||
|
||||
for _, name := range []string{"OIDC_GOOGLE_CLIENTID", "OIDC_GOOGLE_SECRET", "OIDC_GITLAB_CLIENTID", "OIDC_GITLAB_SECRET"} {
|
||||
value, ok := server.Environment[name]
|
||||
|
||||
assert.Truef(t, ok, "%s must be set: the image's entrypoint interpolates it unconditionally, so leaving it unset overrides the suite's own default with an empty system property", name)
|
||||
assert.NotEmptyf(t, value, "%s must not be empty: Spring rejects an empty client id while building OAuth2ClientProperties, which fails the application context before devmode is consulted", name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOIDCConformanceComposeMongoDB(t *testing.T) {
|
||||
type service struct {
|
||||
Environment map[string]string `yaml:"environment"`
|
||||
DependsOn []string `yaml:"depends_on"`
|
||||
}
|
||||
|
||||
read := func(t *testing.T, path string) map[string]service {
|
||||
t.Helper()
|
||||
|
||||
data, err := os.ReadFile(path)
|
||||
require.NoError(t, err)
|
||||
|
||||
compose := struct {
|
||||
Services map[string]service `yaml:"services"`
|
||||
}{}
|
||||
|
||||
require.NoError(t, yaml.Unmarshal(data, &compose))
|
||||
|
||||
return compose.Services
|
||||
}
|
||||
|
||||
t.Run("ShouldLeaveTheDatabaseToItsOwnComposeFile", func(t *testing.T) {
|
||||
services := read(t, "OIDCConformance/compose.yml")
|
||||
|
||||
assert.NotContains(t, services, "conformance-mongodb")
|
||||
assert.NotContains(t, services["conformance-server"].DependsOn, "conformance-mongodb")
|
||||
assert.Equal(t, "${"+oidcConformanceMongoDBHostEnv+":-conformance-mongodb}", services["conformance-server"].Environment["MONGODB_HOST"],
|
||||
"the server uses the database the suite was pointed at, and its own otherwise")
|
||||
})
|
||||
|
||||
t.Run("ShouldRunTheDatabaseFromItsOwnComposeFile", func(t *testing.T) {
|
||||
services := read(t, oidcConformanceMongoDBComposeFile)
|
||||
|
||||
assert.Contains(t, services, "conformance-mongodb")
|
||||
assert.Contains(t, services["conformance-server"].DependsOn, "conformance-mongodb")
|
||||
})
|
||||
}
|
||||
|
||||
func TestOIDCConformanceComposeFiles(t *testing.T) {
|
||||
assert.Contains(t, oidcConformanceComposeFiles(""), "internal/suites/"+oidcConformanceMongoDBComposeFile)
|
||||
assert.Contains(t, oidcConformanceLogServices(""), "conformance-mongodb")
|
||||
|
||||
assert.NotContains(t, oidcConformanceComposeFiles("mongodb.example.com"), "internal/suites/"+oidcConformanceMongoDBComposeFile,
|
||||
"a database the suite is pointed at replaces its own")
|
||||
assert.NotContains(t, oidcConformanceLogServices("mongodb.example.com"), "conformance-mongodb", "there is no container to print the logs of")
|
||||
|
||||
for _, host := range []string{"", "mongodb.example.com"} {
|
||||
assert.Contains(t, oidcConformanceComposeFiles(host), "internal/suites/OIDCConformance/compose.yml")
|
||||
assert.Contains(t, oidcConformanceLogServices(host), "conformance-server")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOIDCConformanceServedByThePortal(t *testing.T) {
|
||||
base, err := url.ParseRequestURI(oidcConformanceBaseURL)
|
||||
require.NoError(t, err)
|
||||
|
||||
conf, err := os.ReadFile("example/compose/nginx/portal/nginx.conf")
|
||||
require.NoError(t, err)
|
||||
|
||||
nginx := string(conf)
|
||||
|
||||
t.Run("ShouldListenOnThePortTheBaseURLUses", func(t *testing.T) {
|
||||
assert.Containsf(t, nginx, "listen "+base.Port()+" ssl;",
|
||||
"the portal must terminate TLS on %s, which is the port %s resolves to", base.Port(), oidcConformanceBaseURL)
|
||||
})
|
||||
|
||||
t.Run("ShouldServeTheConformanceHost", func(t *testing.T) {
|
||||
assert.Equal(t, 3, strings.Count(nginx, `server_name ~^conformance\.example([0-9])*\.com$;`),
|
||||
"one server block per listener the published nginx image exposed")
|
||||
assert.Equal(t, 3, strings.Count(nginx, "set $upstream_endpoint http://conformance-server:8080;"),
|
||||
"the portal proxies straight to the server now that the published nginx image is gone")
|
||||
})
|
||||
|
||||
t.Run("ShouldExposeTheMutualTLSAndFAPIListeners", func(t *testing.T) {
|
||||
// 8444 and 8445 carry no traffic yet, but a profile that reaches for them should find them rather than
|
||||
// fail part way through a plan, and they are the reason the published image existed at all.
|
||||
assert.Contains(t, nginx, "listen 8444 ssl;")
|
||||
assert.Contains(t, nginx, "listen 8445 ssl;")
|
||||
|
||||
assert.Equal(t, 2, strings.Count(nginx, "ssl_verify_client optional_no_ca;"),
|
||||
"the mutual TLS listeners ask for a client certificate but leave verifying it to the suite")
|
||||
assert.Contains(t, nginx, "ssl_ciphers ECDHE-RSA-AES128-GCM-SHA256:ECDHE-RSA-AES256-GCM-SHA384;",
|
||||
"8445 narrows the ciphers to the FAPI 2.0 Final set")
|
||||
})
|
||||
|
||||
t.Run("ShouldReproduceWhatThePublishedImageSet", func(t *testing.T) {
|
||||
// The suite depends on each of these: large headers carry its request objects, the long timeouts outlast
|
||||
// requests that block up to 60s on a callback, and it rebuilds its own base URL from the forwarded headers.
|
||||
for _, directive := range []string{
|
||||
"client_header_buffer_size 32k;",
|
||||
"large_client_header_buffers 4 32k;",
|
||||
"proxy_read_timeout 120s;",
|
||||
"proxy_send_timeout 120s;",
|
||||
"proxy_set_header X-Forwarded-Host $host;",
|
||||
"proxy_set_header X-Forwarded-Port $server_port;",
|
||||
"proxy_set_header X-Ssl-Cert $ssl_client_cert;",
|
||||
} {
|
||||
assert.Equalf(t, 3, strings.Count(nginx, directive),
|
||||
"the published nginx image set %s on every listener and the suite relies on it", directive)
|
||||
}
|
||||
|
||||
// Only the listener that does not ask for a client certificate tells the suite so.
|
||||
assert.Equal(t, 1, strings.Count(nginx, "proxy_set_header X-Test-Mtls-Called-On-Wrong-Host $mtls_wrong_host;"))
|
||||
assert.Equal(t, 1, strings.Count(nginx, "ssl_verify_client off;"))
|
||||
})
|
||||
|
||||
t.Run("ShouldResolveTheConformanceHostToThePortal", func(t *testing.T) {
|
||||
entries := map[string]string{}
|
||||
|
||||
for _, entry := range HostEntries() {
|
||||
entries[entry.Domain] = entry.IP
|
||||
}
|
||||
|
||||
require.Contains(t, entries, base.Hostname())
|
||||
assert.Equal(t, entries["login.example.com"], entries[base.Hostname()],
|
||||
"the conformance host is served by the portal, so it resolves where every other portal domain does")
|
||||
})
|
||||
|
||||
t.Run("ShouldRunThePortal", func(t *testing.T) {
|
||||
assert.Contains(t, oidcConformanceComposeFiles(""), "internal/suites/example/compose/nginx/portal/compose.yml")
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
// SPDX-FileCopyrightText: 2026 Authelia
|
||||
//
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package suites
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
conformanceDiagnosticEntries = 20
|
||||
conformanceDiagnosticValue = 2000
|
||||
)
|
||||
|
||||
var (
|
||||
conformanceDiagnosticResults = map[string]bool{"FAILURE": true, "WARNING": true, "REVIEW": true}
|
||||
conformanceDiagnosticSkip = map[string]bool{
|
||||
"msg": true, "result": true, "src": true, "requirements": true,
|
||||
"_id": true, "testId": true, "testOwner": true, "time": true,
|
||||
"blockId": true, "updatedAt": true, "img": true, "upload": true,
|
||||
}
|
||||
)
|
||||
|
||||
// ConformanceDiagnostics renders the graded entries of a module's log for a failure message. Each entry keeps its
|
||||
// condition's own arguments, which is where a conformance failure actually explains itself: the message says a claim
|
||||
// was missing, the arguments say which one.
|
||||
func ConformanceDiagnostics(entries []ConformanceLogEntry) string {
|
||||
builder := &strings.Builder{}
|
||||
|
||||
reported := 0
|
||||
|
||||
for _, entry := range entries {
|
||||
if !conformanceDiagnosticResults[entry.Result] {
|
||||
continue
|
||||
}
|
||||
|
||||
if reported == conformanceDiagnosticEntries {
|
||||
fmt.Fprintf(builder, "\n ... further entries omitted, see the exported plan archive for the complete log")
|
||||
|
||||
break
|
||||
}
|
||||
|
||||
reported++
|
||||
|
||||
fmt.Fprintf(builder, "\n [%s] %s", entry.Result, entry.Msg)
|
||||
|
||||
if entry.Src != "" {
|
||||
fmt.Fprintf(builder, "\n condition: %s", entry.Src)
|
||||
}
|
||||
|
||||
if requirements := conformanceDiagnosticField(entry.Fields["requirements"]); requirements != "" {
|
||||
fmt.Fprintf(builder, "\n requirements: %s", requirements)
|
||||
}
|
||||
|
||||
for _, name := range conformanceDiagnosticNames(entry.Fields) {
|
||||
fmt.Fprintf(builder, "\n %s: %s", name, conformanceDiagnosticField(entry.Fields[name]))
|
||||
}
|
||||
}
|
||||
|
||||
if reported == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
return builder.String()
|
||||
}
|
||||
|
||||
func conformanceDiagnosticNames(fields map[string]any) (names []string) {
|
||||
for name := range fields {
|
||||
if conformanceDiagnosticSkip[name] {
|
||||
continue
|
||||
}
|
||||
|
||||
names = append(names, name)
|
||||
}
|
||||
|
||||
sort.Strings(names)
|
||||
|
||||
return names
|
||||
}
|
||||
|
||||
func conformanceDiagnosticField(value any) string {
|
||||
if value == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
rendered, ok := value.(string)
|
||||
|
||||
if !ok {
|
||||
data, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
return fmt.Sprintf("%v", value)
|
||||
}
|
||||
|
||||
rendered = string(data)
|
||||
}
|
||||
|
||||
if len(rendered) > conformanceDiagnosticValue {
|
||||
return rendered[:conformanceDiagnosticValue] + fmt.Sprintf("... (%d bytes truncated)", len(rendered)-conformanceDiagnosticValue)
|
||||
}
|
||||
|
||||
return rendered
|
||||
}
|
||||
|
||||
// ConformanceAcceptedWarningReport renders a module's accepted WARNING for its subtest's log: why it is accepted,
|
||||
// followed by the graded entries of the module's log, so the warning which was accepted is visible rather than only
|
||||
// the fact that one was.
|
||||
func ConformanceAcceptedWarningReport(module, status, reason string, entries []ConformanceLogEntry) string {
|
||||
report := fmt.Sprintf("Module '%s' finished with the accepted result 'WARNING' and the status '%s': %s.", module, status, reason)
|
||||
|
||||
if detail := ConformanceDiagnostics(entries); detail != "" {
|
||||
return report + " Conformance log entries:" + detail
|
||||
}
|
||||
|
||||
return report + " The module logged no warning entries."
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
// SPDX-FileCopyrightText: 2026 Authelia
|
||||
//
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package suites
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestConformanceDiagnostics(t *testing.T) {
|
||||
scopeFailure := `[
|
||||
{"result":"SUCCESS","src":"CallUserInfoEndpoint","msg":"Userinfo endpoint response was 200"},
|
||||
{"result":"INFO","src":"ExtractUserInfo","msg":"Extracted userinfo"},
|
||||
{"result":"WARNING","src":"VerifyScopesReturnedInUserInfoClaims","msg":"'claims' in userinfo doesn't contain all scope items of scope in authorization request","requirements":["OIDCC-5.4"],
|
||||
"expected_scope_items":["name","given_name","birthdate"],"actual_scope_items":["name"],"missing_items":["given_name","birthdate"],
|
||||
"_id":"abc","testId":"t1","testOwner":"anon","time":1789021616378}
|
||||
]`
|
||||
|
||||
var entries []ConformanceLogEntry
|
||||
|
||||
require.NoError(t, json.Unmarshal([]byte(scopeFailure), &entries))
|
||||
|
||||
out := ConformanceDiagnostics(entries)
|
||||
|
||||
t.Run("ShouldReportTheGradedEntry", func(t *testing.T) {
|
||||
assert.Contains(t, out, "[WARNING]")
|
||||
assert.Contains(t, out, "doesn't contain all scope items")
|
||||
assert.Contains(t, out, "condition: VerifyScopesReturnedInUserInfoClaims")
|
||||
assert.Contains(t, out, `requirements: ["OIDCC-5.4"]`)
|
||||
})
|
||||
|
||||
t.Run("ShouldReportTheConditionArgumentsThatExplainIt", func(t *testing.T) {
|
||||
assert.Contains(t, out, `missing_items: ["given_name","birthdate"]`)
|
||||
assert.Contains(t, out, `expected_scope_items: ["name","given_name","birthdate"]`)
|
||||
assert.Contains(t, out, `actual_scope_items: ["name"]`)
|
||||
})
|
||||
|
||||
t.Run("ShouldOmitUngradedNarrativeAndBookkeeping", func(t *testing.T) {
|
||||
assert.NotContains(t, out, "Userinfo endpoint response was 200")
|
||||
assert.NotContains(t, out, "Extracted userinfo")
|
||||
assert.NotContains(t, out, "testOwner")
|
||||
assert.NotContains(t, out, "1789021616378")
|
||||
})
|
||||
|
||||
t.Run("ShouldOrderFieldsStablyAcrossRuns", func(t *testing.T) {
|
||||
for i := 0; i < 8; i++ {
|
||||
var repeat []ConformanceLogEntry
|
||||
|
||||
require.NoError(t, json.Unmarshal([]byte(scopeFailure), &repeat))
|
||||
assert.Equal(t, out, ConformanceDiagnostics(repeat))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestConformanceDiagnosticsReportsNothingWhenEveryEntryPassed(t *testing.T) {
|
||||
entries := []ConformanceLogEntry{
|
||||
{Result: "SUCCESS", Msg: "fine"},
|
||||
{Result: "INFO", Msg: "also fine"},
|
||||
}
|
||||
|
||||
assert.Empty(t, ConformanceDiagnostics(entries))
|
||||
}
|
||||
|
||||
func TestConformanceDiagnosticsTruncatesAValueLargeEnoughToBuryTheRest(t *testing.T) {
|
||||
entries := []ConformanceLogEntry{{
|
||||
Result: "FAILURE",
|
||||
Msg: "token endpoint returned an error",
|
||||
Fields: map[string]any{"result": "FAILURE", "msg": "token endpoint returned an error", "http_response_body": strings.Repeat("x", conformanceDiagnosticValue+500)},
|
||||
}}
|
||||
|
||||
out := ConformanceDiagnostics(entries)
|
||||
|
||||
assert.Contains(t, out, "500 bytes truncated")
|
||||
assert.Less(t, len(out), conformanceDiagnosticValue+400)
|
||||
}
|
||||
|
||||
func TestConformanceDiagnosticsCapsTheNumberOfEntries(t *testing.T) {
|
||||
entries := make([]ConformanceLogEntry, 0, conformanceDiagnosticEntries+5)
|
||||
|
||||
for i := 0; i < conformanceDiagnosticEntries+5; i++ {
|
||||
entries = append(entries, ConformanceLogEntry{Result: "FAILURE", Msg: "failed"})
|
||||
}
|
||||
|
||||
out := ConformanceDiagnostics(entries)
|
||||
|
||||
assert.Equal(t, conformanceDiagnosticEntries, strings.Count(out, "[FAILURE]"))
|
||||
assert.Contains(t, out, "further entries omitted")
|
||||
}
|
||||
|
||||
func TestConformanceAcceptedWarningReport(t *testing.T) {
|
||||
entries := []ConformanceLogEntry{
|
||||
{Msg: "Checked the id_token", Result: "SUCCESS", Src: "ValidateIdToken"},
|
||||
{Msg: "acr value in id_token is not (one of the) requested values", Result: "WARNING", Src: "ValidateIdTokenACRClaimAgainstAcrValuesRequest",
|
||||
Fields: map[string]any{"requirements": []any{"OIDCC-3.1.2.1", "OIDCC-15.1"}, "id_token_acr": "0"}},
|
||||
}
|
||||
|
||||
report := ConformanceAcceptedWarningReport("oidcc-ensure-request-with-acr-values-succeeds", "FINISHED", "the acr is the fallback", entries)
|
||||
|
||||
assert.Contains(t, report, "Module 'oidcc-ensure-request-with-acr-values-succeeds' finished with the accepted result 'WARNING' and the status 'FINISHED': the acr is the fallback.")
|
||||
assert.Contains(t, report, "[WARNING] acr value in id_token is not (one of the) requested values")
|
||||
assert.Contains(t, report, "condition: ValidateIdTokenACRClaimAgainstAcrValuesRequest")
|
||||
assert.Contains(t, report, "id_token_acr: 0")
|
||||
assert.NotContains(t, report, "Checked the id_token", "only the graded entries are reported")
|
||||
|
||||
assert.Equal(t, "Module 'oidcc-server' finished with the accepted result 'WARNING' and the status 'FINISHED': reason. The module logged no warning entries.",
|
||||
ConformanceAcceptedWarningReport("oidcc-server", "FINISHED", "reason", nil))
|
||||
}
|
||||
|
||||
func TestConformanceDiagnosticsOmitsTheConformanceUIsBookkeeping(t *testing.T) {
|
||||
detail := ConformanceDiagnostics([]ConformanceLogEntry{
|
||||
{Msg: "acr value in id_token is not (one of the) requested values", Result: "WARNING",
|
||||
Fields: map[string]any{"blockId": "a1c507", "updatedAt": float64(1789035070157), "id_token_acr": "0"}},
|
||||
})
|
||||
|
||||
assert.Contains(t, detail, "id_token_acr: 0")
|
||||
assert.NotContains(t, detail, "blockId")
|
||||
assert.NotContains(t, detail, "updatedAt")
|
||||
}
|
||||
|
||||
func TestConformanceDiagnosticFieldFallsBackForAValueJSONCannotEncode(t *testing.T) {
|
||||
assert.Equal(t, "(1+2i)", conformanceDiagnosticField(complex(1, 2)))
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
// SPDX-FileCopyrightText: 2026 Authelia
|
||||
//
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package suites
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestOIDCConformanceGenerateReportsAFileItCannotWrite(t *testing.T) {
|
||||
t.Setenv("SUITE_TMP_PATH", filepath.Join(t.TempDir(), "missing"))
|
||||
|
||||
assert.Error(t, oidcConformanceGenerate())
|
||||
assert.Error(t, oidcConformanceWriteJSON(SuiteTmpPath(oidcConformancePlansFile), []OIDCConformancePlanFile{}))
|
||||
}
|
||||
|
||||
func TestOIDCConformanceReadPlans(t *testing.T) {
|
||||
t.Run("ShouldReportPlansWhichWereNeverGenerated", func(t *testing.T) {
|
||||
t.Setenv("SUITE_TMP_PATH", t.TempDir())
|
||||
|
||||
_, err := oidcConformanceReadPlans()
|
||||
|
||||
assert.ErrorContains(t, err, "error reading the generated conformance plans, was the suite set up?")
|
||||
})
|
||||
|
||||
t.Run("ShouldReportPlansWhichCannotBeDecoded", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
|
||||
t.Setenv("SUITE_TMP_PATH", dir)
|
||||
|
||||
require.NoError(t, os.WriteFile(filepath.Join(dir, oidcConformancePlansFile), []byte("{"), 0600))
|
||||
|
||||
_, err := oidcConformanceReadPlans()
|
||||
|
||||
assert.Error(t, err)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
// SPDX-FileCopyrightText: 2026 Authelia
|
||||
//
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package suites
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"strings"
|
||||
"unicode"
|
||||
)
|
||||
|
||||
const conformanceModulePrefix = "oidcc-"
|
||||
|
||||
// ConformanceSubtestNames returns the PascalCase subtest name of each module, index aligned with modules. A module
|
||||
// which a plan lists more than once has its variant appended so the names within a plan stay unique, which is what
|
||||
// makes go test able to address a single one with -run.
|
||||
func ConformanceSubtestNames(modules []ConformancePlanModule) (names []string) {
|
||||
counts := make(map[string]int, len(modules))
|
||||
|
||||
for _, module := range modules {
|
||||
counts[module.TestModule]++
|
||||
}
|
||||
|
||||
names = make([]string, len(modules))
|
||||
|
||||
for i, module := range modules {
|
||||
name := conformancePascalCase(strings.TrimPrefix(module.TestModule, conformanceModulePrefix))
|
||||
|
||||
if counts[module.TestModule] > 1 {
|
||||
name += conformanceVariantSuffix(module.Variant)
|
||||
}
|
||||
|
||||
names[i] = name
|
||||
}
|
||||
|
||||
return names
|
||||
}
|
||||
|
||||
func conformanceVariantSuffix(variant map[string]string) (suffix string) {
|
||||
keys := make([]string, 0, len(variant))
|
||||
|
||||
for key := range variant {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
|
||||
sort.Strings(keys)
|
||||
|
||||
for _, key := range keys {
|
||||
suffix += conformancePascalCase(variant[key])
|
||||
}
|
||||
|
||||
return suffix
|
||||
}
|
||||
|
||||
func conformancePascalCase(value string) string {
|
||||
builder := &strings.Builder{}
|
||||
|
||||
for _, part := range strings.FieldsFunc(value, func(r rune) bool {
|
||||
return !unicode.IsLetter(r) && !unicode.IsDigit(r)
|
||||
}) {
|
||||
runes := []rune(strings.ToLower(part))
|
||||
runes[0] = unicode.ToUpper(runes[0])
|
||||
|
||||
builder.WriteString(string(runes))
|
||||
}
|
||||
|
||||
return builder.String()
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
// SPDX-FileCopyrightText: 2026 Authelia
|
||||
//
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package suites
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestConformanceSubtestNames(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
have []ConformancePlanModule
|
||||
expected []string
|
||||
}{
|
||||
{
|
||||
"ShouldStripPrefixAndPascalCase",
|
||||
[]ConformancePlanModule{{TestModule: "oidcc-server"}},
|
||||
[]string{"Server"},
|
||||
},
|
||||
{
|
||||
"ShouldPascalCaseEverySegment",
|
||||
[]ConformancePlanModule{{TestModule: "oidcc-ensure-request-without-nonce-succeeds-for-code-flow"}},
|
||||
[]string{"EnsureRequestWithoutNonceSucceedsForCodeFlow"},
|
||||
},
|
||||
{
|
||||
"ShouldKeepDigits",
|
||||
[]ConformancePlanModule{{TestModule: "oidcc-max-age-10000"}},
|
||||
[]string{"MaxAge10000"},
|
||||
},
|
||||
{
|
||||
"ShouldHandleModulesWithoutThePrefix",
|
||||
[]ConformancePlanModule{{TestModule: "oidcc-config-certification"}, {TestModule: "discovery-issuer-not-matching-config"}},
|
||||
[]string{"ConfigCertification", "DiscoveryIssuerNotMatchingConfig"},
|
||||
},
|
||||
{
|
||||
"ShouldNotSuffixUniqueModules",
|
||||
[]ConformancePlanModule{
|
||||
{TestModule: "oidcc-server", Variant: map[string]string{"response_type": "code"}},
|
||||
{TestModule: "oidcc-scope-address", Variant: map[string]string{"response_type": "code"}},
|
||||
},
|
||||
[]string{"Server", "ScopeAddress"},
|
||||
},
|
||||
{
|
||||
"ShouldSuffixRepeatedModulesWithTheirVariant",
|
||||
[]ConformancePlanModule{
|
||||
{TestModule: "oidcc-server", Variant: map[string]string{"response_type": "code id_token"}},
|
||||
{TestModule: "oidcc-server", Variant: map[string]string{"response_type": "code id_token token"}},
|
||||
},
|
||||
[]string{"ServerCodeIdToken", "ServerCodeIdTokenToken"},
|
||||
},
|
||||
{
|
||||
"ShouldOrderVariantKeysDeterministically",
|
||||
[]ConformancePlanModule{
|
||||
{TestModule: "oidcc-server", Variant: map[string]string{"response_type": "code", "response_mode": "form_post"}},
|
||||
{TestModule: "oidcc-server", Variant: map[string]string{"response_type": "code", "response_mode": "query"}},
|
||||
},
|
||||
[]string{"ServerFormPostCode", "ServerQueryCode"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
assert.Equal(t, tc.expected, ConformanceSubtestNames(tc.have))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestConformanceResultAccepted(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
module string
|
||||
result string
|
||||
expected bool
|
||||
}{
|
||||
{"ShouldAcceptPassedFromAnyModule", "oidcc-server", "PASSED", true},
|
||||
{"ShouldAcceptReviewFromAnyModule", "oidcc-server", "REVIEW", true},
|
||||
{"ShouldAcceptSkippedFromAnyModule", "oidcc-server", "SKIPPED", true},
|
||||
{"ShouldAcceptAWarningFromAListedModule", "oidcc-ensure-request-with-acr-values-succeeds", "WARNING", true},
|
||||
{"ShouldRejectAWarningFromAnUnlistedModule", "oidcc-scope-profile", "WARNING", false},
|
||||
{"ShouldRejectAFailureFromAListedModule", "oidcc-ensure-request-with-acr-values-succeeds", "FAILED", false},
|
||||
{"ShouldRejectAFailure", "oidcc-server", "FAILED", false},
|
||||
{"ShouldRejectAnUnknownResult", "oidcc-server", "", false},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
assert.Equal(t, tc.expected, conformanceResultAccepted(tc.module, tc.result))
|
||||
})
|
||||
}
|
||||
|
||||
for module, reason := range conformanceAcceptedWarnings {
|
||||
assert.NotEmptyf(t, reason, "the WARNING accepted from '%s' must say why it is expected", module)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConformanceArchivePath(t *testing.T) {
|
||||
t.Run("ShouldWriteWhereTheCIArtifactPathCollectsIt", func(t *testing.T) {
|
||||
t.Setenv("CI", "true")
|
||||
|
||||
assert.Equal(t, filepath.Join("..", "..", "oidc-conformance-plans", "conformance-basic.zip"), conformanceArchivePath("basic"))
|
||||
})
|
||||
|
||||
t.Run("ShouldWriteToTheTemporaryDirectoryOutsideCI", func(t *testing.T) {
|
||||
t.Setenv("CI", "")
|
||||
|
||||
assert.Equal(t, filepath.Join(os.TempDir(), "authelia-suites-oidc-conformance-plans", composeProjectName(), "conformance-basic.zip"), conformanceArchivePath("basic"))
|
||||
})
|
||||
}
|
||||
|
||||
func TestBuildkiteLogMarkers(t *testing.T) {
|
||||
t.Run("ShouldMarkGroupsInBuildkiteWhenDebugging", func(t *testing.T) {
|
||||
t.Setenv("BUILDKITE", "true")
|
||||
t.Setenv("SUITE_DEBUG", "true")
|
||||
|
||||
assert.Equal(t, "--- OIDC Conformance Plan: basic", buildkiteGroup("OIDC Conformance Plan: basic"))
|
||||
assert.Equal(t, "^^^ +++", buildkiteExpandGroup())
|
||||
})
|
||||
|
||||
t.Run("ShouldMarkNothingInBuildkiteWhenNotDebugging", func(t *testing.T) {
|
||||
t.Setenv("BUILDKITE", "true")
|
||||
t.Setenv("SUITE_DEBUG", "")
|
||||
|
||||
assert.Empty(t, buildkiteGroup("OIDC Conformance Plan: basic"))
|
||||
assert.Empty(t, buildkiteExpandGroup())
|
||||
})
|
||||
|
||||
t.Run("ShouldMarkNothingOutsideBuildkite", func(t *testing.T) {
|
||||
t.Setenv("BUILDKITE", "")
|
||||
t.Setenv("SUITE_DEBUG", "true")
|
||||
|
||||
assert.Empty(t, buildkiteGroup("OIDC Conformance Plan: basic"))
|
||||
assert.Empty(t, buildkiteExpandGroup())
|
||||
})
|
||||
}
|
||||
|
||||
func TestConformancePlanSummary(t *testing.T) {
|
||||
assert.Equal(t, "Plan 'basic': 45 accepted, 2 failed, 1 skipped", conformancePlanSummary("basic", 45, 2, 1))
|
||||
}
|
||||
|
||||
func TestConformanceArchiveReference(t *testing.T) {
|
||||
t.Run("ShouldLinkTheJobsArtifactsInBuildkite", func(t *testing.T) {
|
||||
t.Setenv("CI", "true")
|
||||
t.Setenv("BUILDKITE_BUILD_URL", "https://buildkite.com/authelia/authelia/builds/58611")
|
||||
t.Setenv("BUILDKITE_JOB_ID", "0192")
|
||||
|
||||
assert.Equal(t, "oidc-conformance-plans/conformance-basic.zip in the job's artifacts: https://buildkite.com/authelia/authelia/builds/58611/waterfall?jid=0192&tab=artifacts",
|
||||
conformanceArchiveReference("basic", "https://conformance.example.com:8443/log-detail.html?log=m1"))
|
||||
})
|
||||
|
||||
t.Run("ShouldPointAtTheLiveLogElsewhere", func(t *testing.T) {
|
||||
t.Setenv("CI", "")
|
||||
t.Setenv("BUILDKITE_BUILD_URL", "")
|
||||
t.Setenv("BUILDKITE_JOB_ID", "")
|
||||
|
||||
assert.Equal(t, conformanceArchivePath("basic")+", or the live log while the suite is up: https://conformance.example.com:8443/log-detail.html?log=m1",
|
||||
conformanceArchiveReference("basic", "https://conformance.example.com:8443/log-detail.html?log=m1"))
|
||||
})
|
||||
}
|
||||
|
||||
func TestOIDCConformanceRelease(t *testing.T) {
|
||||
assert.Equal(t, "v4.39.24 (commit 7498f635a5f3)", oidcConformanceRelease("7498f635a5f3", "v4.39.24"))
|
||||
assert.Equal(t, "commit 7498f635a5f3", oidcConformanceRelease("7498f635a5f3", ""))
|
||||
assert.Empty(t, oidcConformanceRelease("", ""), "without a commit the builder's own version is left in place")
|
||||
}
|
||||
|
||||
func TestConformanceOutcomeAccepted(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
outcome ConformanceOutcome
|
||||
expected bool
|
||||
}{
|
||||
{"ShouldAcceptAFinishedPass", ConformanceOutcome{Module: "oidcc-server", Status: "FINISHED", Result: "PASSED"}, true},
|
||||
{"ShouldAcceptAFinishedListedWarning", ConformanceOutcome{Module: "oidcc-ensure-request-with-acr-values-succeeds", Status: "FINISHED", Result: "WARNING"}, true},
|
||||
{"ShouldRejectAnInterruptedReview", ConformanceOutcome{Module: "oidcc-server", Status: "INTERRUPTED", Result: "REVIEW"}, false},
|
||||
{"ShouldRejectAnInterruptedPass", ConformanceOutcome{Module: "oidcc-server", Status: "INTERRUPTED", Result: "PASSED"}, false},
|
||||
{"ShouldRejectAFinishedFailure", ConformanceOutcome{Module: "oidcc-server", Status: "FINISHED", Result: "FAILED"}, false},
|
||||
{"ShouldRejectASuiteError", ConformanceOutcome{Module: "oidcc-server", Status: "FINISHED", Result: "PASSED", Err: errors.New("stalled")}, false},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
assert.Equal(t, tc.expected, conformanceOutcomeAccepted(tc.outcome))
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
// SPDX-FileCopyrightText: 2026 Authelia
|
||||
//
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package suites
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestConformanceProgress(t *testing.T) {
|
||||
patience := time.Second * 30
|
||||
start := time.Date(2026, 9, 10, 12, 0, 0, 0, time.UTC)
|
||||
|
||||
t.Run("ShouldStallOnAnUnrecognizedPageThatNeverMoves", func(t *testing.T) {
|
||||
progress := newConformanceProgress(patience, start)
|
||||
|
||||
assert.False(t, progress.observe(start, "https://stuck.example.com", false))
|
||||
assert.False(t, progress.observe(start.Add(time.Second*29), "https://stuck.example.com", false))
|
||||
assert.True(t, progress.observe(start.Add(time.Second*31), "https://stuck.example.com", false))
|
||||
})
|
||||
|
||||
t.Run("ShouldNotStallWhileTheURLKeepsChanging", func(t *testing.T) {
|
||||
progress := newConformanceProgress(patience, start)
|
||||
|
||||
for i, at := range []time.Duration{0, time.Second * 25, time.Second * 50, time.Second * 75} {
|
||||
assert.Falsef(t, progress.observe(start.Add(at), "https://hop.example.com/"+string(rune('a'+i)), false), "hop %d", i)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("ShouldNotStallOnAPageTheDriverIsActingOn", func(t *testing.T) {
|
||||
progress := newConformanceProgress(patience, start)
|
||||
|
||||
assert.False(t, progress.observe(start, "https://login.example.com/consent", true))
|
||||
assert.False(t, progress.observe(start.Add(time.Second*45), "https://login.example.com/consent", true))
|
||||
assert.False(t, progress.observe(start.Add(time.Second*60), "https://login.example.com/consent", true))
|
||||
})
|
||||
|
||||
t.Run("ShouldStallOnceAPageStopsBeingActedOn", func(t *testing.T) {
|
||||
progress := newConformanceProgress(patience, start)
|
||||
|
||||
assert.False(t, progress.observe(start, "https://login.example.com/consent", true))
|
||||
assert.False(t, progress.observe(start.Add(time.Second*10), "https://login.example.com/consent", false))
|
||||
assert.True(t, progress.observe(start.Add(time.Second*41), "https://login.example.com/consent", false))
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,568 @@
|
||||
// SPDX-FileCopyrightText: 2026 Authelia
|
||||
//
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package suites
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
conformanceReauthenticationLeg = 1
|
||||
conformanceStatusConfigured = "CONFIGURED"
|
||||
conformanceStatusWaiting = "WAITING"
|
||||
conformanceStatusFinished = "FINISHED"
|
||||
conformanceStatusInterrupted = "INTERRUPTED"
|
||||
conformanceModuleTimeout = time.Minute * 5
|
||||
conformancePlaceholderTimeout = time.Second * 30
|
||||
conformanceUploadSettleInterval = time.Second
|
||||
conformanceUploadSettleTimeout = time.Second * 10
|
||||
conformancePlaceholderInterval = time.Second
|
||||
conformanceOutcomeInfoTimeout = time.Second * 10
|
||||
conformanceErrorSettleTimeout = time.Second * 30
|
||||
)
|
||||
|
||||
var (
|
||||
conformanceTerminalStates = []string{conformanceStatusFinished, conformanceStatusInterrupted}
|
||||
|
||||
conformanceOverrides = map[string]ConformanceOverride{
|
||||
// The second authorization carries prompt=login, so Authelia must ask for credentials again despite the session.
|
||||
"oidcc-prompt-login": {Assert: conformanceAssertReauthentication, Screenshot: ConformanceScreenshotReauthentication},
|
||||
|
||||
// The max age one module sets max_age=1 with a second of delay must force re-authentication and an auth_time claim.
|
||||
"oidcc-max-age-1": {Assert: conformanceAssertReauthentication, Screenshot: ConformanceScreenshotReauthentication},
|
||||
|
||||
// The max age 1000 module sets max_age=10000 is longer than the session has existed, so the provider must not ask
|
||||
// again.
|
||||
"oidcc-max-age-10000": {Assert: conformanceAssertNoReauthentication},
|
||||
|
||||
// The redirect_uri is not registered, so Authelia must show its own error page rather than send the browser to it.
|
||||
// This is the page the module's screenshot placeholder asks for.
|
||||
"oidcc-ensure-registered-redirect-uri": {Assert: conformanceAssertErrorPage, Screenshot: ConformanceScreenshotErrorPage},
|
||||
|
||||
// The request object carries a registered redirect_uri and the query an unregistered one. The module accepts either
|
||||
// Authelia using the request object's and completing the flow, or showing its error page, and only asks for a
|
||||
// screenshot of the latter.
|
||||
"oidcc-ensure-request-object-with-redirect-uri": {Screenshot: ConformanceScreenshotErrorPage},
|
||||
|
||||
// Every module whose upstream summary says to remove any cookies received from the provider, and no others. The
|
||||
// plans also carry oidcc-registration-logo-uri, -policy-uri and -tos-uri under that instruction, but those are
|
||||
// Dynamic OP modules and none of the profiles Authelia is certified for includes them.
|
||||
//
|
||||
// oidcc-prompt-none-not-logged-in is the one where a stale session is not merely untidy: prompt=none against a
|
||||
// live session returns a code, and the module is asserting that the provider errors instead, so it fails.
|
||||
"oidcc-display-page": {ClearCookies: true},
|
||||
"oidcc-display-popup": {ClearCookies: true},
|
||||
"oidcc-login-hint": {ClearCookies: true},
|
||||
"oidcc-prompt-none-not-logged-in": {ClearCookies: true},
|
||||
"oidcc-ui-locales": {ClearCookies: true},
|
||||
}
|
||||
|
||||
conformanceUnattended = map[string]string{
|
||||
"oidcc-server-rotate-keys": "requires the provider's signing keys to be rotated by hand while the module waits",
|
||||
}
|
||||
)
|
||||
|
||||
// ConformanceOutcome is the result of one module, produced off the test goroutine and asserted on it.
|
||||
type ConformanceOutcome struct {
|
||||
Name string
|
||||
Module string
|
||||
ID string
|
||||
Result string
|
||||
Status string
|
||||
LogURL string
|
||||
Skipped bool
|
||||
Reason string
|
||||
Err error
|
||||
Trace []string
|
||||
}
|
||||
|
||||
// ConformanceOverride is the per module behavior which differs from the reactive default.
|
||||
type ConformanceOverride struct {
|
||||
// ClearCookies discards the context's cookies before the module's first browser leg, for the modules whose
|
||||
// instructions are to remove any cookies received from the provider before proceeding.
|
||||
ClearCookies bool
|
||||
|
||||
// Assert stands in for the screenshot the conformance suite would otherwise want. It is handed what the driver
|
||||
// observed over one browser leg, once that leg has been driven to its end, and its error becomes the module's
|
||||
// verdict. It is given the record rather than the browser because by the time a leg ends the browser is on the
|
||||
// conformance suite's callback, so nothing about Authelia's pages can be established from the live DOM any more.
|
||||
// The record carries the leg's index, so an expectation which belongs to the second authorization round trip is
|
||||
// not applied to the first.
|
||||
Assert func(leg ConformanceLeg) error
|
||||
|
||||
// Screenshot is the page this module's screenshot placeholder asks for. A placeholder is only filled with a capture
|
||||
// of that page, so one from a module without it is left unfilled and the module stalls.
|
||||
Screenshot ConformanceScreenshot
|
||||
}
|
||||
|
||||
// ConformanceScreenshot is a page a module's screenshot placeholder asks for.
|
||||
type ConformanceScreenshot int
|
||||
|
||||
const (
|
||||
// ConformanceScreenshotNone is a module which asks for no screenshot.
|
||||
ConformanceScreenshotNone ConformanceScreenshot = iota
|
||||
|
||||
// ConformanceScreenshotReauthentication is Authelia asking for the password again on the second authorization.
|
||||
ConformanceScreenshotReauthentication
|
||||
|
||||
// ConformanceScreenshotErrorPage is Authelia's error page.
|
||||
ConformanceScreenshotErrorPage
|
||||
)
|
||||
|
||||
func (o ConformanceOverride) screenshotOf(leg ConformanceLeg) string {
|
||||
switch o.Screenshot {
|
||||
case ConformanceScreenshotReauthentication:
|
||||
if leg.Index == conformanceReauthenticationLeg {
|
||||
return leg.LoginScreenshot
|
||||
}
|
||||
case ConformanceScreenshotErrorPage:
|
||||
return leg.ErrorScreenshot
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
func conformanceAssertReauthentication(leg ConformanceLeg) error {
|
||||
if leg.Index != conformanceReauthenticationLeg {
|
||||
return nil
|
||||
}
|
||||
|
||||
if !leg.Reauthentication && !leg.FirstFactor {
|
||||
return errors.New("expected Authelia to ask for the password again on the second authorization but it did not")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func conformanceAssertNoReauthentication(leg ConformanceLeg) error {
|
||||
if leg.Index != conformanceReauthenticationLeg {
|
||||
return nil
|
||||
}
|
||||
|
||||
if leg.Reauthentication || leg.FirstFactor {
|
||||
return errors.New("expected Authelia to reuse the existing session on the second authorization but it asked for the password again")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func conformanceAssertErrorPage(leg ConformanceLeg) error {
|
||||
if !leg.AutheliaError {
|
||||
return errors.New("expected Authelia to show an error page rather than redirect to the client but it did not")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ConformanceRunner walks one plan's modules in order.
|
||||
type ConformanceRunner struct {
|
||||
client *ConformanceClient
|
||||
browser *ConformanceBrowser
|
||||
planID string
|
||||
modules []ConformancePlanModule
|
||||
names []string
|
||||
|
||||
// debug is whether each module's trace is recorded.
|
||||
debug bool
|
||||
|
||||
// trace is the trace of the module being run. Modules run one at a time, so it is replaced as each begins.
|
||||
trace *ConformanceTrace
|
||||
}
|
||||
|
||||
// NewConformanceRunner returns a ConformanceRunner for one plan. browser may be nil only when no module in modules
|
||||
// reaches the WAITING state, which in practice means only in tests.
|
||||
func NewConformanceRunner(client *ConformanceClient, browser *ConformanceBrowser, planID string, modules []ConformancePlanModule) *ConformanceRunner {
|
||||
return &ConformanceRunner{
|
||||
client: client,
|
||||
browser: browser,
|
||||
planID: planID,
|
||||
modules: modules,
|
||||
names: ConformanceSubtestNames(modules),
|
||||
}
|
||||
}
|
||||
|
||||
// Run walks the plan on its own goroutine and returns the channel its outcomes arrive on, one per module in plan
|
||||
// order. The channel closes when the plan is done. Nothing here touches [testing.T]: an outcome carries its error so the
|
||||
// test goroutine can be the one that fails.
|
||||
func (r *ConformanceRunner) Run(ctx context.Context) <-chan ConformanceOutcome {
|
||||
outcomes := make(chan ConformanceOutcome, len(r.modules))
|
||||
|
||||
go func() {
|
||||
defer close(outcomes)
|
||||
|
||||
for i, module := range r.modules {
|
||||
outcomes <- r.run(ctx, r.names[i], module)
|
||||
}
|
||||
}()
|
||||
|
||||
return outcomes
|
||||
}
|
||||
|
||||
func (r *ConformanceRunner) run(ctx context.Context, name string, module ConformancePlanModule) (outcome ConformanceOutcome) {
|
||||
outcome = ConformanceOutcome{Name: name, Module: module.TestModule}
|
||||
|
||||
r.trace = NewConformanceTrace(r.debug)
|
||||
|
||||
if r.browser != nil {
|
||||
r.browser.trace = r.trace
|
||||
}
|
||||
|
||||
defer func() {
|
||||
outcome.Trace = r.trace.Lines()
|
||||
}()
|
||||
|
||||
if reason, ok := conformanceUnattended[module.TestModule]; ok {
|
||||
outcome.Skipped, outcome.Reason = true, reason
|
||||
|
||||
return outcome
|
||||
}
|
||||
|
||||
parent := ctx
|
||||
|
||||
ctx, cancel := context.WithTimeout(ctx, conformanceModuleTimeout)
|
||||
defer cancel()
|
||||
|
||||
override := conformanceOverrides[module.TestModule]
|
||||
|
||||
id, state, err := r.createAndAdvance(ctx, module, override)
|
||||
|
||||
r.trace.Logf("Module '%s' created as '%s' in state '%s'", module.TestModule, id, state)
|
||||
|
||||
if id != "" {
|
||||
outcome.ID, outcome.LogURL = id, r.client.LogDetailURL(id)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
outcome.Err = err
|
||||
|
||||
if id != "" {
|
||||
r.settleAfterError(parent, id)
|
||||
}
|
||||
|
||||
return outcome
|
||||
}
|
||||
|
||||
if state == conformanceStatusWaiting {
|
||||
if err = r.interact(ctx, id, override); err != nil {
|
||||
outcome.Err = err
|
||||
}
|
||||
}
|
||||
|
||||
if outcome.Err == nil {
|
||||
if _, err = r.client.WaitState(ctx, id, conformanceTerminalStates...); err != nil {
|
||||
outcome.Err = err
|
||||
}
|
||||
}
|
||||
|
||||
if outcome.Err != nil {
|
||||
r.settleAfterError(parent, id)
|
||||
}
|
||||
|
||||
infoCtx, infoCancel := context.WithTimeout(context.WithoutCancel(ctx), conformanceOutcomeInfoTimeout)
|
||||
defer infoCancel()
|
||||
|
||||
info, err := r.client.Info(infoCtx, id)
|
||||
if err != nil {
|
||||
if outcome.Err == nil {
|
||||
outcome.Err = err
|
||||
}
|
||||
|
||||
return outcome
|
||||
}
|
||||
|
||||
outcome.Status, outcome.Result = info.Status, info.Result
|
||||
|
||||
return outcome
|
||||
}
|
||||
|
||||
func (r *ConformanceRunner) settleAfterError(parent context.Context, id string) {
|
||||
ctx, cancel := context.WithTimeout(parent, conformanceErrorSettleTimeout)
|
||||
defer cancel()
|
||||
|
||||
state, err := r.client.WaitState(ctx, id, append([]string{conformanceStatusConfigured, conformanceStatusWaiting}, conformanceTerminalStates...)...)
|
||||
|
||||
r.trace.Logf("Module '%s' settled in state '%s' after the suite failed it (%v)", id, state, err)
|
||||
}
|
||||
|
||||
func (r *ConformanceRunner) createAndAdvance(ctx context.Context, module ConformancePlanModule, override ConformanceOverride) (id, state string, err error) {
|
||||
if id, err = r.client.CreateTest(ctx, r.planID, module.TestModule, module.Variant); err != nil {
|
||||
return "", "", fmt.Errorf("error creating module '%s': %w", module.TestModule, err)
|
||||
}
|
||||
|
||||
if override.ClearCookies && r.browser != nil {
|
||||
if err = r.browser.ClearCookies(); err != nil {
|
||||
return id, "", err
|
||||
}
|
||||
}
|
||||
|
||||
if state, err = r.client.WaitState(ctx, id, append([]string{conformanceStatusConfigured, conformanceStatusWaiting}, conformanceTerminalStates...)...); err != nil {
|
||||
return id, "", err
|
||||
}
|
||||
|
||||
if state != conformanceStatusConfigured {
|
||||
return id, state, nil
|
||||
}
|
||||
|
||||
if err = r.client.StartTest(ctx, id); err != nil {
|
||||
return id, "", err
|
||||
}
|
||||
|
||||
state, err = r.client.WaitState(ctx, id, append([]string{conformanceStatusWaiting}, conformanceTerminalStates...)...)
|
||||
|
||||
return id, state, err
|
||||
}
|
||||
|
||||
func (r *ConformanceRunner) interact(ctx context.Context, id string, override ConformanceOverride) (err error) {
|
||||
if r.browser == nil {
|
||||
return errors.New("the module needs a browser but the runner has none")
|
||||
}
|
||||
|
||||
legs := &conformanceLegs{visited: map[string]bool{}}
|
||||
|
||||
var deadline time.Time
|
||||
|
||||
for {
|
||||
if ctx.Err() != nil {
|
||||
return fmt.Errorf("module '%s' did not leave the WAITING state: %w", id, ctx.Err())
|
||||
}
|
||||
|
||||
status, err := r.client.BrowserStatus(ctx, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if deadline.IsZero() {
|
||||
deadline = time.Now().Add(conformancePlaceholderTimeout)
|
||||
}
|
||||
|
||||
progressed, err := r.visitURLs(ctx, id, override, legs, status.URLs)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
filled, pending, err := r.fillPlaceholders(ctx, id, legs.screenshot)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
legs.pendingPlaceholder = pending
|
||||
|
||||
done, settled, err := r.advance(ctx, id, filled)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if done {
|
||||
return nil
|
||||
}
|
||||
|
||||
if progressed || filled || settled {
|
||||
deadline = time.Now().Add(conformancePlaceholderTimeout)
|
||||
} else if time.Now().After(deadline) {
|
||||
return legs.stalled(id)
|
||||
}
|
||||
|
||||
time.Sleep(conformancePlaceholderInterval)
|
||||
}
|
||||
}
|
||||
|
||||
func (r *ConformanceRunner) advance(ctx context.Context, id string, filled bool) (done, settled bool, err error) {
|
||||
var state string
|
||||
|
||||
if state, err = r.moduleState(ctx, id, filled); err != nil {
|
||||
return false, false, err
|
||||
}
|
||||
|
||||
switch state {
|
||||
case conformanceStatusFinished, conformanceStatusInterrupted:
|
||||
return true, false, nil
|
||||
case conformanceStatusWaiting:
|
||||
return false, false, nil
|
||||
}
|
||||
|
||||
r.trace.Logf("Module '%s' is in state '%s' between browser legs", id, state)
|
||||
|
||||
if state, err = r.client.WaitState(ctx, id, append([]string{conformanceStatusWaiting}, conformanceTerminalStates...)...); err != nil {
|
||||
return false, false, err
|
||||
}
|
||||
|
||||
return state != conformanceStatusWaiting, true, nil
|
||||
}
|
||||
|
||||
type conformanceLegs struct {
|
||||
visited map[string]bool
|
||||
count int
|
||||
errorURL string
|
||||
screenshot string
|
||||
pendingPlaceholder bool
|
||||
}
|
||||
|
||||
func (l *conformanceLegs) stalled(id string) error {
|
||||
if l.pendingPlaceholder {
|
||||
return fmt.Errorf("module '%s' is waiting for an evidence image, but no leg showed the page it asks for", id)
|
||||
}
|
||||
|
||||
if l.errorURL != "" {
|
||||
return &ConformanceErrorPageStallError{ID: id, URL: l.errorURL}
|
||||
}
|
||||
|
||||
return fmt.Errorf("module '%s' stayed in the WAITING state with nothing left to visit or fill", id)
|
||||
}
|
||||
|
||||
// ConformanceErrorPageStallError is a module left waiting for the flow to return to the client after Authelia ended
|
||||
// it on an error page, which the module had no placeholder for.
|
||||
type ConformanceErrorPageStallError struct {
|
||||
ID string
|
||||
URL string
|
||||
}
|
||||
|
||||
// Error names the module and what Authelia's error page reported.
|
||||
func (e *ConformanceErrorPageStallError) Error() string {
|
||||
return fmt.Sprintf("module '%s' is waiting for the flow to return to the client, but Authelia ended it on an error page and the module asked for no evidence image of it: %s",
|
||||
e.ID, conformanceDescribeErrorPage(e.URL))
|
||||
}
|
||||
|
||||
func conformanceDescribeErrorPage(uri string) string {
|
||||
u, err := url.Parse(uri)
|
||||
if err != nil {
|
||||
return uri
|
||||
}
|
||||
|
||||
query := u.Query()
|
||||
|
||||
var parts []string
|
||||
|
||||
for _, field := range []struct{ label, key string }{
|
||||
{"error", "error"},
|
||||
{"description", "error_description"},
|
||||
{"hint", "error_hint"},
|
||||
{"debug", "error_debug"},
|
||||
} {
|
||||
if value := query.Get(field.key); value != "" {
|
||||
parts = append(parts, fmt.Sprintf("%s '%s'", field.label, value))
|
||||
}
|
||||
}
|
||||
|
||||
if len(parts) == 0 {
|
||||
return uri
|
||||
}
|
||||
|
||||
return strings.Join(parts, ", ")
|
||||
}
|
||||
|
||||
func (r *ConformanceRunner) visitURLs(ctx context.Context, id string, override ConformanceOverride, legs *conformanceLegs, urls []string) (progressed bool, err error) {
|
||||
for _, uri := range urls {
|
||||
if legs.visited[uri] {
|
||||
continue
|
||||
}
|
||||
|
||||
legs.visited[uri] = true
|
||||
progressed = true
|
||||
|
||||
index := legs.count
|
||||
legs.count++
|
||||
|
||||
r.trace.Logf("Driving leg %d at '%s'", index, uri)
|
||||
|
||||
leg, err := r.browser.Drive(ctx, index, uri)
|
||||
if err != nil {
|
||||
return progressed, err
|
||||
}
|
||||
|
||||
r.trace.Logf("Finished leg %d: first factor %t, consent %t, reauthentication %t, error page %t",
|
||||
index, leg.FirstFactor, leg.Consent, leg.Reauthentication, leg.AutheliaError)
|
||||
|
||||
legs.errorURL = leg.ErrorURL
|
||||
legs.screenshot = conformanceLatest(legs.screenshot, override.screenshotOf(leg))
|
||||
|
||||
if override.Assert != nil {
|
||||
if err = override.Assert(leg); err != nil {
|
||||
return progressed, err
|
||||
}
|
||||
}
|
||||
|
||||
if err = r.client.Visit(ctx, id, uri); err != nil {
|
||||
return progressed, err
|
||||
}
|
||||
}
|
||||
|
||||
return progressed, nil
|
||||
}
|
||||
|
||||
func (r *ConformanceRunner) fillPlaceholders(ctx context.Context, id, screenshot string) (filled, pending bool, err error) {
|
||||
entries, err := r.client.Log(ctx, id)
|
||||
if err != nil {
|
||||
return false, false, err
|
||||
}
|
||||
|
||||
for _, entry := range entries {
|
||||
if entry.Upload == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
if screenshot == "" {
|
||||
pending = true
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
r.trace.Logf("Module '%s' uploading evidence image '%s'", id, entry.Upload)
|
||||
|
||||
if err = r.client.UploadPlaceholder(ctx, id, entry.Upload, screenshot); err != nil {
|
||||
return filled, pending, err
|
||||
}
|
||||
|
||||
filled = true
|
||||
}
|
||||
|
||||
return filled, pending, nil
|
||||
}
|
||||
|
||||
func (r *ConformanceRunner) awaitPlaceholderSettled(ctx context.Context, id string, interval, timeout time.Duration) (current string, err error) {
|
||||
deadline := time.Now().Add(timeout)
|
||||
|
||||
for {
|
||||
var info *ConformanceTestInfo
|
||||
|
||||
if info, err = r.client.Info(ctx, id); err != nil {
|
||||
return "", fmt.Errorf("error reading the status of module '%s' while waiting for it to take up its evidence image: %w", id, err)
|
||||
}
|
||||
|
||||
if info.Status != conformanceStatusWaiting || time.Now().After(deadline) {
|
||||
return info.Status, nil
|
||||
}
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return "", fmt.Errorf("module '%s' did not leave the %s state after its evidence image was uploaded: %w", id, conformanceStatusWaiting, ctx.Err())
|
||||
case <-time.After(interval):
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (r *ConformanceRunner) moduleState(ctx context.Context, id string, filled bool) (state string, err error) {
|
||||
if filled {
|
||||
if state, err = r.awaitPlaceholderSettled(ctx, id, conformanceUploadSettleInterval, conformanceUploadSettleTimeout); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
r.trace.Logf("Module '%s' is in state '%s' after its evidence image was uploaded", id, state)
|
||||
|
||||
return state, nil
|
||||
}
|
||||
|
||||
var info *ConformanceTestInfo
|
||||
|
||||
if info, err = r.client.Info(ctx, id); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return info.Status, nil
|
||||
}
|
||||
@@ -0,0 +1,305 @@
|
||||
// SPDX-FileCopyrightText: 2026 Authelia
|
||||
//
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package suites
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestConformanceRunner_ReportsTheStepWhichFailed(t *testing.T) {
|
||||
created := conformanceReply(http.StatusCreated, `{"id":"m1"}`)
|
||||
|
||||
testCases := []struct {
|
||||
name string
|
||||
routes map[string]http.HandlerFunc
|
||||
expected string
|
||||
id string
|
||||
}{
|
||||
{
|
||||
"ShouldReportAModuleWhichCouldNotBeCreated",
|
||||
map[string]http.HandlerFunc{"/api/runner": conformanceReply(http.StatusInternalServerError, "broken")},
|
||||
"error creating module 'oidcc-server'",
|
||||
"",
|
||||
},
|
||||
{
|
||||
"ShouldReportAModuleWhichCouldNotBeWaitedOn",
|
||||
map[string]http.HandlerFunc{
|
||||
"/api/runner": created,
|
||||
"/api/runner/m1/wait-state": conformanceReply(http.StatusBadRequest, "bad"),
|
||||
},
|
||||
"expected status 200 but got 400",
|
||||
"m1",
|
||||
},
|
||||
{
|
||||
"ShouldReportAConfiguredModuleWhichCouldNotBeStarted",
|
||||
map[string]http.HandlerFunc{
|
||||
"/api/runner": created,
|
||||
"/api/runner/m1/wait-state": conformanceReply(http.StatusOK, `{"state":"CONFIGURED"}`),
|
||||
"POST /api/runner/m1": conformanceReply(http.StatusInternalServerError, "broken"),
|
||||
},
|
||||
"expected status 200 but got 500",
|
||||
"m1",
|
||||
},
|
||||
{
|
||||
"ShouldReportAModuleWhoseStatusCouldNotBeRead",
|
||||
map[string]http.HandlerFunc{
|
||||
"/api/runner": created,
|
||||
"/api/runner/m1/wait-state": conformanceReply(http.StatusOK, `{"state":"FINISHED"}`),
|
||||
"/api/info/m1": conformanceReply(http.StatusInternalServerError, "broken"),
|
||||
},
|
||||
"expected status 200 but got 500",
|
||||
"m1",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
outcome := conformanceRunOne(t, newConformanceFakeClient(t, tc.routes))
|
||||
|
||||
require.Error(t, outcome.Err)
|
||||
assert.ErrorContains(t, outcome.Err, tc.expected)
|
||||
assert.Equal(t, tc.id, outcome.ID)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestConformanceRunner_StartsAConfiguredModule(t *testing.T) {
|
||||
var started atomic.Bool
|
||||
|
||||
client := newConformanceFakeClient(t, map[string]http.HandlerFunc{
|
||||
"/api/runner": conformanceReply(http.StatusCreated, `{"id":"m1"}`),
|
||||
"/api/runner/m1/wait-state": func(w http.ResponseWriter, r *http.Request) {
|
||||
if strings.Contains(r.URL.Query().Get("states"), conformanceStatusConfigured) {
|
||||
_, _ = w.Write([]byte(`{"state":"CONFIGURED"}`))
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
_, _ = w.Write([]byte(`{"state":"FINISHED"}`))
|
||||
},
|
||||
"POST /api/runner/m1": func(w http.ResponseWriter, _ *http.Request) {
|
||||
started.Store(true)
|
||||
|
||||
_, _ = w.Write([]byte(`{}`))
|
||||
},
|
||||
"/api/info/m1": conformanceReply(http.StatusOK, `{"_id":"m1","status":"FINISHED","result":"PASSED"}`),
|
||||
})
|
||||
|
||||
outcome := conformanceRunOne(t, client)
|
||||
|
||||
require.NoError(t, outcome.Err)
|
||||
assert.True(t, started.Load())
|
||||
assert.Equal(t, "PASSED", outcome.Result)
|
||||
}
|
||||
|
||||
func TestConformanceRunner_InteractReportsTheCallWhichFailed(t *testing.T) {
|
||||
status := conformanceReply(http.StatusOK, `{"urls":[]}`)
|
||||
log := conformanceReply(http.StatusOK, `[]`)
|
||||
|
||||
testCases := []struct {
|
||||
name string
|
||||
routes map[string]http.HandlerFunc
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
"ShouldReportTheBrowserStatus",
|
||||
map[string]http.HandlerFunc{"/api/runner/browser/m1": conformanceReply(http.StatusInternalServerError, "status broken")},
|
||||
"status broken",
|
||||
},
|
||||
{
|
||||
"ShouldReportTheLog",
|
||||
map[string]http.HandlerFunc{
|
||||
"/api/runner/browser/m1": status,
|
||||
"/api/log/m1": conformanceReply(http.StatusInternalServerError, "log broken"),
|
||||
},
|
||||
"log broken",
|
||||
},
|
||||
{
|
||||
"ShouldReportTheStatus",
|
||||
map[string]http.HandlerFunc{
|
||||
"/api/runner/browser/m1": status,
|
||||
"/api/log/m1": log,
|
||||
"/api/info/m1": conformanceReply(http.StatusInternalServerError, "info broken"),
|
||||
},
|
||||
"info broken",
|
||||
},
|
||||
{
|
||||
"ShouldReportTheWaitForAModuleBetweenLegs",
|
||||
map[string]http.HandlerFunc{
|
||||
"/api/runner/browser/m1": status,
|
||||
"/api/log/m1": log,
|
||||
"/api/info/m1": conformanceReply(http.StatusOK, `{"_id":"m1","status":"RUNNING"}`),
|
||||
"/api/runner/m1/wait-state": conformanceReply(http.StatusBadRequest, "wait broken"),
|
||||
},
|
||||
"wait broken",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
runner := NewConformanceRunner(newConformanceFakeClient(t, tc.routes), &ConformanceBrowser{}, "plan1", nil)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Second*5)
|
||||
defer cancel()
|
||||
|
||||
assert.ErrorContains(t, runner.interact(ctx, "m1", ConformanceOverride{}), tc.expected)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestConformanceRunner_FillPlaceholdersReportsAnUploadWhichFailed(t *testing.T) {
|
||||
client := newConformanceFakeClient(t, map[string]http.HandlerFunc{
|
||||
"/api/log/m1": conformanceReply(http.StatusOK, `[{"msg":"upload","upload":"ph1","result":"REVIEW"}]`),
|
||||
"/api/log/m1/images/ph1": conformanceReply(http.StatusInternalServerError, "upload broken"),
|
||||
})
|
||||
|
||||
filled, pending, err := NewConformanceRunner(client, nil, "plan1", nil).fillPlaceholders(context.Background(), "m1", conformanceTestScreenshot)
|
||||
|
||||
assert.ErrorContains(t, err, "upload broken")
|
||||
assert.False(t, filled)
|
||||
assert.False(t, pending)
|
||||
}
|
||||
|
||||
func TestConformanceRunner_InteractStopsWhenTheContextHasExpired(t *testing.T) {
|
||||
runner := NewConformanceRunner(nil, &ConformanceBrowser{}, "plan1", nil)
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
|
||||
assert.ErrorContains(t, runner.interact(ctx, "m1", ConformanceOverride{}), "module 'm1' did not leave the WAITING state")
|
||||
}
|
||||
|
||||
func TestConformanceRunner_InteractPollsAWaitingModuleAgain(t *testing.T) {
|
||||
var infos atomic.Int32
|
||||
|
||||
client := newConformanceFakeClient(t, map[string]http.HandlerFunc{
|
||||
"/api/runner/browser/m1": conformanceReply(http.StatusOK, `{"urls":[]}`),
|
||||
"/api/log/m1": conformanceReply(http.StatusOK, `[]`),
|
||||
"/api/info/m1": func(w http.ResponseWriter, _ *http.Request) {
|
||||
if infos.Add(1) == 1 {
|
||||
_, _ = w.Write([]byte(`{"_id":"m1","status":"WAITING"}`))
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
_, _ = w.Write([]byte(`{"_id":"m1","status":"FINISHED"}`))
|
||||
},
|
||||
})
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Second*10)
|
||||
defer cancel()
|
||||
|
||||
require.NoError(t, NewConformanceRunner(client, &ConformanceBrowser{}, "plan1", nil).interact(ctx, "m1", ConformanceOverride{}))
|
||||
assert.Equal(t, int32(2), infos.Load())
|
||||
}
|
||||
|
||||
func TestConformanceRunner_ModuleState(t *testing.T) {
|
||||
t.Run("ShouldSettleAfterAnEvidenceImage", func(t *testing.T) {
|
||||
client := newConformanceFakeClient(t, map[string]http.HandlerFunc{
|
||||
"/api/info/m1": conformanceReply(http.StatusOK, `{"_id":"m1","status":"FINISHED"}`),
|
||||
})
|
||||
|
||||
runner := NewConformanceRunner(client, nil, "plan1", nil)
|
||||
runner.trace = NewConformanceTrace(true)
|
||||
|
||||
state, err := runner.moduleState(context.Background(), "m1", true)
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, conformanceStatusFinished, state)
|
||||
require.Len(t, runner.trace.Lines(), 1)
|
||||
assert.Contains(t, runner.trace.Lines()[0], "Module 'm1' is in state 'FINISHED' after its evidence image was uploaded")
|
||||
})
|
||||
|
||||
t.Run("ShouldReportAStatusWhichCouldNotBeReadWhileSettling", func(t *testing.T) {
|
||||
client := newConformanceFakeClient(t, map[string]http.HandlerFunc{
|
||||
"/api/info/m1": conformanceReply(http.StatusInternalServerError, "broken"),
|
||||
})
|
||||
|
||||
_, err := NewConformanceRunner(client, nil, "plan1", nil).moduleState(context.Background(), "m1", true)
|
||||
|
||||
assert.ErrorContains(t, err, "while waiting for it to take up its evidence image")
|
||||
})
|
||||
}
|
||||
|
||||
func TestConformanceDescribeErrorPageFallsBackForAnUnparsableURL(t *testing.T) {
|
||||
assert.Equal(t, "%zz", conformanceDescribeErrorPage("%zz"))
|
||||
}
|
||||
|
||||
func TestConformanceRunner_HandsTheModuleTraceToItsBrowser(t *testing.T) {
|
||||
client := newConformanceFakeClient(t, map[string]http.HandlerFunc{
|
||||
"/api/runner": conformanceReply(http.StatusCreated, `{"id":"m1"}`),
|
||||
"/api/runner/m1/wait-state": conformanceReply(http.StatusOK, `{"state":"FINISHED"}`),
|
||||
"/api/info/m1": conformanceReply(http.StatusOK, `{"_id":"m1","status":"FINISHED","result":"PASSED"}`),
|
||||
})
|
||||
|
||||
browser := &ConformanceBrowser{}
|
||||
runner := NewConformanceRunner(client, browser, "plan1", []ConformancePlanModule{{TestModule: "oidcc-server"}})
|
||||
runner.debug = true
|
||||
|
||||
for outcome := range runner.Run(context.Background()) {
|
||||
require.NoError(t, outcome.Err)
|
||||
}
|
||||
|
||||
assert.Same(t, runner.trace, browser.trace, "what the driver does is recorded in the module's trace")
|
||||
}
|
||||
|
||||
func newConformanceFakeClient(t *testing.T, routes map[string]http.HandlerFunc) *ConformanceClient {
|
||||
t.Helper()
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
route, ok := routes[r.Method+" "+r.URL.Path]
|
||||
if !ok {
|
||||
route, ok = routes[r.URL.Path]
|
||||
}
|
||||
|
||||
if !ok {
|
||||
t.Errorf("unexpected request %s %s", r.Method, r.URL.Path)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
route(w, r)
|
||||
}))
|
||||
|
||||
t.Cleanup(server.Close)
|
||||
|
||||
client, err := NewConformanceClient(server.URL)
|
||||
require.NoError(t, err)
|
||||
|
||||
return client
|
||||
}
|
||||
|
||||
func conformanceReply(status int, body string) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(status)
|
||||
_, _ = w.Write([]byte(body))
|
||||
}
|
||||
}
|
||||
|
||||
func conformanceRunOne(t *testing.T, client *ConformanceClient) ConformanceOutcome {
|
||||
t.Helper()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Second*10)
|
||||
defer cancel()
|
||||
|
||||
var outcomes []ConformanceOutcome
|
||||
|
||||
for outcome := range NewConformanceRunner(client, nil, "plan1", []ConformancePlanModule{{TestModule: "oidcc-server"}}).Run(ctx) {
|
||||
outcomes = append(outcomes, outcome)
|
||||
}
|
||||
|
||||
require.Len(t, outcomes, 1)
|
||||
|
||||
return outcomes[0]
|
||||
}
|
||||
@@ -0,0 +1,659 @@
|
||||
// SPDX-FileCopyrightText: 2026 Authelia
|
||||
//
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package suites
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestConformanceRunner_ReportsOutcomesInPlanOrder(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch { //nolint:staticcheck // a tagged switch would obscure the multi-condition cases below.
|
||||
case r.URL.Path == "/api/runner":
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
_, _ = w.Write([]byte(`{"id":"` + r.URL.Query().Get("test") + `-id"}`)) //nolint:gosec // test double echoing a query param set by this same test, not attacker controlled.
|
||||
case r.URL.Path == "/api/runner/oidcc-server-id/wait-state",
|
||||
r.URL.Path == "/api/runner/oidcc-scope-address-id/wait-state":
|
||||
_, _ = w.Write([]byte(`{"state":"FINISHED"}`))
|
||||
case r.URL.Path == "/api/info/oidcc-server-id":
|
||||
_, _ = w.Write([]byte(`{"_id":"oidcc-server-id","status":"FINISHED","result":"PASSED"}`))
|
||||
case r.URL.Path == "/api/info/oidcc-scope-address-id":
|
||||
_, _ = w.Write([]byte(`{"_id":"oidcc-scope-address-id","status":"FINISHED","result":"WARNING"}`))
|
||||
case r.URL.Path == "/api/log/oidcc-server-id", r.URL.Path == "/api/log/oidcc-scope-address-id":
|
||||
_, _ = w.Write([]byte(`[]`))
|
||||
default:
|
||||
t.Errorf("unexpected path %s", r.URL.Path)
|
||||
}
|
||||
}))
|
||||
|
||||
defer server.Close()
|
||||
|
||||
client, err := NewConformanceClient(server.URL)
|
||||
require.NoError(t, err)
|
||||
|
||||
modules := []ConformancePlanModule{{TestModule: "oidcc-server"}, {TestModule: "oidcc-scope-address"}}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Second*30)
|
||||
defer cancel()
|
||||
|
||||
var outcomes []ConformanceOutcome
|
||||
|
||||
for outcome := range NewConformanceRunner(client, nil, "plan1", modules).Run(ctx) {
|
||||
outcomes = append(outcomes, outcome)
|
||||
}
|
||||
|
||||
require.Len(t, outcomes, 2)
|
||||
|
||||
assert.Equal(t, "Server", outcomes[0].Name)
|
||||
assert.Equal(t, "PASSED", outcomes[0].Result)
|
||||
require.NoError(t, outcomes[0].Err)
|
||||
|
||||
assert.Equal(t, "ScopeAddress", outcomes[1].Name)
|
||||
assert.Equal(t, "WARNING", outcomes[1].Result)
|
||||
}
|
||||
|
||||
func TestConformanceRunner_SkipsUnattendedModules(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
t.Errorf("the runner must not contact the server for a skipped module, got %s", r.URL.Path)
|
||||
}))
|
||||
|
||||
defer server.Close()
|
||||
|
||||
client, err := NewConformanceClient(server.URL)
|
||||
require.NoError(t, err)
|
||||
|
||||
modules := []ConformancePlanModule{{TestModule: "oidcc-server-rotate-keys"}}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Second*5)
|
||||
defer cancel()
|
||||
|
||||
var outcomes []ConformanceOutcome
|
||||
|
||||
for outcome := range NewConformanceRunner(client, nil, "plan1", modules).Run(ctx) {
|
||||
outcomes = append(outcomes, outcome)
|
||||
}
|
||||
|
||||
require.Len(t, outcomes, 1)
|
||||
assert.True(t, outcomes[0].Skipped)
|
||||
assert.Equal(t, "ServerRotateKeys", outcomes[0].Name)
|
||||
assert.NotEmpty(t, outcomes[0].Reason)
|
||||
}
|
||||
|
||||
func TestConformanceRunner_FillPlaceholdersUploadsOnlyUnfilledEntries(t *testing.T) {
|
||||
t.Run("UploadsOnlyTheEntryWithAnUnfilledPlaceholder", func(t *testing.T) {
|
||||
var (
|
||||
uploadCount int
|
||||
uploadedID string
|
||||
uploadedBody []byte
|
||||
)
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/api/log/m1":
|
||||
_, _ = w.Write([]byte(`[{"msg":"info","result":"INFO"},{"msg":"upload","upload":"ph1","result":"REVIEW"}]`))
|
||||
case "/api/log/m1/images/ph1":
|
||||
uploadCount++
|
||||
uploadedID = "ph1"
|
||||
uploadedBody, _ = io.ReadAll(r.Body)
|
||||
|
||||
_, _ = w.Write([]byte(`{}`))
|
||||
default:
|
||||
t.Errorf("unexpected path %s", r.URL.Path)
|
||||
}
|
||||
}))
|
||||
|
||||
defer server.Close()
|
||||
|
||||
client, err := NewConformanceClient(server.URL)
|
||||
require.NoError(t, err)
|
||||
|
||||
runner := NewConformanceRunner(client, nil, "plan1", nil)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Second*5)
|
||||
defer cancel()
|
||||
|
||||
filled, pending, err := runner.fillPlaceholders(ctx, "m1", conformanceTestScreenshot)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.True(t, filled)
|
||||
assert.False(t, pending)
|
||||
assert.Equal(t, 1, uploadCount)
|
||||
assert.Equal(t, "ph1", uploadedID)
|
||||
assert.Equal(t, conformanceTestScreenshot, string(uploadedBody))
|
||||
})
|
||||
|
||||
t.Run("UploadsNothingWhenNoPlaceholderIsUnfilled", func(t *testing.T) {
|
||||
var uploadCalled bool
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/api/log/m1":
|
||||
_, _ = w.Write([]byte(`[{"msg":"info","result":"INFO"}]`))
|
||||
case "/api/log/m1/images/ph1":
|
||||
uploadCalled = true
|
||||
|
||||
_, _ = w.Write([]byte(`{}`))
|
||||
default:
|
||||
t.Errorf("unexpected path %s", r.URL.Path)
|
||||
}
|
||||
}))
|
||||
|
||||
defer server.Close()
|
||||
|
||||
client, err := NewConformanceClient(server.URL)
|
||||
require.NoError(t, err)
|
||||
|
||||
runner := NewConformanceRunner(client, nil, "plan1", nil)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Second*5)
|
||||
defer cancel()
|
||||
|
||||
filled, pending, err := runner.fillPlaceholders(ctx, "m1", conformanceTestScreenshot)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.False(t, filled)
|
||||
assert.False(t, pending)
|
||||
assert.False(t, uploadCalled)
|
||||
})
|
||||
|
||||
t.Run("WaitsForTheScreenshotBeforeUploading", func(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/api/log/m1":
|
||||
_, _ = w.Write([]byte(`[{"msg":"upload","upload":"ph1","result":"REVIEW"}]`))
|
||||
default:
|
||||
t.Errorf("unexpected path %s", r.URL.Path)
|
||||
}
|
||||
}))
|
||||
|
||||
defer server.Close()
|
||||
|
||||
client, err := NewConformanceClient(server.URL)
|
||||
require.NoError(t, err)
|
||||
|
||||
runner := NewConformanceRunner(client, nil, "plan1", nil)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Second*5)
|
||||
defer cancel()
|
||||
|
||||
filled, pending, err := runner.fillPlaceholders(ctx, "m1", "")
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.False(t, filled)
|
||||
assert.True(t, pending)
|
||||
})
|
||||
}
|
||||
|
||||
func TestConformanceOverrideAssertions(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
assert func(leg ConformanceLeg) error
|
||||
leg ConformanceLeg
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
"ShouldIgnoreTheFirstLegWhenReauthenticationIsExpected",
|
||||
conformanceAssertReauthentication,
|
||||
ConformanceLeg{Index: 0, FirstFactor: false},
|
||||
"",
|
||||
},
|
||||
{
|
||||
"ShouldIgnoreTheFirstLegEvenWhenItPrompted",
|
||||
conformanceAssertReauthentication,
|
||||
ConformanceLeg{Index: 0, FirstFactor: true},
|
||||
"",
|
||||
},
|
||||
{
|
||||
"ShouldPassWhenTheSecondLegPrompted",
|
||||
conformanceAssertReauthentication,
|
||||
ConformanceLeg{Index: 1, FirstFactor: true},
|
||||
"",
|
||||
},
|
||||
{
|
||||
"ShouldFailWhenTheSecondLegDidNotPrompt",
|
||||
conformanceAssertReauthentication,
|
||||
ConformanceLeg{Index: 1, FirstFactor: false},
|
||||
"expected Authelia to ask for the password again on the second authorization but it did not",
|
||||
},
|
||||
{
|
||||
"ShouldPassWhenTheSecondLegReauthenticatedOnTheConsentForm",
|
||||
conformanceAssertReauthentication,
|
||||
ConformanceLeg{Index: 1, Consent: true, Reauthentication: true},
|
||||
"",
|
||||
},
|
||||
{
|
||||
"ShouldFailWhenTheSecondLegShowedConsentWithoutAskingForThePassword",
|
||||
conformanceAssertReauthentication,
|
||||
ConformanceLeg{Index: 1, Consent: true, Reauthentication: false},
|
||||
"expected Authelia to ask for the password again on the second authorization but it did not",
|
||||
},
|
||||
{
|
||||
"ShouldFailWhenTheSecondLegReauthenticatedOnTheConsentFormButShouldNotHave",
|
||||
conformanceAssertNoReauthentication,
|
||||
ConformanceLeg{Index: 1, Consent: true, Reauthentication: true},
|
||||
"expected Authelia to reuse the existing session on the second authorization but it asked for the password again",
|
||||
},
|
||||
{
|
||||
"ShouldIgnoreTheFirstLegWhenNoReauthenticationIsExpected",
|
||||
conformanceAssertNoReauthentication,
|
||||
ConformanceLeg{Index: 0, FirstFactor: true},
|
||||
"",
|
||||
},
|
||||
{
|
||||
"ShouldPassWhenTheSecondLegDidNotPrompt",
|
||||
conformanceAssertNoReauthentication,
|
||||
ConformanceLeg{Index: 1, FirstFactor: false},
|
||||
"",
|
||||
},
|
||||
{
|
||||
"ShouldFailWhenTheSecondLegPrompted",
|
||||
conformanceAssertNoReauthentication,
|
||||
ConformanceLeg{Index: 1, FirstFactor: true},
|
||||
"expected Authelia to reuse the existing session on the second authorization but it asked for the password again",
|
||||
},
|
||||
{
|
||||
"ShouldNotBeConfusedByConsentOnTheSecondLeg",
|
||||
conformanceAssertNoReauthentication,
|
||||
ConformanceLeg{Index: 1, FirstFactor: false, Consent: true},
|
||||
"",
|
||||
},
|
||||
{
|
||||
"ShouldPassWhenTheLegEndedOnAnAutheliaErrorPage",
|
||||
conformanceAssertErrorPage,
|
||||
ConformanceLeg{Index: 0, AutheliaError: true},
|
||||
"",
|
||||
},
|
||||
{
|
||||
"ShouldFailWhenTheLegReachedTheCallbackInsteadOfAnErrorPage",
|
||||
conformanceAssertErrorPage,
|
||||
ConformanceLeg{Index: 0},
|
||||
"expected Authelia to show an error page rather than redirect to the client but it did not",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
err := tc.assert(tc.leg)
|
||||
|
||||
if tc.expected == "" {
|
||||
assert.NoError(t, err)
|
||||
} else {
|
||||
assert.EqualError(t, err, tc.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestConformanceOverrides_AreWiredToTheirAssertions(t *testing.T) {
|
||||
for module, expected := range map[string]ConformanceLeg{
|
||||
"oidcc-prompt-login": {Index: conformanceReauthenticationLeg, FirstFactor: false},
|
||||
"oidcc-max-age-1": {Index: conformanceReauthenticationLeg, FirstFactor: false},
|
||||
"oidcc-max-age-10000": {Index: conformanceReauthenticationLeg, FirstFactor: true},
|
||||
|
||||
"oidcc-ensure-registered-redirect-uri": {Index: 0},
|
||||
} {
|
||||
override, ok := conformanceOverrides[module]
|
||||
|
||||
require.Truef(t, ok, "the '%s' module has no override", module)
|
||||
require.NotNilf(t, override.Assert, "the '%s' module has no assertion", module)
|
||||
|
||||
assert.Errorf(t, override.Assert(expected), "the '%s' module's assertion cannot fail", module)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConformanceRunner_VisitURLsDoesNotRedriveOrRecountAVisitedURL(t *testing.T) {
|
||||
legs := &conformanceLegs{visited: map[string]bool{"https://conformance.example.com/first": true}, count: 1}
|
||||
|
||||
override := ConformanceOverride{Assert: func(leg ConformanceLeg) error {
|
||||
return fmt.Errorf("the assertion must not run for an already driven leg, got %+v", leg)
|
||||
}}
|
||||
|
||||
runner := NewConformanceRunner(nil, nil, "plan1", nil)
|
||||
|
||||
progressed, err := runner.visitURLs(context.Background(), "m1", override, legs, []string{"https://conformance.example.com/first"})
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.False(t, progressed)
|
||||
assert.Equal(t, 1, legs.count)
|
||||
}
|
||||
|
||||
func TestConformanceOverrides_ClearCookiesMatchesTheModulesThatRequireIt(t *testing.T) {
|
||||
expected := []string{
|
||||
"oidcc-display-page",
|
||||
"oidcc-display-popup",
|
||||
"oidcc-login-hint",
|
||||
"oidcc-prompt-none-not-logged-in",
|
||||
"oidcc-ui-locales",
|
||||
}
|
||||
|
||||
var actual []string
|
||||
|
||||
for module, override := range conformanceOverrides {
|
||||
if override.ClearCookies {
|
||||
actual = append(actual, module)
|
||||
}
|
||||
}
|
||||
|
||||
sort.Strings(actual)
|
||||
|
||||
assert.Equal(t, expected, actual)
|
||||
}
|
||||
|
||||
func TestConformanceRunner_TreatsAnInterruptedModuleAsFinished(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/api/runner":
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
_, _ = w.Write([]byte(`{"id":"m1"}`))
|
||||
case "/api/runner/m1/wait-state":
|
||||
if !strings.Contains(r.URL.Query().Get("states"), conformanceStatusInterrupted) {
|
||||
time.Sleep(time.Millisecond * 10)
|
||||
|
||||
_, _ = w.Write([]byte(`{"timeout":true}`))
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
_, _ = w.Write([]byte(`{"state":"INTERRUPTED"}`))
|
||||
case "/api/info/m1":
|
||||
_, _ = w.Write([]byte(`{"_id":"m1","status":"INTERRUPTED","result":"FAILED"}`))
|
||||
default:
|
||||
t.Errorf("unexpected path %s", r.URL.Path)
|
||||
}
|
||||
}))
|
||||
|
||||
defer server.Close()
|
||||
|
||||
client, err := NewConformanceClient(server.URL)
|
||||
require.NoError(t, err)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Second*5)
|
||||
defer cancel()
|
||||
|
||||
var outcomes []ConformanceOutcome
|
||||
|
||||
for outcome := range NewConformanceRunner(client, nil, "plan1", []ConformancePlanModule{{TestModule: "oidcc-max-age-1"}}).Run(ctx) {
|
||||
outcomes = append(outcomes, outcome)
|
||||
}
|
||||
|
||||
require.Len(t, outcomes, 1)
|
||||
require.NoError(t, outcomes[0].Err)
|
||||
|
||||
assert.Equal(t, conformanceStatusInterrupted, outcomes[0].Status)
|
||||
assert.Equal(t, "FAILED", outcomes[0].Result)
|
||||
}
|
||||
|
||||
func TestConformanceRunner_InteractKeepsDrivingAModuleWhichIsRunningBetweenLegs(t *testing.T) {
|
||||
var (
|
||||
mu sync.Mutex
|
||||
browserPolls, infos int
|
||||
)
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
|
||||
switch r.URL.Path {
|
||||
case "/api/runner/browser/m1":
|
||||
browserPolls++
|
||||
|
||||
_, _ = w.Write([]byte(`{"urls":[]}`))
|
||||
case "/api/log/m1":
|
||||
_, _ = w.Write([]byte(`[]`))
|
||||
case "/api/info/m1":
|
||||
infos++
|
||||
|
||||
if infos == 1 {
|
||||
_, _ = w.Write([]byte(`{"_id":"m1","status":"RUNNING"}`))
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
_, _ = w.Write([]byte(`{"_id":"m1","status":"FINISHED"}`))
|
||||
case "/api/runner/m1/wait-state":
|
||||
assert.Equal(t, "WAITING,FINISHED,INTERRUPTED", r.URL.Query().Get("states"))
|
||||
|
||||
_, _ = w.Write([]byte(`{"state":"WAITING"}`))
|
||||
default:
|
||||
t.Errorf("unexpected path %s", r.URL.Path)
|
||||
}
|
||||
}))
|
||||
|
||||
defer server.Close()
|
||||
|
||||
client, err := NewConformanceClient(server.URL)
|
||||
require.NoError(t, err)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Second*5)
|
||||
defer cancel()
|
||||
|
||||
runner := NewConformanceRunner(client, &ConformanceBrowser{}, "plan1", nil)
|
||||
|
||||
require.NoError(t, runner.interact(ctx, "m1", ConformanceOverride{}))
|
||||
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
|
||||
assert.Equal(t, 2, browserPolls, "the browser status must be read again once the module is back in WAITING")
|
||||
}
|
||||
|
||||
func TestConformanceRunner_ReportsTheStatusOfAModuleWhichRanOutOfTime(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/api/runner":
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
_, _ = w.Write([]byte(`{"id":"m1"}`))
|
||||
case "/api/runner/m1/wait-state":
|
||||
if strings.Contains(r.URL.Query().Get("states"), conformanceStatusConfigured) {
|
||||
_, _ = w.Write([]byte(`{"state":"FINISHED"}`))
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
time.Sleep(time.Millisecond * 10)
|
||||
|
||||
_, _ = w.Write([]byte(`{"timeout":true}`))
|
||||
case "/api/info/m1":
|
||||
_, _ = w.Write([]byte(`{"_id":"m1","status":"WAITING"}`))
|
||||
default:
|
||||
t.Errorf("unexpected path %s", r.URL.Path)
|
||||
}
|
||||
}))
|
||||
|
||||
defer server.Close()
|
||||
|
||||
client, err := NewConformanceClient(server.URL)
|
||||
require.NoError(t, err)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
|
||||
defer cancel()
|
||||
|
||||
var outcomes []ConformanceOutcome
|
||||
|
||||
for outcome := range NewConformanceRunner(client, nil, "plan1", []ConformancePlanModule{{TestModule: "oidcc-server"}}).Run(ctx) {
|
||||
outcomes = append(outcomes, outcome)
|
||||
}
|
||||
|
||||
require.Len(t, outcomes, 1)
|
||||
require.Error(t, outcomes[0].Err)
|
||||
|
||||
assert.Equal(t, conformanceStatusWaiting, outcomes[0].Status)
|
||||
}
|
||||
|
||||
func TestConformanceLegs_Stalled(t *testing.T) {
|
||||
t.Run("ShouldNameAutheliasErrorWhenTheLastLegEndedOnItsErrorPage", func(t *testing.T) {
|
||||
legs := &conformanceLegs{errorURL: "https://login.example.com:8080/consent/completion?error=invalid_request_object&error_description=The+request+parameter+contains+an+invalid+Request+Object.&error_hint=Could+not+be+validated.&error_debug=Expected+typ+JWT."}
|
||||
|
||||
err := legs.stalled("m1")
|
||||
|
||||
var stall *ConformanceErrorPageStallError
|
||||
|
||||
require.ErrorAs(t, err, &stall)
|
||||
assert.EqualError(t, err, "module 'm1' is waiting for the flow to return to the client, but Authelia ended it on an error page and the module asked for no evidence image of it: "+
|
||||
"error 'invalid_request_object', description 'The request parameter contains an invalid Request Object.', hint 'Could not be validated.', debug 'Expected typ JWT.'")
|
||||
})
|
||||
|
||||
t.Run("ShouldFallBackToTheURLWhenTheErrorPageCarriesNoParameters", func(t *testing.T) {
|
||||
legs := &conformanceLegs{errorURL: "https://login.example.com:8080/"}
|
||||
|
||||
assert.EqualError(t, legs.stalled("m1"), "module 'm1' is waiting for the flow to return to the client, but Authelia ended it on an error page and the module asked for no evidence image of it: https://login.example.com:8080/")
|
||||
})
|
||||
|
||||
t.Run("ShouldReportAPlainStallOtherwise", func(t *testing.T) {
|
||||
legs := &conformanceLegs{}
|
||||
|
||||
err := legs.stalled("m1")
|
||||
|
||||
var stall *ConformanceErrorPageStallError
|
||||
|
||||
assert.False(t, errors.As(err, &stall))
|
||||
assert.EqualError(t, err, "module 'm1' stayed in the WAITING state with nothing left to visit or fill")
|
||||
})
|
||||
}
|
||||
|
||||
func TestConformanceOverride_ScreenshotOf(t *testing.T) {
|
||||
leg := func(index int) ConformanceLeg {
|
||||
return ConformanceLeg{Index: index, LoginScreenshot: fmt.Sprintf("login-%d", index), ErrorScreenshot: fmt.Sprintf("error-%d", index)}
|
||||
}
|
||||
|
||||
reauthentication := ConformanceOverride{Screenshot: ConformanceScreenshotReauthentication}
|
||||
|
||||
assert.Empty(t, reauthentication.screenshotOf(leg(0)))
|
||||
assert.Equal(t, "login-1", reauthentication.screenshotOf(leg(1)))
|
||||
|
||||
errorPage := ConformanceOverride{Screenshot: ConformanceScreenshotErrorPage}
|
||||
|
||||
assert.Equal(t, "error-0", errorPage.screenshotOf(leg(0)))
|
||||
assert.Empty(t, errorPage.screenshotOf(ConformanceLeg{Index: 0, LoginScreenshot: "login-0"}))
|
||||
|
||||
assert.Empty(t, ConformanceOverride{}.screenshotOf(leg(1)), "a module with no screenshot kind is given none")
|
||||
}
|
||||
|
||||
func TestConformanceOverrides_ScreenshotEachPlaceholderModule(t *testing.T) {
|
||||
expected := map[string]ConformanceScreenshot{
|
||||
"oidcc-prompt-login": ConformanceScreenshotReauthentication,
|
||||
"oidcc-max-age-1": ConformanceScreenshotReauthentication,
|
||||
|
||||
"oidcc-ensure-registered-redirect-uri": ConformanceScreenshotErrorPage,
|
||||
"oidcc-ensure-request-object-with-redirect-uri": ConformanceScreenshotErrorPage,
|
||||
}
|
||||
|
||||
actual := map[string]ConformanceScreenshot{}
|
||||
|
||||
for module, override := range conformanceOverrides {
|
||||
if override.Screenshot != ConformanceScreenshotNone {
|
||||
actual[module] = override.Screenshot
|
||||
}
|
||||
}
|
||||
|
||||
assert.Equal(t, expected, actual)
|
||||
}
|
||||
|
||||
func TestConformanceOverrides_RequestObjectModules(t *testing.T) {
|
||||
_, ok := conformanceOverrides["oidcc-unsigned-request-object-supported-correctly-or-rejected-as-unsupported"]
|
||||
assert.False(t, ok)
|
||||
|
||||
override := conformanceOverrides["oidcc-ensure-request-object-with-redirect-uri"]
|
||||
|
||||
assert.Nil(t, override.Assert, "either outcome is accepted by the module")
|
||||
assert.Equal(t, "error", override.screenshotOf(ConformanceLeg{AutheliaError: true, ErrorScreenshot: "error"}))
|
||||
assert.Empty(t, override.screenshotOf(ConformanceLeg{}), "a leg which reached the client has no error page to capture")
|
||||
}
|
||||
|
||||
func TestConformanceLegs_StalledOnAPlaceholderWithoutAScreenshot(t *testing.T) {
|
||||
legs := &conformanceLegs{pendingPlaceholder: true, errorURL: "https://login.example.com:8080/consent/completion?error=invalid_request"}
|
||||
|
||||
err := legs.stalled("m1")
|
||||
|
||||
var stall *ConformanceErrorPageStallError
|
||||
|
||||
assert.False(t, errors.As(err, &stall), "a module waiting on its placeholder is not stranded for want of one")
|
||||
assert.EqualError(t, err, "module 'm1' is waiting for an evidence image, but no leg showed the page it asks for")
|
||||
}
|
||||
|
||||
func TestConformanceRunner_LetsAFailedModuleFinishBeforeMovingOn(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
settle string
|
||||
expected string
|
||||
}{
|
||||
{"ShouldWaitForAModuleWhichIsFinishing", `{"state":"FINISHED"}`, "FINISHED"},
|
||||
{"ShouldNotWaitOnAModuleWhichIsWaitingOnTheSuite", `{"state":"WAITING"}`, "WAITING"},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
var (
|
||||
mu sync.Mutex
|
||||
settled []string
|
||||
)
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
|
||||
switch r.URL.Path {
|
||||
case "/api/runner":
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
_, _ = w.Write([]byte(`{"id":"m1"}`))
|
||||
case "/api/runner/m1/wait-state":
|
||||
states := r.URL.Query().Get("states")
|
||||
|
||||
if states == "CONFIGURED,WAITING,FINISHED,INTERRUPTED" && len(settled) == 0 && !strings.Contains(r.URL.RawQuery, "settle") {
|
||||
settled = append(settled, "create:"+states)
|
||||
|
||||
_, _ = w.Write([]byte(`{"state":"WAITING"}`))
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
settled = append(settled, "settle:"+states)
|
||||
|
||||
_, _ = w.Write([]byte(tc.settle))
|
||||
case "/api/info/m1":
|
||||
_, _ = w.Write([]byte(`{"_id":"m1","status":"` + tc.expected + `","result":"FAILED"}`))
|
||||
default:
|
||||
t.Errorf("unexpected path %s", r.URL.Path)
|
||||
}
|
||||
}))
|
||||
|
||||
defer server.Close()
|
||||
|
||||
client, err := NewConformanceClient(server.URL)
|
||||
require.NoError(t, err)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Second*5)
|
||||
defer cancel()
|
||||
|
||||
var outcomes []ConformanceOutcome
|
||||
|
||||
for outcome := range NewConformanceRunner(client, nil, "plan1", []ConformancePlanModule{{TestModule: "oidcc-server"}}).Run(ctx) {
|
||||
outcomes = append(outcomes, outcome)
|
||||
}
|
||||
|
||||
require.Len(t, outcomes, 1)
|
||||
require.Error(t, outcomes[0].Err)
|
||||
assert.Equal(t, tc.expected, outcomes[0].Status)
|
||||
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
|
||||
assert.Equal(t, []string{"create:CONFIGURED,WAITING,FINISHED,INTERRUPTED", "settle:CONFIGURED,WAITING,FINISHED,INTERRUPTED"}, settled)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
// SPDX-FileCopyrightText: 2026 Authelia
|
||||
//
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package suites
|
||||
|
||||
import (
|
||||
"io/fs"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestConformanceSelectorsExistInThePortal(t *testing.T) {
|
||||
portal := conformancePortalSource(t)
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
selector string
|
||||
}{
|
||||
{"FirstFactorStage", conformanceSelectorFirstFactor},
|
||||
{"ConsentStage", conformanceSelectorConsent},
|
||||
{"ConsentAccept", conformanceSelectorConsentAccept},
|
||||
{"ConsentAuthenticate", conformanceSelectorConsentAuthenticate},
|
||||
{"ConsentReauthenticationField", conformanceSelectorConsentReauthentication},
|
||||
{"ConsentReauthenticationPassword", conformanceSelectorConsentPassword},
|
||||
{"Username", conformanceSelectorUsername},
|
||||
{"Password", conformanceSelectorPassword},
|
||||
{"SignIn", conformanceSelectorSignIn},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
for _, id := range conformanceSelectorIdentifiers(tc.selector) {
|
||||
assert.Containsf(t, portal, id, "the portal no longer renders anything with the id %q, which %q depends on", id, tc.selector)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("AutheliaErrorToast", func(t *testing.T) {
|
||||
assert.Contains(t, portal, "notification", "the toast no longer carries the notification class")
|
||||
assert.Contains(t, portal, "data-[type=error]", "the toast no longer distinguishes an error by data-type")
|
||||
})
|
||||
|
||||
t.Run("AutheliaCompletionError", func(t *testing.T) {
|
||||
assert.Contains(t, portal, `data-testid={"openid-completion-outcome"}`, "the completion view no longer carries its outcome test id")
|
||||
assert.Contains(t, portal, "data-outcome={outcome}", "the completion view no longer exposes its outcome")
|
||||
assert.Contains(t, portal, `error ? "error"`, "the completion view no longer names the error outcome 'error'")
|
||||
})
|
||||
}
|
||||
|
||||
func conformanceSelectorIdentifiers(selector string) (ids []string) {
|
||||
for _, part := range strings.Fields(selector) {
|
||||
if !strings.HasPrefix(part, "#") {
|
||||
continue
|
||||
}
|
||||
|
||||
ids = append(ids, strings.TrimPrefix(part, "#"))
|
||||
}
|
||||
|
||||
return ids
|
||||
}
|
||||
|
||||
func conformancePortalSource(t *testing.T) string {
|
||||
t.Helper()
|
||||
|
||||
builder := &strings.Builder{}
|
||||
|
||||
root, err := os.OpenRoot("../../web/src")
|
||||
require.NoError(t, err)
|
||||
|
||||
defer root.Close()
|
||||
|
||||
require.NoError(t, fs.WalkDir(root.FS(), ".", func(path string, entry fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if entry.IsDir() || !strings.HasSuffix(path, ".tsx") {
|
||||
return nil
|
||||
}
|
||||
|
||||
data, err := root.ReadFile(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
builder.Write(data)
|
||||
|
||||
return nil
|
||||
}))
|
||||
|
||||
require.NotEmpty(t, builder.String(), "no portal sources were read, so this test proves nothing")
|
||||
|
||||
return builder.String()
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
// SPDX-FileCopyrightText: 2026 Authelia
|
||||
//
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package suites
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestConformanceRunner_AwaitPlaceholderSettledReturnsAsSoonAsTheStatusChanges(t *testing.T) {
|
||||
client, calls, closeServer := conformanceSettleServer(t, 2)
|
||||
defer closeServer()
|
||||
|
||||
runner := NewConformanceRunner(client, nil, "plan1", nil)
|
||||
|
||||
start := time.Now()
|
||||
|
||||
status, err := runner.awaitPlaceholderSettled(context.Background(), "m1", time.Millisecond*20, time.Second*2)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, "FINISHED", status)
|
||||
assert.Equal(t, int32(3), calls.Load(), "polls until it changes and then stops")
|
||||
assert.Less(t, time.Since(start), time.Second, "does not wait out the whole window once it has an answer")
|
||||
}
|
||||
|
||||
func TestConformanceRunner_AwaitPlaceholderSettledChecksBeforeWaitingAtAll(t *testing.T) {
|
||||
client, calls, closeServer := conformanceSettleServer(t, 0)
|
||||
defer closeServer()
|
||||
|
||||
runner := NewConformanceRunner(client, nil, "plan1", nil)
|
||||
|
||||
start := time.Now()
|
||||
|
||||
status, err := runner.awaitPlaceholderSettled(context.Background(), "m1", time.Second*5, time.Second*30)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, "FINISHED", status)
|
||||
assert.Equal(t, int32(1), calls.Load())
|
||||
assert.Less(t, time.Since(start), time.Second, "a module which already moved on is not made to wait for the interval")
|
||||
}
|
||||
|
||||
func TestConformanceRunner_AwaitPlaceholderSettledGivesUpAtTheTimeoutAndReportsWhatItSaw(t *testing.T) {
|
||||
client, calls, closeServer := conformanceSettleServer(t, 1000)
|
||||
defer closeServer()
|
||||
|
||||
runner := NewConformanceRunner(client, nil, "plan1", nil)
|
||||
|
||||
status, err := runner.awaitPlaceholderSettled(context.Background(), "m1", time.Millisecond*20, time.Millisecond*100)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, conformanceStatusWaiting, status, "reports the status it last saw rather than erroring")
|
||||
assert.Greater(t, calls.Load(), int32(2), "polled repeatedly across the window")
|
||||
}
|
||||
|
||||
func TestConformanceRunner_AwaitPlaceholderSettledStopsWhenTheContextExpires(t *testing.T) {
|
||||
client, _, closeServer := conformanceSettleServer(t, 1000)
|
||||
defer closeServer()
|
||||
|
||||
runner := NewConformanceRunner(client, nil, "plan1", nil)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Millisecond*50)
|
||||
defer cancel()
|
||||
|
||||
_, err := runner.awaitPlaceholderSettled(ctx, "m1", time.Millisecond*20, time.Minute)
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "m1")
|
||||
assert.Contains(t, err.Error(), "evidence image")
|
||||
}
|
||||
|
||||
func TestConformanceUploadSettleMatchesTheIntendedCadence(t *testing.T) {
|
||||
assert.Equal(t, time.Second, conformanceUploadSettleInterval)
|
||||
assert.Equal(t, time.Second*10, conformanceUploadSettleTimeout)
|
||||
}
|
||||
|
||||
func conformanceSettleServer(t *testing.T, waits int32) (client *ConformanceClient, calls *atomic.Int32, close func()) {
|
||||
t.Helper()
|
||||
|
||||
calls = &atomic.Int32{}
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, "/api/info/m1", r.URL.Path)
|
||||
|
||||
status := "FINISHED"
|
||||
|
||||
if calls.Add(1) <= waits {
|
||||
status = "WAITING"
|
||||
}
|
||||
|
||||
_, _ = fmt.Fprintf(w, `{"_id":"m1","status":%q,"result":"REVIEW"}`, status)
|
||||
}))
|
||||
|
||||
client, err := NewConformanceClient(server.URL)
|
||||
require.NoError(t, err)
|
||||
|
||||
return client, calls, server.Close
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
// SPDX-FileCopyrightText: 2026 Authelia
|
||||
//
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package suites
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ConformanceTrace records what the suite did for one module, for that module's subtest to log. The runner works on
|
||||
// its own goroutine, which outlives any one subtest, so it records rather than logging directly. It records nothing
|
||||
// unless the suite was asked to debug.
|
||||
type ConformanceTrace struct {
|
||||
enabled bool
|
||||
lines []string
|
||||
}
|
||||
|
||||
// NewConformanceTrace returns a ConformanceTrace which records only when enabled.
|
||||
func NewConformanceTrace(enabled bool) *ConformanceTrace {
|
||||
return &ConformanceTrace{enabled: enabled}
|
||||
}
|
||||
|
||||
// Logf records a line, stamped with the time in UTC so it can be lined up with the container logs.
|
||||
func (t *ConformanceTrace) Logf(format string, args ...any) {
|
||||
if t == nil || !t.enabled {
|
||||
return
|
||||
}
|
||||
|
||||
t.lines = append(t.lines, time.Now().UTC().Format("15:04:05.000")+" "+fmt.Sprintf(format, args...))
|
||||
}
|
||||
|
||||
// Lines returns the recorded lines.
|
||||
func (t *ConformanceTrace) Lines() []string {
|
||||
if t == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
return t.lines
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
// SPDX-FileCopyrightText: 2026 Authelia
|
||||
//
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package suites
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"regexp"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestConformanceTrace(t *testing.T) {
|
||||
t.Run("ShouldRecordNothingUnlessDebugging", func(t *testing.T) {
|
||||
trace := NewConformanceTrace(false)
|
||||
|
||||
trace.Logf("driving leg %d", 0)
|
||||
|
||||
assert.Empty(t, trace.Lines())
|
||||
})
|
||||
|
||||
t.Run("ShouldRecordEachLineStampedWithTheTime", func(t *testing.T) {
|
||||
trace := NewConformanceTrace(true)
|
||||
|
||||
trace.Logf("driving leg %d", 0)
|
||||
trace.Logf("finished leg %d", 0)
|
||||
|
||||
lines := trace.Lines()
|
||||
|
||||
require.Len(t, lines, 2)
|
||||
assert.Regexp(t, regexp.MustCompile(`^\d{2}:\d{2}:\d{2}\.\d{3} driving leg 0$`), lines[0])
|
||||
assert.Regexp(t, regexp.MustCompile(`^\d{2}:\d{2}:\d{2}\.\d{3} finished leg 0$`), lines[1])
|
||||
})
|
||||
|
||||
t.Run("ShouldBeSafeWithoutATrace", func(t *testing.T) {
|
||||
var trace *ConformanceTrace
|
||||
|
||||
assert.NotPanics(t, func() { trace.Logf("driving leg %d", 0) })
|
||||
assert.Empty(t, trace.Lines())
|
||||
})
|
||||
}
|
||||
|
||||
func TestSuiteDebug(t *testing.T) {
|
||||
t.Setenv("SUITE_DEBUG", "true")
|
||||
assert.True(t, suiteDebug())
|
||||
|
||||
t.Setenv("SUITE_DEBUG", "")
|
||||
assert.False(t, suiteDebug())
|
||||
}
|
||||
|
||||
func TestConformanceRunner_TracesEachModuleOnlyWhenDebugging(t *testing.T) {
|
||||
for _, debug := range []bool{false, true} {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/api/runner":
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
_, _ = w.Write([]byte(`{"id":"m1"}`))
|
||||
case "/api/runner/m1/wait-state":
|
||||
_, _ = w.Write([]byte(`{"state":"FINISHED"}`))
|
||||
case "/api/info/m1":
|
||||
_, _ = w.Write([]byte(`{"_id":"m1","status":"FINISHED","result":"PASSED"}`))
|
||||
default:
|
||||
t.Errorf("unexpected path %s", r.URL.Path)
|
||||
}
|
||||
}))
|
||||
|
||||
client, err := NewConformanceClient(server.URL)
|
||||
require.NoError(t, err)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Second*5)
|
||||
|
||||
runner := NewConformanceRunner(client, nil, "plan1", []ConformancePlanModule{{TestModule: "oidcc-server"}})
|
||||
runner.debug = debug
|
||||
|
||||
var outcomes []ConformanceOutcome
|
||||
|
||||
for outcome := range runner.Run(ctx) {
|
||||
outcomes = append(outcomes, outcome)
|
||||
}
|
||||
|
||||
cancel()
|
||||
server.Close()
|
||||
|
||||
require.Len(t, outcomes, 1)
|
||||
|
||||
if debug {
|
||||
require.NotEmpty(t, outcomes[0].Trace, "a debugging run records what the module did")
|
||||
assert.Contains(t, outcomes[0].Trace[0], "created as 'm1' in state 'FINISHED'")
|
||||
} else {
|
||||
assert.Empty(t, outcomes[0].Trace, "a run which is not debugging records nothing")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
// SPDX-FileCopyrightText: 2026 Authelia
|
||||
//
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package suites
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/authelia/authelia/v4/internal/authentication"
|
||||
)
|
||||
|
||||
func TestOIDCConformanceUserSuppliesEveryStandardClaim(t *testing.T) {
|
||||
database := authentication.NewFileUserDatabase("OIDCConformance/users.yml", false, false, nil)
|
||||
|
||||
require.NoError(t, database.Load())
|
||||
|
||||
user, err := database.GetUserDetails(testUsername)
|
||||
require.NoError(t, err)
|
||||
|
||||
details := user.ToExtendedUserDetails()
|
||||
|
||||
// scope=profile, less the claims Authelia derives rather than reads: preferred_username comes from the username
|
||||
// and updated_at is generated.
|
||||
assert.NotEmpty(t, details.GetDisplayName(), "backs the 'name' claim")
|
||||
assert.NotEmpty(t, details.GetGivenName(), "backs the 'given_name' claim")
|
||||
assert.NotEmpty(t, details.GetFamilyName(), "backs the 'family_name' claim")
|
||||
assert.NotEmpty(t, details.GetMiddleName(), "backs the 'middle_name' claim")
|
||||
assert.NotEmpty(t, details.GetNickname(), "backs the 'nickname' claim")
|
||||
assert.NotEmpty(t, details.GetProfile(), "backs the 'profile' claim")
|
||||
assert.NotEmpty(t, details.GetPicture(), "backs the 'picture' claim")
|
||||
assert.NotEmpty(t, details.GetWebsite(), "backs the 'website' claim")
|
||||
assert.NotEmpty(t, details.GetGender(), "backs the 'gender' claim")
|
||||
assert.NotEmpty(t, details.GetBirthdate(), "backs the 'birthdate' claim")
|
||||
assert.NotEmpty(t, details.GetZoneInfo(), "backs the 'zoneinfo' claim")
|
||||
assert.NotEmpty(t, details.GetLocale(), "backs the 'locale' claim")
|
||||
|
||||
// scope=email, less email_verified which Authelia always reports.
|
||||
assert.NotEmpty(t, details.GetEmails(), "backs the 'email' claim")
|
||||
|
||||
// scope=phone. Authelia emits phone_number_verified only when a number is present, so the number carries both
|
||||
// claims of this scope.
|
||||
assert.NotEmpty(t, details.GetPhoneNumberRFC3966(), "backs the 'phone_number' and 'phone_number_verified' claims")
|
||||
|
||||
// scope=address, whose single claim is an object Authelia assembles from these five.
|
||||
assert.NotEmpty(t, details.GetStreetAddress(), "backs the 'address' claim")
|
||||
assert.NotEmpty(t, details.GetLocality(), "backs the 'address' claim")
|
||||
assert.NotEmpty(t, details.GetRegion(), "backs the 'address' claim")
|
||||
assert.NotEmpty(t, details.GetPostalCode(), "backs the 'address' claim")
|
||||
assert.NotEmpty(t, details.GetCountry(), "backs the 'address' claim")
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
// SPDX-FileCopyrightText: 2026 Authelia
|
||||
//
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package suites
|
||||
|
||||
import (
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestWriteCoverage(t *testing.T) {
|
||||
dir := filepath.Join(t.TempDir(), ".nyc_output")
|
||||
|
||||
require.NoError(t, writeCoverage(dir, `{"/node/src/app/src/index.tsx":{"path":"/node/src/app/src/index.tsx"}}`))
|
||||
require.NoError(t, writeCoverage(dir, `{}`))
|
||||
|
||||
entries, err := os.ReadDir(dir)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, entries, 2, "each document's coverage is written to a file of its own, so the report merges them rather than one replacing another")
|
||||
|
||||
wd, err := os.Getwd()
|
||||
require.NoError(t, err)
|
||||
|
||||
web := strings.TrimSuffix(wd, "internal/suites") + "web/"
|
||||
|
||||
var found bool
|
||||
|
||||
for _, entry := range entries {
|
||||
assert.True(t, strings.HasPrefix(entry.Name(), "coverage-") && strings.HasSuffix(entry.Name(), ".json"), entry.Name())
|
||||
|
||||
data, err := os.ReadFile(filepath.Join(dir, entry.Name()))
|
||||
require.NoError(t, err)
|
||||
|
||||
if strings.Contains(string(data), "index.tsx") {
|
||||
found = true
|
||||
|
||||
assert.NotContains(t, string(data), "/node/src/app/", "the paths the frontend was built at must be rewritten to this checkout")
|
||||
assert.Contains(t, string(data), web+"src/index.tsx")
|
||||
}
|
||||
}
|
||||
|
||||
assert.True(t, found)
|
||||
}
|
||||
|
||||
func TestConformanceCoverageScript(t *testing.T) {
|
||||
assert.Contains(t, conformanceCoverageScript, "'beforeunload'")
|
||||
assert.Contains(t, conformanceCoverageScript, "window."+conformanceCoverageBinding+"(")
|
||||
assert.Contains(t, conformanceCoverageScript, "window.__coverage__")
|
||||
}
|
||||
|
||||
func TestWriteCoverageReportsADirectoryItCannotCreate(t *testing.T) {
|
||||
file := filepath.Join(t.TempDir(), "file")
|
||||
|
||||
require.NoError(t, os.WriteFile(file, nil, 0600))
|
||||
|
||||
assert.Error(t, writeCoverage(filepath.Join(file, ".nyc_output"), `{}`))
|
||||
}
|
||||
|
||||
func TestEmitBuildkiteMarker(t *testing.T) {
|
||||
capture := func(t *testing.T, marker string) string {
|
||||
t.Helper()
|
||||
|
||||
r, w, err := os.Pipe()
|
||||
require.NoError(t, err)
|
||||
|
||||
stdout := os.Stdout
|
||||
os.Stdout = w
|
||||
|
||||
emitBuildkiteMarker(marker)
|
||||
|
||||
os.Stdout = stdout
|
||||
|
||||
require.NoError(t, w.Close())
|
||||
|
||||
data, err := io.ReadAll(r)
|
||||
require.NoError(t, err)
|
||||
|
||||
return string(data)
|
||||
}
|
||||
|
||||
assert.Equal(t, "--- OIDC Conformance Plan: basic\n", capture(t, "--- OIDC Conformance Plan: basic"))
|
||||
assert.Empty(t, capture(t, ""), "no marker is written outside Buildkite")
|
||||
}
|
||||
@@ -16,6 +16,7 @@ services:
|
||||
- secure.example.com
|
||||
- login.example.com
|
||||
- duo.example.com
|
||||
- conformance.example.com
|
||||
# Set the IP to be able to query on port 443
|
||||
ipv4_address: ${SUITE_SUBNET:-192.168.240}.100
|
||||
...
|
||||
@@ -450,6 +450,142 @@ http {
|
||||
}
|
||||
}
|
||||
|
||||
# OpenID Connect 1.0 conformance suite. This replaces the nginx image the conformance project publishes, whose
|
||||
# configuration is baked in and only answers on that container's own address. The three listeners below mirror
|
||||
# that image: 8443 is the ordinary one, 8444 asks for a client certificate so the mutual TLS tests have somewhere
|
||||
# to land, and 8445 narrows the ciphers to the FAPI 2.0 set. Only 8443 is exercised today, but a suite that grows
|
||||
# a profile reaching for the other two finds them where it expects rather than failing part way through a plan.
|
||||
#
|
||||
# Each listener repeats the proxy body rather than sharing one: nginx would need an include file mounted beside
|
||||
# this one to factor it out, and the ports differ in the handshake, not in what they forward. The buffer sizes,
|
||||
# timeouts and forwarded headers are what the published image sets and the suite depends on, so they are
|
||||
# reproduced here rather than left to the defaults the blocks above share.
|
||||
server {
|
||||
listen 8443 ssl;
|
||||
server_name ~^conformance\.example([0-9])*\.com$;
|
||||
|
||||
# The mutual TLS tests have to arrive on a listener that asks for a client certificate, which this one does
|
||||
# not, so the suite is told it reached the wrong host and reports that rather than timing out.
|
||||
ssl_verify_client off;
|
||||
|
||||
resolver 127.0.0.11 ipv6=off;
|
||||
set $upstream_endpoint http://conformance-server:8080;
|
||||
|
||||
ssl_certificate /pki/public.chain.pem;
|
||||
ssl_certificate_key /pki/private.pem;
|
||||
ssl_protocols TLSv1.2 TLSv1.3;
|
||||
|
||||
# The suite sends request objects and authorization requests that overflow the default 1k header buffer.
|
||||
client_header_buffer_size 32k;
|
||||
large_client_header_buffers 4 32k;
|
||||
|
||||
location / {
|
||||
# The suite has requests that block waiting for callbacks for up to 60s, which the default 60s proxy
|
||||
# timeout races against and turns into sporadic 504s.
|
||||
proxy_read_timeout 120s;
|
||||
proxy_send_timeout 120s;
|
||||
|
||||
set $mtls_wrong_host "";
|
||||
if ($request_uri ~* "^/test-mtls") {
|
||||
set $mtls_wrong_host "true";
|
||||
}
|
||||
proxy_set_header X-Test-Mtls-Called-On-Wrong-Host $mtls_wrong_host;
|
||||
|
||||
# Host is deliberately $host rather than $http_host: the suite rebuilds its own base URL from
|
||||
# X-Forwarded-Host and X-Forwarded-Port, so a port carried in either of the first two would be doubled.
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Forwarded-Host $host;
|
||||
proxy_set_header X-Forwarded-Port $server_port;
|
||||
proxy_set_header X-Forwarded-Uri $request_uri;
|
||||
proxy_set_header X-Ssl-Cipher $ssl_cipher;
|
||||
proxy_set_header X-Ssl-Protocol $ssl_protocol;
|
||||
proxy_set_header X-Ssl-Cert $ssl_client_cert;
|
||||
proxy_set_header Forwarded "by=$server_addr;for=$remote_addr;host=$host;proto=$scheme";
|
||||
|
||||
proxy_pass_request_headers on;
|
||||
proxy_pass $upstream_endpoint;
|
||||
}
|
||||
}
|
||||
|
||||
# Mutual TLS. The client certificate is requested but not checked against a CA: the suite inspects it itself out
|
||||
# of X-Ssl-Cert, which is why this is optional_no_ca rather than on.
|
||||
server {
|
||||
listen 8444 ssl;
|
||||
server_name ~^conformance\.example([0-9])*\.com$;
|
||||
|
||||
ssl_verify_client optional_no_ca;
|
||||
|
||||
resolver 127.0.0.11 ipv6=off;
|
||||
set $upstream_endpoint http://conformance-server:8080;
|
||||
|
||||
ssl_certificate /pki/public.chain.pem;
|
||||
ssl_certificate_key /pki/private.pem;
|
||||
ssl_protocols TLSv1.2 TLSv1.3;
|
||||
|
||||
client_header_buffer_size 32k;
|
||||
large_client_header_buffers 4 32k;
|
||||
|
||||
location / {
|
||||
proxy_read_timeout 120s;
|
||||
proxy_send_timeout 120s;
|
||||
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Forwarded-Host $host;
|
||||
proxy_set_header X-Forwarded-Port $server_port;
|
||||
proxy_set_header X-Forwarded-Uri $request_uri;
|
||||
proxy_set_header X-Ssl-Cipher $ssl_cipher;
|
||||
proxy_set_header X-Ssl-Protocol $ssl_protocol;
|
||||
proxy_set_header X-Ssl-Cert $ssl_client_cert;
|
||||
proxy_set_header Forwarded "by=$server_addr;for=$remote_addr;host=$host;proto=$scheme";
|
||||
|
||||
proxy_pass_request_headers on;
|
||||
proxy_pass $upstream_endpoint;
|
||||
}
|
||||
}
|
||||
|
||||
# The FAPI 2.0 Final security profile cipher set.
|
||||
server {
|
||||
listen 8445 ssl;
|
||||
server_name ~^conformance\.example([0-9])*\.com$;
|
||||
|
||||
ssl_verify_client optional_no_ca;
|
||||
ssl_ciphers ECDHE-RSA-AES128-GCM-SHA256:ECDHE-RSA-AES256-GCM-SHA384;
|
||||
ssl_prefer_server_ciphers on;
|
||||
|
||||
resolver 127.0.0.11 ipv6=off;
|
||||
set $upstream_endpoint http://conformance-server:8080;
|
||||
|
||||
ssl_certificate /pki/public.chain.pem;
|
||||
ssl_certificate_key /pki/private.pem;
|
||||
ssl_protocols TLSv1.2 TLSv1.3;
|
||||
|
||||
client_header_buffer_size 32k;
|
||||
large_client_header_buffers 4 32k;
|
||||
|
||||
location / {
|
||||
proxy_read_timeout 120s;
|
||||
proxy_send_timeout 120s;
|
||||
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Forwarded-Host $host;
|
||||
proxy_set_header X-Forwarded-Port $server_port;
|
||||
proxy_set_header X-Forwarded-Uri $request_uri;
|
||||
proxy_set_header X-Ssl-Cipher $ssl_cipher;
|
||||
proxy_set_header X-Ssl-Protocol $ssl_protocol;
|
||||
proxy_set_header X-Ssl-Cert $ssl_client_cert;
|
||||
proxy_set_header Forwarded "by=$server_addr;for=$remote_addr;host=$host;proto=$scheme";
|
||||
|
||||
proxy_pass_request_headers on;
|
||||
proxy_pass $upstream_endpoint;
|
||||
}
|
||||
}
|
||||
|
||||
# Fake Web Mail used to receive emails sent by Authelia.
|
||||
server {
|
||||
listen 8080 ssl;
|
||||
|
||||
@@ -57,6 +57,9 @@ func HostEntries() []HostEntry {
|
||||
{Domain: "oidc.example.com", IP: portal},
|
||||
{Domain: "oidc-public.example.com", IP: portal},
|
||||
|
||||
// OpenID Connect 1.0 conformance suite.
|
||||
{Domain: "conformance.example.com", IP: portal},
|
||||
|
||||
// For Traefik suite.
|
||||
{Domain: "traefik.example.com", IP: portal},
|
||||
|
||||
|
||||
@@ -119,7 +119,7 @@ func AssertVisualSnapshot(t *testing.T, repoRoot, name string, screenshot []byte
|
||||
}
|
||||
|
||||
if diff.Percentage <= tolerancePercentage {
|
||||
t.Logf("snapshot %s differs by %d/%d pixels (%.2f%%) — within tolerance (%.2f%%)",
|
||||
t.Logf("snapshot %s differs by %d/%d pixels (%.2f%%) - within tolerance (%.2f%%)",
|
||||
baselinePath, diff.DifferingPixels, diff.TotalPixels, diff.Percentage, tolerancePercentage)
|
||||
|
||||
_ = os.Remove(actualPath)
|
||||
|
||||
@@ -45,11 +45,12 @@ func init() {
|
||||
|
||||
GlobalRegistry.Register(activedirectorySuiteName, Suite{
|
||||
SetUp: setup,
|
||||
SetUpTimeout: 5 * time.Minute,
|
||||
SetUpTimeout: 2 * time.Minute,
|
||||
OnSetupTimeout: displayAutheliaLogs,
|
||||
OnError: displayAutheliaLogs,
|
||||
TestTimeout: 120 * time.Second,
|
||||
TearDown: teardown,
|
||||
TearDownTimeout: 2 * time.Minute,
|
||||
OnError: displayAutheliaLogs,
|
||||
TearDownTimeout: 1 * time.Minute,
|
||||
Description: "This suite has been created to test Authelia against an Active Directory implementation of LDAP, backed by a Samba domain controller.",
|
||||
})
|
||||
}
|
||||
@@ -44,12 +44,12 @@ func init() {
|
||||
|
||||
GlobalRegistry.Register(bypassAllSuiteName, Suite{
|
||||
SetUp: setup,
|
||||
SetUpTimeout: 5 * time.Minute,
|
||||
SetUpTimeout: 2 * time.Minute,
|
||||
OnSetupTimeout: displayAutheliaLogs,
|
||||
OnError: displayAutheliaLogs,
|
||||
TestTimeout: 1 * time.Minute,
|
||||
TearDown: teardown,
|
||||
TearDownTimeout: 2 * time.Minute,
|
||||
TearDownTimeout: 1 * time.Minute,
|
||||
Description: "This suite has been created to test Authelia with a bypass policy on all resources",
|
||||
})
|
||||
}
|
||||
@@ -58,11 +58,12 @@ func init() {
|
||||
|
||||
GlobalRegistry.Register(caddySuiteName, Suite{
|
||||
SetUp: setup,
|
||||
SetUpTimeout: 5 * time.Minute,
|
||||
SetUpTimeout: 2 * time.Minute,
|
||||
OnSetupTimeout: displayAutheliaLogs,
|
||||
OnError: displayAutheliaLogs,
|
||||
TestTimeout: 150 * time.Second,
|
||||
TearDown: teardown,
|
||||
TearDownTimeout: 2 * time.Minute,
|
||||
TearDownTimeout: 1 * time.Minute,
|
||||
Description: "This suite has been created to test Authelia as an authorization server behind the Caddy reverse proxy.",
|
||||
})
|
||||
}
|
||||
@@ -44,11 +44,13 @@ func init() {
|
||||
|
||||
GlobalRegistry.Register(cliSuiteName, Suite{
|
||||
SetUp: setup,
|
||||
SetUpTimeout: 5 * time.Minute,
|
||||
SetUpTimeout: 2 * time.Minute,
|
||||
OnSetupTimeout: displayAutheliaLogs,
|
||||
OnError: displayAutheliaLogs,
|
||||
TestTimeout: 3 * time.Minute,
|
||||
TearDown: teardown,
|
||||
TearDownTimeout: 2 * time.Minute,
|
||||
TearDownTimeout: 1 * time.Minute,
|
||||
Description: `This suite has been created to test the Authelia command line interface, covering the
|
||||
configuration validation, password hashing, certificate generation, storage and access control subcommands.`,
|
||||
})
|
||||
}
|
||||
@@ -43,13 +43,12 @@ func init() {
|
||||
|
||||
GlobalRegistry.Register(dockerSuiteName, Suite{
|
||||
SetUp: setup,
|
||||
SetUpTimeout: 5 * time.Minute,
|
||||
SetUpTimeout: 2 * time.Minute,
|
||||
OnSetupTimeout: displayAutheliaLogs,
|
||||
OnError: displayAutheliaLogs,
|
||||
TestTimeout: 1 * time.Minute,
|
||||
TearDown: teardown,
|
||||
TearDownTimeout: 2 * time.Minute,
|
||||
|
||||
TearDownTimeout: 1 * time.Minute,
|
||||
Description: `This suite has been created to test the distributable version of Authelia
|
||||
It's often useful to test this one before the Kube one.`,
|
||||
})
|
||||
|
||||
@@ -47,13 +47,12 @@ func init() {
|
||||
|
||||
GlobalRegistry.Register(duoPushSuiteName, Suite{
|
||||
SetUp: setup,
|
||||
SetUpTimeout: 5 * time.Minute,
|
||||
SetUpTimeout: 2 * time.Minute,
|
||||
OnSetupTimeout: displayAutheliaLogs,
|
||||
OnError: displayAutheliaLogs,
|
||||
TestTimeout: 4 * time.Minute,
|
||||
TearDown: teardown,
|
||||
TearDownTimeout: 2 * time.Minute,
|
||||
|
||||
TearDownTimeout: 1 * time.Minute,
|
||||
Description: `This suite has been created to test Authelia against
|
||||
the Duo API for push notifications. It allows a user to validate second factor
|
||||
with a mobile phone.`,
|
||||
|
||||
@@ -58,11 +58,12 @@ func init() {
|
||||
|
||||
GlobalRegistry.Register(envoySuiteName, Suite{
|
||||
SetUp: setup,
|
||||
SetUpTimeout: 5 * time.Minute,
|
||||
SetUpTimeout: 2 * time.Minute,
|
||||
OnSetupTimeout: displayAutheliaLogs,
|
||||
OnError: displayAutheliaLogs,
|
||||
TestTimeout: 150 * time.Second,
|
||||
TearDown: teardown,
|
||||
TearDownTimeout: 2 * time.Minute,
|
||||
TearDownTimeout: 1 * time.Minute,
|
||||
Description: "This suite has been created to test Authelia as an authorization server behind the Envoy reverse proxy.",
|
||||
})
|
||||
}
|
||||
@@ -58,11 +58,12 @@ func init() {
|
||||
|
||||
GlobalRegistry.Register(haproxySuiteName, Suite{
|
||||
SetUp: setup,
|
||||
SetUpTimeout: 5 * time.Minute,
|
||||
SetUpTimeout: 2 * time.Minute,
|
||||
OnSetupTimeout: displayAutheliaLogs,
|
||||
OnError: displayAutheliaLogs,
|
||||
TestTimeout: 2 * time.Minute,
|
||||
TearDown: teardown,
|
||||
TearDownTimeout: 2 * time.Minute,
|
||||
TearDownTimeout: 1 * time.Minute,
|
||||
Description: "This suite has been created to test Authelia as an authorization server behind the HAProxy reverse proxy.",
|
||||
})
|
||||
}
|
||||
@@ -48,12 +48,12 @@ func init() {
|
||||
|
||||
GlobalRegistry.Register(highAvailabilitySuiteName, Suite{
|
||||
SetUp: setup,
|
||||
SetUpTimeout: 5 * time.Minute,
|
||||
SetUpTimeout: 2 * time.Minute,
|
||||
OnSetupTimeout: displayAutheliaLogs,
|
||||
OnError: displayAutheliaLogs,
|
||||
TestTimeout: 6 * time.Minute,
|
||||
TearDown: teardown,
|
||||
TearDownTimeout: 2 * time.Minute,
|
||||
OnError: displayAutheliaLogs,
|
||||
TearDownTimeout: 1 * time.Minute,
|
||||
Description: `This suite is made to test Authelia in a *complete*
|
||||
environment, that is, with all components making Authelia highly available.`,
|
||||
})
|
||||
|
||||
@@ -102,10 +102,10 @@ func init() {
|
||||
|
||||
GlobalRegistry.Register(kubernetesSuiteName, Suite{
|
||||
SetUp: setup,
|
||||
SetUpTimeout: 12 * time.Minute,
|
||||
SetUpTimeout: 2 * time.Minute,
|
||||
TestTimeout: 2 * time.Minute,
|
||||
TearDown: teardown,
|
||||
TearDownTimeout: 2 * time.Minute,
|
||||
TearDownTimeout: 1 * time.Minute,
|
||||
Description: "This suite has been created to test Authelia in a Kubernetes context and using Traefik as the ingress controller.",
|
||||
})
|
||||
}
|
||||
|
||||
@@ -46,11 +46,13 @@ func init() {
|
||||
|
||||
GlobalRegistry.Register(ldapSuiteName, Suite{
|
||||
SetUp: setup,
|
||||
SetUpTimeout: 5 * time.Minute,
|
||||
SetUpTimeout: 2 * time.Minute,
|
||||
OnSetupTimeout: displayAutheliaLogs,
|
||||
OnError: displayAutheliaLogs,
|
||||
TestTimeout: 150 * time.Second,
|
||||
TearDown: teardown,
|
||||
TearDownTimeout: 2 * time.Minute,
|
||||
OnError: displayAutheliaLogs,
|
||||
TearDownTimeout: 1 * time.Minute,
|
||||
Description: `This suite has been created to test Authelia against an OpenLDAP backend, including the
|
||||
phpLDAPadmin administration interface.`,
|
||||
})
|
||||
}
|
||||
@@ -46,10 +46,12 @@ func init() {
|
||||
|
||||
GlobalRegistry.Register(mariadbSuiteName, Suite{
|
||||
SetUp: setup,
|
||||
SetUpTimeout: 5 * time.Minute,
|
||||
SetUpTimeout: 2 * time.Minute,
|
||||
OnSetupTimeout: displayAutheliaLogs,
|
||||
OnError: displayAutheliaLogs,
|
||||
TestTimeout: 2 * time.Minute,
|
||||
TearDown: teardown,
|
||||
TearDownTimeout: 2 * time.Minute,
|
||||
TearDownTimeout: 1 * time.Minute,
|
||||
Description: "This suite has been created to test Authelia with MariaDB as the storage backend and LDAP as the user backend.",
|
||||
})
|
||||
}
|
||||
@@ -44,12 +44,12 @@ func init() {
|
||||
|
||||
GlobalRegistry.Register(multiCookieDomainSuiteName, Suite{
|
||||
SetUp: setup,
|
||||
SetUpTimeout: 5 * time.Minute,
|
||||
OnError: displayAutheliaLogs,
|
||||
SetUpTimeout: 2 * time.Minute,
|
||||
OnSetupTimeout: displayAutheliaLogs,
|
||||
TearDown: teardown,
|
||||
OnError: displayAutheliaLogs,
|
||||
TestTimeout: 4 * time.Minute,
|
||||
TearDownTimeout: 2 * time.Minute,
|
||||
TearDown: teardown,
|
||||
TearDownTimeout: 1 * time.Minute,
|
||||
Description: `This suite is used to test Authelia in a multi cookie domain
|
||||
configuration with in-memory sessions and a local sqlite db stored on disk`,
|
||||
})
|
||||
|
||||
@@ -46,10 +46,12 @@ func init() {
|
||||
|
||||
GlobalRegistry.Register(mysqlSuiteName, Suite{
|
||||
SetUp: setup,
|
||||
SetUpTimeout: 5 * time.Minute,
|
||||
SetUpTimeout: 2 * time.Minute,
|
||||
OnSetupTimeout: displayAutheliaLogs,
|
||||
OnError: displayAutheliaLogs,
|
||||
TestTimeout: 2 * time.Minute,
|
||||
TearDown: teardown,
|
||||
TearDownTimeout: 2 * time.Minute,
|
||||
TearDownTimeout: 1 * time.Minute,
|
||||
Description: "This suite has been created to test Authelia with MySQL as the storage backend and LDAP as the user backend.",
|
||||
})
|
||||
}
|
||||
@@ -46,12 +46,12 @@ func init() {
|
||||
|
||||
GlobalRegistry.Register(networkACLSuiteName, Suite{
|
||||
SetUp: setup,
|
||||
SetUpTimeout: 5 * time.Minute,
|
||||
SetUpTimeout: 2 * time.Minute,
|
||||
OnSetupTimeout: displayAutheliaLogs,
|
||||
OnError: displayAutheliaLogs,
|
||||
TestTimeout: 1 * time.Minute,
|
||||
TearDown: teardown,
|
||||
TearDownTimeout: 2 * time.Minute,
|
||||
TearDownTimeout: 1 * time.Minute,
|
||||
Description: `This suite has been created to test Authelia with basic feature in a non highly-available setup.
|
||||
Authelia basically use an in-memory cache to store user sessions and persist data on disk instead
|
||||
of using a remote database. Also, the user accounts are stored in file-based database.`,
|
||||
|
||||
@@ -52,11 +52,13 @@ func init() {
|
||||
|
||||
GlobalRegistry.Register(oidcSuiteName, Suite{
|
||||
SetUp: setup,
|
||||
SetUpTimeout: 5 * time.Minute,
|
||||
SetUpTimeout: 2 * time.Minute,
|
||||
OnSetupTimeout: displayAutheliaLogs,
|
||||
OnError: displayAutheliaLogs,
|
||||
TestTimeout: 2 * time.Minute,
|
||||
TearDown: teardown,
|
||||
TearDownTimeout: 2 * time.Minute,
|
||||
TearDownTimeout: 1 * time.Minute,
|
||||
Description: `This suite has been created to test Authelia as an OpenID Connect 1.0 Provider using a
|
||||
client application registered against it, with Redis backed sessions.`,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
// SPDX-FileCopyrightText: 2026 Authelia
|
||||
//
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package suites
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"go.yaml.in/yaml/v4"
|
||||
|
||||
"github.com/authelia/authelia/v4/internal/configuration/schema"
|
||||
"github.com/authelia/authelia/v4/internal/oidc/conformance"
|
||||
"github.com/authelia/authelia/v4/internal/random"
|
||||
"github.com/authelia/authelia/v4/internal/utils"
|
||||
)
|
||||
|
||||
const (
|
||||
oidcConformanceSuiteName = "OIDCConformance"
|
||||
oidcConformanceBaseURL = "https://conformance.example.com:8443"
|
||||
oidcConformanceAutheliaURL = "https://login.example.com:8080"
|
||||
oidcConformancePlansFile = "conformance-plans.json"
|
||||
oidcConformanceClientsFile = "conformance-clients.yml"
|
||||
oidcConformanceMongoDBHostEnv = "SUITE_OIDC_CONFORMANCE_MONGODB_HOST"
|
||||
oidcConformanceMongoDBComposeFile = "OIDCConformance/compose.mongodb.yml"
|
||||
)
|
||||
|
||||
// OIDCConformancePlanFile is one entry of the plans file exchanged between the setup and test processes. The API plan
|
||||
// name and the variant are carried as their own fields rather than being read back off the embedded
|
||||
// [conformance.Plan]: that type tags both of them json:"-" because authelia-gen holds them in memory and sends them as
|
||||
// query parameters while writing only the plan body to disk, so a round trip through this file would otherwise decode
|
||||
// them as their zero values and the suite would create every plan nameless and unvarianted.
|
||||
type OIDCConformancePlanFile struct {
|
||||
// Name is the builder name of the profile, which is what the suite's per profile test methods look plans up by.
|
||||
Name string `json:"name"`
|
||||
|
||||
// PlanName is the conformance suite's own name for the plan, sent as the planName query parameter.
|
||||
PlanName string `json:"plan_name"`
|
||||
|
||||
// Variant is the plan's variant, sent as the variant query parameter.
|
||||
Variant *conformance.PlanVariant `json:"variant,omitempty"`
|
||||
|
||||
// Plan is the plan body, sent as the request body.
|
||||
Plan conformance.Plan `json:"plan"`
|
||||
}
|
||||
|
||||
type oidcConformanceClients struct {
|
||||
IdentityProviders struct {
|
||||
OIDC struct {
|
||||
Clients []schema.IdentityProvidersOpenIDConnectClient `yaml:"clients"`
|
||||
} `yaml:"oidc"`
|
||||
} `yaml:"identity_providers"`
|
||||
}
|
||||
|
||||
func oidcConformanceGenerate() (err error) {
|
||||
suiteURL, err := url.ParseRequestURI(oidcConformanceBaseURL)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
autheliaURL, err := url.ParseRequestURI(oidcConformanceAutheliaURL)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
version := random.New().StringCustom(8, random.CharSetAlphabeticLower+random.CharSetNumeric)
|
||||
|
||||
clients := &oidcConformanceClients{}
|
||||
clients.IdentityProviders.OIDC.Clients = []schema.IdentityProvidersOpenIDConnectClient{}
|
||||
|
||||
var plans []OIDCConformancePlanFile
|
||||
|
||||
release := oidcConformanceRelease(oidcConformanceRevision())
|
||||
|
||||
for _, builder := range conformance.Builders(version, "implicit", "one_factor", "authelia", suiteURL, autheliaURL) {
|
||||
suite := builder.Build()
|
||||
|
||||
if release != "" {
|
||||
suite.Plan.Description = builder.Description(release)
|
||||
}
|
||||
|
||||
clients.IdentityProviders.OIDC.Clients = append(clients.IdentityProviders.OIDC.Clients, suite.Clients...)
|
||||
plans = append(plans, OIDCConformancePlanFile{Name: builder.Name, PlanName: suite.Plan.Name, Variant: suite.Plan.Variant, Plan: suite.Plan})
|
||||
}
|
||||
|
||||
if err = oidcConformanceWriteYAML(SuiteTmpPath(oidcConformanceClientsFile), clients); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return oidcConformanceWriteJSON(SuiteTmpPath(oidcConformancePlansFile), plans)
|
||||
}
|
||||
|
||||
func oidcConformanceRevision() (commit, tag string) {
|
||||
commit, _, _ = utils.RunCommandAndReturnOutput("git rev-parse HEAD")
|
||||
tag, _, _ = utils.RunCommandAndReturnOutput("git describe --tags --exact-match HEAD")
|
||||
|
||||
return commit, tag
|
||||
}
|
||||
|
||||
func oidcConformanceRelease(commit, tag string) string {
|
||||
switch {
|
||||
case commit == "":
|
||||
return ""
|
||||
case tag == "":
|
||||
return "commit " + commit
|
||||
default:
|
||||
return fmt.Sprintf("%s (commit %s)", tag, commit)
|
||||
}
|
||||
}
|
||||
|
||||
func oidcConformanceComposeFiles(mongodbHost string) (files []string) {
|
||||
files = []string{
|
||||
"internal/suites/compose.yml",
|
||||
"internal/suites/OIDCConformance/compose.yml",
|
||||
"internal/suites/example/compose/authelia/compose.backend.{}.yml",
|
||||
"internal/suites/example/compose/authelia/compose.frontend.{}.yml",
|
||||
"internal/suites/example/compose/nginx/backend/compose.yml",
|
||||
"internal/suites/example/compose/nginx/portal/compose.yml",
|
||||
"internal/suites/example/compose/smtp/compose.yml",
|
||||
"internal/suites/example/compose/redis/compose.yml",
|
||||
"internal/suites/example/compose/postgres/compose.yml",
|
||||
}
|
||||
|
||||
if mongodbHost == "" {
|
||||
files = append(files, "internal/suites/"+oidcConformanceMongoDBComposeFile)
|
||||
}
|
||||
|
||||
return files
|
||||
}
|
||||
|
||||
func oidcConformanceLogServices(mongodbHost string) (services []string) {
|
||||
services = []string{"authelia-backend", "authelia-frontend", "postgres"}
|
||||
|
||||
if mongodbHost == "" {
|
||||
services = append(services, "conformance-mongodb")
|
||||
}
|
||||
|
||||
return append(services, "conformance-server")
|
||||
}
|
||||
|
||||
func oidcConformanceWriteYAML(path string, value any) (err error) {
|
||||
f, err := os.OpenFile(path, os.O_TRUNC|os.O_CREATE|os.O_WRONLY, 0600)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
defer func() {
|
||||
if cerr := f.Close(); cerr != nil && err == nil {
|
||||
err = fmt.Errorf("error closing '%s': %w", path, cerr)
|
||||
}
|
||||
}()
|
||||
|
||||
encoder := yaml.NewEncoder(f)
|
||||
encoder.SetIndent(2)
|
||||
|
||||
defer func() {
|
||||
if cerr := encoder.Close(); cerr != nil && err == nil {
|
||||
err = fmt.Errorf("error flushing '%s': %w", path, cerr)
|
||||
}
|
||||
}()
|
||||
|
||||
return encoder.Encode(value)
|
||||
}
|
||||
|
||||
func oidcConformanceWriteJSON(path string, value any) (err error) {
|
||||
f, err := os.OpenFile(path, os.O_TRUNC|os.O_CREATE|os.O_WRONLY, 0600)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
defer func() {
|
||||
if cerr := f.Close(); cerr != nil && err == nil {
|
||||
err = fmt.Errorf("error closing '%s': %w", path, cerr)
|
||||
}
|
||||
}()
|
||||
|
||||
return json.NewEncoder(f).Encode(value)
|
||||
}
|
||||
|
||||
func oidcConformanceReadPlans() (plans []OIDCConformancePlanFile, err error) {
|
||||
data, err := os.ReadFile(SuiteTmpPath(oidcConformancePlansFile))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error reading the generated conformance plans, was the suite set up?: %w", err)
|
||||
}
|
||||
|
||||
if err = json.Unmarshal(data, &plans); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return plans, nil
|
||||
}
|
||||
|
||||
func init() {
|
||||
mongodbHost := os.Getenv(oidcConformanceMongoDBHostEnv)
|
||||
|
||||
dockerEnvironment := NewDockerEnvironment(oidcConformanceComposeFiles(mongodbHost))
|
||||
|
||||
setup := func(suitePath string) (err error) {
|
||||
if err = oidcConformanceGenerate(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err = dockerEnvironment.Up(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err = waitUntilAutheliaIsReady(dockerEnvironment, oidcConformanceSuiteName); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
client, err := NewConformanceClient(oidcConformanceBaseURL)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Minute*5)
|
||||
defer cancel()
|
||||
|
||||
if err = client.WaitReady(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return updateDevEnvFileForDomain(BaseDomain, dockerEnvironment)
|
||||
}
|
||||
|
||||
displayLogs := func() error {
|
||||
return dockerEnvironment.PrintLogs(oidcConformanceLogServices(mongodbHost)...)
|
||||
}
|
||||
|
||||
teardown := func(suitePath string) error {
|
||||
return dockerEnvironment.Down()
|
||||
}
|
||||
|
||||
GlobalRegistry.Register(oidcConformanceSuiteName, Suite{
|
||||
SetUp: setup,
|
||||
SetUpTimeout: 2 * time.Minute,
|
||||
OnSetupTimeout: displayLogs,
|
||||
OnError: displayLogs,
|
||||
TestTimeout: 8 * time.Minute,
|
||||
TearDown: teardown,
|
||||
TearDownTimeout: 1 * time.Minute,
|
||||
Description: "This suite runs the OpenID Foundation conformance suite against Authelia for every profile Authelia is OpenID Certified for.",
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,448 @@
|
||||
// SPDX-FileCopyrightText: 2026 Authelia
|
||||
//
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package suites
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/stretchr/testify/suite"
|
||||
"go.yaml.in/yaml/v4"
|
||||
|
||||
"github.com/authelia/authelia/v4/internal/oidc/conformance"
|
||||
"github.com/authelia/authelia/v4/internal/utils"
|
||||
)
|
||||
|
||||
var (
|
||||
conformanceAcceptedResults = []string{"PASSED", "REVIEW", "SKIPPED"}
|
||||
conformanceAcceptedWarnings = map[string]string{
|
||||
// ValidateIdTokenACRClaimAgainstAcrValuesRequest (OIDCC-3.1.2.1, OIDCC-15.1).
|
||||
"oidcc-ensure-request-with-acr-values-succeeds": "Authelia does not select an acr from acr_values, which is a voluntary request, so the ID Token carries the fallback acr '0' rather than one of the requested values",
|
||||
}
|
||||
)
|
||||
|
||||
func conformanceResultAccepted(module, result string) bool {
|
||||
if utils.IsStringInSlice(result, conformanceAcceptedResults) {
|
||||
return true
|
||||
}
|
||||
|
||||
_, ok := conformanceAcceptedWarnings[module]
|
||||
|
||||
return ok && result == "WARNING"
|
||||
}
|
||||
|
||||
// conformanceOutcomeAccepted reports whether a module passes: the suite drove it without error, it was not interrupted,
|
||||
// and its result is accepted. A module is interrupted when it stops before running to completion, so its result says
|
||||
// nothing about the conformance of what it did not get to check.
|
||||
func conformanceOutcomeAccepted(outcome ConformanceOutcome) bool {
|
||||
return outcome.Err == nil && outcome.Status != conformanceStatusInterrupted && conformanceResultAccepted(outcome.Module, outcome.Result)
|
||||
}
|
||||
|
||||
const (
|
||||
conformanceDiagnosticTimeout = time.Second * 30
|
||||
conformancePlanTimeout = time.Minute * 75
|
||||
conformanceDrainTimeout = time.Second * 45
|
||||
conformanceArchiveDir = "oidc-conformance-plans"
|
||||
)
|
||||
|
||||
// OIDCConformanceSuite runs the OpenID Foundation conformance suite against Authelia, one test per certified profile.
|
||||
type OIDCConformanceSuite struct {
|
||||
*RodSuite
|
||||
|
||||
client *ConformanceClient
|
||||
cancel context.CancelFunc
|
||||
browsers []*ConformanceBrowser
|
||||
outcomes map[string]<-chan ConformanceOutcome
|
||||
planIDs map[string]string
|
||||
}
|
||||
|
||||
// NewOIDCConformanceSuite returns a new *OIDCConformanceSuite.
|
||||
func NewOIDCConformanceSuite() *OIDCConformanceSuite {
|
||||
return &OIDCConformanceSuite{
|
||||
RodSuite: NewRodSuite(oidcConformanceSuiteName),
|
||||
outcomes: map[string]<-chan ConformanceOutcome{},
|
||||
planIDs: map[string]string{},
|
||||
}
|
||||
}
|
||||
|
||||
// SetupSuite creates every plan and starts a runner per plan.
|
||||
func (s *OIDCConformanceSuite) SetupSuite() {
|
||||
s.BaseSuite.SetupSuite()
|
||||
|
||||
complete := false
|
||||
|
||||
defer func() {
|
||||
if !complete {
|
||||
s.release()
|
||||
}
|
||||
}()
|
||||
|
||||
plans, err := oidcConformanceReadPlans()
|
||||
s.Require().NoError(err)
|
||||
|
||||
s.client, err = NewConformanceClient(oidcConformanceBaseURL)
|
||||
s.Require().NoError(err)
|
||||
|
||||
session, err := NewRodSession()
|
||||
s.Require().NoError(err)
|
||||
|
||||
s.RodSession = session
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), conformancePlanTimeout)
|
||||
|
||||
s.cancel = cancel
|
||||
|
||||
for _, plan := range plans {
|
||||
plan := plan
|
||||
|
||||
created, err := s.client.CreatePlan(ctx, plan.PlanName, plan.Variant, &plan.Plan)
|
||||
s.Require().NoErrorf(err, "error creating the '%s' plan", plan.Name)
|
||||
|
||||
s.planIDs[plan.Name] = created.ID
|
||||
|
||||
browser, err := NewConformanceBrowser(s.RodSession)
|
||||
s.Require().NoError(err)
|
||||
|
||||
s.browsers = append(s.browsers, browser)
|
||||
|
||||
runner := NewConformanceRunner(s.client, browser, created.ID, created.Modules)
|
||||
runner.debug = suiteDebug()
|
||||
|
||||
s.outcomes[plan.Name] = runner.Run(ctx)
|
||||
}
|
||||
|
||||
complete = true
|
||||
}
|
||||
|
||||
func (s *OIDCConformanceSuite) release() {
|
||||
if s.cancel != nil {
|
||||
s.cancel()
|
||||
}
|
||||
|
||||
s.drainRunners()
|
||||
|
||||
for _, browser := range s.browsers {
|
||||
browser.Close()
|
||||
}
|
||||
|
||||
if s.RodSession != nil {
|
||||
if err := s.Stop(); err != nil {
|
||||
s.T().Logf("error stopping rod session: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *OIDCConformanceSuite) drainRunners() {
|
||||
for name, outcomes := range s.outcomes {
|
||||
timer := time.NewTimer(conformanceDrainTimeout)
|
||||
|
||||
for drained := false; !drained; {
|
||||
select {
|
||||
case _, ok := <-outcomes:
|
||||
if !ok {
|
||||
drained = true
|
||||
}
|
||||
case <-timer.C:
|
||||
s.T().Logf("timed out after %s waiting for the '%s' plan's runner to finish", conformanceDrainTimeout, name)
|
||||
|
||||
drained = true
|
||||
}
|
||||
}
|
||||
|
||||
timer.Stop()
|
||||
}
|
||||
}
|
||||
|
||||
// TearDownSuite exports each plan's log for the CI artifacts, then releases the runners, browser contexts and rod
|
||||
// session via release.
|
||||
func (s *OIDCConformanceSuite) TearDownSuite() {
|
||||
emitBuildkiteMarker(buildkiteGroup("OIDC Conformance Finalization: teardown"))
|
||||
|
||||
if s.client != nil {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Minute*2)
|
||||
defer cancel()
|
||||
|
||||
for name, id := range s.planIDs {
|
||||
path := conformanceArchivePath(name)
|
||||
|
||||
if err := s.client.ExportPlanHTML(ctx, id, path); err != nil {
|
||||
s.T().Logf("Error exporting the '%s' plan log: %v", name, err)
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
s.T().Logf("Exported the '%s' plan log to '%s'", name, path)
|
||||
}
|
||||
}
|
||||
|
||||
s.release()
|
||||
}
|
||||
|
||||
func (s *OIDCConformanceSuite) assertPlan(name string) {
|
||||
outcomes, ok := s.outcomes[name]
|
||||
s.Require().Truef(ok, "the '%s' plan was not created", name)
|
||||
|
||||
emitBuildkiteMarker(buildkiteGroup("OIDC Conformance Plan: " + name))
|
||||
|
||||
count, accepted, failed, skipped, expanded := 0, 0, 0, 0, false
|
||||
|
||||
for outcome := range outcomes {
|
||||
outcome := outcome
|
||||
count++
|
||||
|
||||
s.Run(outcome.Name, func() {
|
||||
t := s.T()
|
||||
|
||||
if len(outcome.Trace) != 0 {
|
||||
t.Log("Trace:\n" + strings.Join(outcome.Trace, "\n"))
|
||||
}
|
||||
|
||||
if outcome.Skipped {
|
||||
skipped++
|
||||
|
||||
t.Skipf("%s cannot run unattended: %s", outcome.Module, outcome.Reason)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if !conformanceOutcomeAccepted(outcome) {
|
||||
failed++
|
||||
|
||||
s.reportFailure(t, name, outcome)
|
||||
|
||||
if !expanded {
|
||||
emitBuildkiteMarker(buildkiteExpandGroup())
|
||||
|
||||
expanded = true
|
||||
}
|
||||
}
|
||||
|
||||
require.NoError(t, outcome.Err)
|
||||
require.NotEqualf(t, conformanceStatusInterrupted, outcome.Status,
|
||||
"module '%s' was interrupted with the result '%s' and the status '%s'", outcome.Module, outcome.Result, outcome.Status)
|
||||
require.Truef(t, conformanceResultAccepted(outcome.Module, outcome.Result),
|
||||
"module '%s' finished with the result '%s' and the status '%s'", outcome.Module, outcome.Result, outcome.Status)
|
||||
|
||||
accepted++
|
||||
|
||||
if outcome.Result == "WARNING" {
|
||||
s.reportAcceptedWarning(outcome)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
s.T().Log(conformancePlanSummary(name, accepted, failed, skipped))
|
||||
|
||||
s.Require().NotZerof(count, "the '%s' plan produced no modules", name)
|
||||
}
|
||||
|
||||
func (s *OIDCConformanceSuite) reportAcceptedWarning(outcome ConformanceOutcome) {
|
||||
reason := conformanceAcceptedWarnings[outcome.Module]
|
||||
|
||||
if s.client == nil || outcome.ID == "" {
|
||||
s.T().Logf("Module '%s' finished with the accepted result 'WARNING' and the status '%s': %s.", outcome.Module, outcome.Status, reason)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), conformanceDiagnosticTimeout)
|
||||
defer cancel()
|
||||
|
||||
entries, err := s.client.Log(ctx, outcome.ID)
|
||||
if err != nil {
|
||||
s.T().Logf("Module '%s' finished with the accepted result 'WARNING' and the status '%s': %s. Its log could not be read: %v", outcome.Module, outcome.Status, reason, err)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
s.T().Log(ConformanceAcceptedWarningReport(outcome.Module, outcome.Status, reason, entries))
|
||||
}
|
||||
|
||||
func (s *OIDCConformanceSuite) reportFailure(t *testing.T, plan string, outcome ConformanceOutcome) {
|
||||
t.Logf("Module '%s' of the '%s' plan finished with the result '%s' and the status '%s'.", outcome.Module, plan, outcome.Result, outcome.Status)
|
||||
|
||||
if outcome.Err != nil {
|
||||
t.Logf("Suite error: %v", outcome.Err)
|
||||
}
|
||||
|
||||
if outcome.ID == "" {
|
||||
t.Log("The module was never created, so the conformance server has no log for it.")
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if s.client == nil {
|
||||
return
|
||||
}
|
||||
|
||||
t.Logf("Module id %s, in %s", outcome.ID, conformanceArchiveReference(plan, outcome.LogURL))
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), conformanceDiagnosticTimeout)
|
||||
defer cancel()
|
||||
|
||||
entries, err := s.client.Log(ctx, outcome.ID)
|
||||
if err != nil {
|
||||
t.Logf("The module's log could not be read for this report: %v", err)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
var stall *ConformanceErrorPageStallError
|
||||
|
||||
switch detail := ConformanceDiagnostics(entries); {
|
||||
case detail != "":
|
||||
t.Logf("Conformance log entries that did not pass:%s", detail)
|
||||
case errors.As(outcome.Err, &stall):
|
||||
t.Logf("The module logged no failing entries because it is still waiting for the callback Authelia's error page never sent.")
|
||||
default:
|
||||
t.Logf("The module logged no failing entries, which points at the suite driving it rather than at the provider.")
|
||||
}
|
||||
}
|
||||
|
||||
// TestConfig runs the Config OP certification profile.
|
||||
func (s *OIDCConformanceSuite) TestConfig() {
|
||||
s.assertPlan(conformance.NameConfig)
|
||||
}
|
||||
|
||||
// TestBasic runs the Basic OP certification profile.
|
||||
func (s *OIDCConformanceSuite) TestBasic() {
|
||||
s.assertPlan(conformance.NameBasic)
|
||||
}
|
||||
|
||||
// TestBasicFormPost runs the Basic OP certification profile with the form post response mode.
|
||||
func (s *OIDCConformanceSuite) TestBasicFormPost() {
|
||||
s.assertPlan(conformance.NameBasicFormPost)
|
||||
}
|
||||
|
||||
// TestHybrid runs the Hybrid OP certification profile.
|
||||
func (s *OIDCConformanceSuite) TestHybrid() {
|
||||
s.assertPlan(conformance.NameHybrid)
|
||||
}
|
||||
|
||||
// TestHybridFormPost runs the Hybrid OP certification profile with the form post response mode.
|
||||
func (s *OIDCConformanceSuite) TestHybridFormPost() {
|
||||
s.assertPlan(conformance.NameHybridFormPost)
|
||||
}
|
||||
|
||||
// TestImplicit runs the Implicit OP certification profile.
|
||||
func (s *OIDCConformanceSuite) TestImplicit() {
|
||||
s.assertPlan(conformance.NameImplicit)
|
||||
}
|
||||
|
||||
// TestImplicitFormPost runs the Implicit OP certification profile with the form post response mode.
|
||||
func (s *OIDCConformanceSuite) TestImplicitFormPost() {
|
||||
s.assertPlan(conformance.NameImplicitFormPost)
|
||||
}
|
||||
|
||||
func TestOIDCConformanceSuite(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping suite test in short mode")
|
||||
}
|
||||
|
||||
suite.Run(t, NewOIDCConformanceSuite())
|
||||
}
|
||||
|
||||
func TestOIDCConformanceGenerateRoundTrip(t *testing.T) {
|
||||
t.Setenv("SUITE_TMP_PATH", t.TempDir())
|
||||
|
||||
require.NoError(t, oidcConformanceGenerate())
|
||||
|
||||
plans, err := oidcConformanceReadPlans()
|
||||
require.NoError(t, err)
|
||||
|
||||
expected := map[string]struct {
|
||||
plan string
|
||||
variant bool
|
||||
}{
|
||||
conformance.NameConfig: {"oidcc-config-certification-test-plan", false},
|
||||
conformance.NameBasic: {"oidcc-basic-certification-test-plan", true},
|
||||
conformance.NameBasicFormPost: {"oidcc-formpost-basic-certification-test-plan", true},
|
||||
conformance.NameHybrid: {"oidcc-hybrid-certification-test-plan", true},
|
||||
conformance.NameHybridFormPost: {"oidcc-formpost-hybrid-certification-test-plan", true},
|
||||
conformance.NameImplicit: {"oidcc-implicit-certification-test-plan", true},
|
||||
conformance.NameImplicitFormPost: {"oidcc-formpost-implicit-certification-test-plan", true},
|
||||
}
|
||||
|
||||
require.Len(t, plans, len(expected))
|
||||
|
||||
data, err := os.ReadFile(SuiteTmpPath(oidcConformanceClientsFile))
|
||||
require.NoError(t, err)
|
||||
|
||||
clients := &oidcConformanceClients{}
|
||||
require.NoError(t, yaml.Unmarshal(data, clients))
|
||||
|
||||
ids := map[string]bool{}
|
||||
|
||||
for _, client := range clients.IdentityProviders.OIDC.Clients {
|
||||
ids[client.ID] = true
|
||||
}
|
||||
|
||||
require.NotEmpty(t, ids)
|
||||
|
||||
for _, plan := range plans {
|
||||
want, ok := expected[plan.Name]
|
||||
|
||||
require.Truef(t, ok, "unexpected profile '%s'", plan.Name)
|
||||
|
||||
assert.Equalf(t, want.plan, plan.PlanName, "the '%s' profile lost its conformance plan name", plan.Name)
|
||||
assert.NotEmptyf(t, plan.Plan.Alias, "the '%s' profile lost its alias", plan.Name)
|
||||
assert.NotEmptyf(t, plan.Plan.Server.DiscoveryURL, "the '%s' profile lost its discovery url", plan.Name)
|
||||
|
||||
if want.variant {
|
||||
require.NotNilf(t, plan.Variant, "the '%s' profile lost its variant", plan.Name)
|
||||
assert.Equal(t, "discovery", plan.Variant.ServerMetadata)
|
||||
assert.Equal(t, "static_client", plan.Variant.ClientRegistration)
|
||||
} else {
|
||||
assert.Nilf(t, plan.Variant, "the '%s' profile gained a variant", plan.Name)
|
||||
}
|
||||
|
||||
if plan.Name == conformance.NameConfig {
|
||||
assert.Nil(t, plan.Plan.Client)
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
require.NotNilf(t, plan.Plan.Client, "the '%s' profile lost its client", plan.Name)
|
||||
assert.NotEmpty(t, plan.Plan.Client.Secret)
|
||||
assert.Truef(t, ids[plan.Plan.Client.ID], "the '%s' profile's client '%s' is not in the generated configuration", plan.Name, plan.Plan.Client.ID)
|
||||
require.NotNilf(t, plan.Plan.ClientAlternate, "the '%s' profile lost its alternate client", plan.Name)
|
||||
require.NotNilf(t, plan.Plan.ClientSecretPost, "the '%s' profile lost its secret post client", plan.Name)
|
||||
|
||||
assert.Truef(t, ids[plan.Plan.ClientAlternate.ID], "the '%s' profile's alternate client '%s' is not in the generated configuration", plan.Name, plan.Plan.ClientAlternate.ID)
|
||||
assert.Truef(t, ids[plan.Plan.ClientSecretPost.ID], "the '%s' profile's secret post client '%s' is not in the generated configuration", plan.Name, plan.Plan.ClientSecretPost.ID)
|
||||
}
|
||||
}
|
||||
|
||||
func conformanceArchiveReference(plan, liveURL string) string {
|
||||
if build, job := os.Getenv("BUILDKITE_BUILD_URL"), os.Getenv("BUILDKITE_JOB_ID"); os.Getenv("CI") == t && build != "" && job != "" {
|
||||
return fmt.Sprintf("%s/%s in the job's artifacts: %s/waterfall?jid=%s&tab=artifacts", conformanceArchiveDir, conformanceArchiveName(plan), build, job)
|
||||
}
|
||||
|
||||
return fmt.Sprintf("%s, or the live log while the suite is up: %s", conformanceArchivePath(plan), liveURL)
|
||||
}
|
||||
|
||||
func conformancePlanSummary(plan string, accepted, failed, skipped int) string {
|
||||
return fmt.Sprintf("Plan '%s': %d accepted, %d failed, %d skipped", plan, accepted, failed, skipped)
|
||||
}
|
||||
|
||||
func conformanceArchiveName(plan string) string {
|
||||
return fmt.Sprintf("conformance-%s.zip", plan)
|
||||
}
|
||||
|
||||
func conformanceArchivePath(plan string) string {
|
||||
if os.Getenv("CI") == t {
|
||||
return filepath.Join("../..", conformanceArchiveDir, conformanceArchiveName(plan))
|
||||
}
|
||||
|
||||
return filepath.Join(os.TempDir(), "authelia-suites-"+conformanceArchiveDir, composeProjectName(), conformanceArchiveName(plan))
|
||||
}
|
||||
@@ -72,11 +72,13 @@ func init() {
|
||||
|
||||
GlobalRegistry.Register(oidcTraefikSuiteName, Suite{
|
||||
SetUp: setup,
|
||||
SetUpTimeout: 5 * time.Minute,
|
||||
SetUpTimeout: 2 * time.Minute,
|
||||
OnSetupTimeout: displayAutheliaLogs,
|
||||
OnError: displayAutheliaLogs,
|
||||
TestTimeout: 2 * time.Minute,
|
||||
TearDown: teardown,
|
||||
TearDownTimeout: 2 * time.Minute,
|
||||
TearDownTimeout: 1 * time.Minute,
|
||||
Description: `This suite has been created to test Authelia as an OpenID Connect 1.0 Provider behind the
|
||||
Traefik reverse proxy, with Redis backed sessions.`,
|
||||
})
|
||||
}
|
||||
@@ -42,12 +42,12 @@ func init() {
|
||||
|
||||
GlobalRegistry.Register(oneFactorOnlySuiteName, Suite{
|
||||
SetUp: setup,
|
||||
SetUpTimeout: 5 * time.Minute,
|
||||
SetUpTimeout: 2 * time.Minute,
|
||||
OnSetupTimeout: displayAutheliaLogs,
|
||||
OnError: displayAutheliaLogs,
|
||||
TestTimeout: 1 * time.Minute,
|
||||
TearDown: teardown,
|
||||
TearDownTimeout: 2 * time.Minute,
|
||||
TearDownTimeout: 1 * time.Minute,
|
||||
Description: "This suite has been created to test Authelia in a one-factor only configuration",
|
||||
})
|
||||
}
|
||||
@@ -48,12 +48,12 @@ func init() {
|
||||
|
||||
GlobalRegistry.Register(pamSuiteName, Suite{
|
||||
SetUp: setup,
|
||||
SetUpTimeout: 5 * time.Minute,
|
||||
SetUpTimeout: 2 * time.Minute,
|
||||
OnSetupTimeout: displayAutheliaLogs,
|
||||
OnError: displayAutheliaLogs,
|
||||
TestTimeout: 3 * time.Minute,
|
||||
TearDown: teardown,
|
||||
TearDownTimeout: 2 * time.Minute,
|
||||
TearDownTimeout: 1 * time.Minute,
|
||||
Description: "PAM module integration tests for SSH authentication via pam_authelia",
|
||||
})
|
||||
}
|
||||
@@ -64,11 +64,12 @@ func init() {
|
||||
|
||||
GlobalRegistry.Register(pathPrefixSuiteName, Suite{
|
||||
SetUp: setup,
|
||||
SetUpTimeout: 5 * time.Minute,
|
||||
SetUpTimeout: 2 * time.Minute,
|
||||
OnSetupTimeout: displayAutheliaLogs,
|
||||
OnError: displayAutheliaLogs,
|
||||
TestTimeout: 150 * time.Second,
|
||||
TearDown: teardown,
|
||||
TearDownTimeout: 2 * time.Minute,
|
||||
TearDownTimeout: 1 * time.Minute,
|
||||
Description: "This suite has been created to test Authelia served from a path prefix rather than the root of a domain.",
|
||||
})
|
||||
}
|
||||
@@ -46,10 +46,12 @@ func init() {
|
||||
|
||||
GlobalRegistry.Register(postgresSuiteName, Suite{
|
||||
SetUp: setup,
|
||||
SetUpTimeout: 5 * time.Minute,
|
||||
SetUpTimeout: 2 * time.Minute,
|
||||
OnSetupTimeout: displayAutheliaLogs,
|
||||
OnError: displayAutheliaLogs,
|
||||
TestTimeout: 2 * time.Minute,
|
||||
TearDown: teardown,
|
||||
TearDownTimeout: 2 * time.Minute,
|
||||
TearDownTimeout: 1 * time.Minute,
|
||||
Description: "This suite has been created to test Authelia with PostgreSQL as the storage backend and LDAP as the user backend.",
|
||||
})
|
||||
}
|
||||
@@ -43,12 +43,12 @@ func init() {
|
||||
|
||||
GlobalRegistry.Register(shortTimeoutsSuiteName, Suite{
|
||||
SetUp: setup,
|
||||
SetUpTimeout: 5 * time.Minute,
|
||||
SetUpTimeout: 2 * time.Minute,
|
||||
OnSetupTimeout: displayAutheliaLogs,
|
||||
OnError: displayAutheliaLogs,
|
||||
TestTimeout: 3 * time.Minute,
|
||||
TearDown: teardown,
|
||||
TearDownTimeout: 2 * time.Minute,
|
||||
TearDownTimeout: 1 * time.Minute,
|
||||
Description: `This suite has been created to configure Authelia with short timeouts for sessions expiration
|
||||
in order to test the inactivity feature and the remember me feature.`,
|
||||
})
|
||||
|
||||
@@ -51,12 +51,12 @@ func init() {
|
||||
|
||||
GlobalRegistry.Register(standaloneSuiteName, Suite{
|
||||
SetUp: setup,
|
||||
SetUpTimeout: 5 * time.Minute,
|
||||
OnError: displayAutheliaLogs,
|
||||
SetUpTimeout: 2 * time.Minute,
|
||||
OnSetupTimeout: displayAutheliaLogs,
|
||||
TearDown: teardown,
|
||||
OnError: displayAutheliaLogs,
|
||||
TestTimeout: 5 * time.Minute,
|
||||
TearDownTimeout: 2 * time.Minute,
|
||||
TearDown: teardown,
|
||||
TearDownTimeout: 1 * time.Minute,
|
||||
Description: `This suite is used to test Authelia in a standalone
|
||||
configuration with in-memory sessions and a local sqlite db stored on disk`,
|
||||
})
|
||||
|
||||
@@ -66,11 +66,12 @@ func init() {
|
||||
|
||||
GlobalRegistry.Register(traefik2SuiteName, Suite{
|
||||
SetUp: setup,
|
||||
SetUpTimeout: 5 * time.Minute,
|
||||
SetUpTimeout: 2 * time.Minute,
|
||||
OnSetupTimeout: displayAutheliaLogs,
|
||||
OnError: displayAutheliaLogs,
|
||||
TestTimeout: 2 * time.Minute,
|
||||
TearDown: teardown,
|
||||
TearDownTimeout: 2 * time.Minute,
|
||||
TearDownTimeout: 1 * time.Minute,
|
||||
Description: "This suite has been created to test Authelia as an authorization server behind version 2 of the Traefik reverse proxy.",
|
||||
})
|
||||
}
|
||||
@@ -66,11 +66,12 @@ func init() {
|
||||
|
||||
GlobalRegistry.Register(traefik3SuiteName, Suite{
|
||||
SetUp: setup,
|
||||
SetUpTimeout: 5 * time.Minute,
|
||||
SetUpTimeout: 2 * time.Minute,
|
||||
OnSetupTimeout: displayAutheliaLogs,
|
||||
OnError: displayAutheliaLogs,
|
||||
TestTimeout: 2 * time.Minute,
|
||||
TearDown: teardown,
|
||||
TearDownTimeout: 2 * time.Minute,
|
||||
TearDownTimeout: 1 * time.Minute,
|
||||
Description: "This suite has been created to test Authelia as an authorization server behind version 3 of the Traefik reverse proxy.",
|
||||
})
|
||||
}
|
||||
@@ -52,12 +52,12 @@ func init() {
|
||||
|
||||
GlobalRegistry.Register(twoFactorSuiteName, Suite{
|
||||
SetUp: setup,
|
||||
SetUpTimeout: 5 * time.Minute,
|
||||
OnError: displayAutheliaLogs,
|
||||
SetUpTimeout: 2 * time.Minute,
|
||||
OnSetupTimeout: displayAutheliaLogs,
|
||||
TearDown: teardown,
|
||||
OnError: displayAutheliaLogs,
|
||||
TestTimeout: 4 * time.Minute,
|
||||
TearDownTimeout: 2 * time.Minute,
|
||||
TearDown: teardown,
|
||||
TearDownTimeout: 1 * time.Minute,
|
||||
Description: `This suite is used to test Authelia in a two factor
|
||||
configuration with in-memory sessions and a local sqlite db stored on disk`,
|
||||
})
|
||||
|
||||
+47
-55
@@ -22,7 +22,6 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/go-rod/rod"
|
||||
"github.com/go-rod/rod/lib/input"
|
||||
"github.com/google/uuid"
|
||||
log "github.com/sirupsen/logrus"
|
||||
"github.com/stretchr/testify/assert"
|
||||
@@ -95,21 +94,10 @@ const diagnosticsConsole = `() => JSON.stringify({
|
||||
entries: window.__diagnostics__ || [],
|
||||
}, null, 2)`
|
||||
|
||||
const coverageDir = "../../web/.nyc_output"
|
||||
|
||||
var errPageNotCreated = errors.New("the page was not created")
|
||||
|
||||
// StringToKeys returns the input.Key values which represent the given string.
|
||||
func StringToKeys(value string) []input.Key {
|
||||
n := len(value)
|
||||
|
||||
keys := make([]input.Key, n)
|
||||
|
||||
for i := 0; i < n; i++ {
|
||||
keys[i] = input.Key(value[i])
|
||||
}
|
||||
|
||||
return keys
|
||||
}
|
||||
|
||||
// ValidateBrowserPath validates the appropriate chromium browser path.
|
||||
func ValidateBrowserPath(path string) (browserPath string, err error) {
|
||||
var info os.FileInfo
|
||||
@@ -161,8 +149,6 @@ func (rs *RodSession) collectCoverage(page *rod.Page) {
|
||||
return
|
||||
}
|
||||
|
||||
coverageDir := "../../web/.nyc_output"
|
||||
|
||||
resp, err := page.Eval("() => JSON.stringify(window.__coverage__)")
|
||||
if err != nil {
|
||||
log.Errorf("Error collecting coverage: %v", err)
|
||||
@@ -175,15 +161,54 @@ func (rs *RodSession) collectCoverage(page *rod.Page) {
|
||||
_ = os.MkdirAll(coverageDir, 0775)
|
||||
|
||||
if coverageData != "<nil>" {
|
||||
if err = os.WriteFile(fmt.Sprintf("%s/coverage-%s.json", coverageDir, uuid.New().String()), []byte(coverageData), 0664); err != nil { //nolint:gosec
|
||||
if err = writeCoverage(coverageDir, coverageData); err != nil {
|
||||
log.Errorf("Error writing coverage: %v", err)
|
||||
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if err = filepath.Walk("../../web/.nyc_output", fixCoveragePath); err != nil {
|
||||
log.Errorf("Error rewriting coverage paths: %v", err)
|
||||
}
|
||||
func writeCoverage(dir, data string) (err error) {
|
||||
if err = os.MkdirAll(dir, 0775); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
wd, err := os.Getwd()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
data = strings.ReplaceAll(data, "/node/src/app/", strings.TrimSuffix(wd, "internal/suites")+"web/")
|
||||
|
||||
return os.WriteFile(filepath.Join(dir, fmt.Sprintf("coverage-%s.json", uuid.New().String())), []byte(data), 0664) //nolint:gosec
|
||||
}
|
||||
|
||||
func suiteDebug() bool {
|
||||
return os.Getenv("SUITE_DEBUG") == t
|
||||
}
|
||||
|
||||
func buildkiteGroups() bool {
|
||||
return os.Getenv("BUILDKITE") == t && suiteDebug()
|
||||
}
|
||||
|
||||
func buildkiteGroup(title string) string {
|
||||
if !buildkiteGroups() {
|
||||
return ""
|
||||
}
|
||||
|
||||
return "--- " + title
|
||||
}
|
||||
|
||||
func buildkiteExpandGroup() string {
|
||||
if !buildkiteGroups() {
|
||||
return ""
|
||||
}
|
||||
|
||||
return "^^^ +++"
|
||||
}
|
||||
|
||||
func emitBuildkiteMarker(marker string) {
|
||||
if marker != "" {
|
||||
fmt.Println(marker) //nolint:forbidigo
|
||||
}
|
||||
}
|
||||
|
||||
@@ -559,39 +584,6 @@ func (s *RodSuite) VerifyPageElementAttributeValue(t *testing.T, page *rod.Page,
|
||||
assert.Equal(t, value, *attr)
|
||||
}
|
||||
|
||||
func fixCoveragePath(path string, file os.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if file.IsDir() {
|
||||
return nil
|
||||
}
|
||||
|
||||
coverage, err := filepath.Match("*.json", file.Name())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if coverage {
|
||||
var data []byte
|
||||
|
||||
if data, err = os.ReadFile(path); err != nil { //nolint:gosec
|
||||
return err
|
||||
}
|
||||
|
||||
wd, _ := os.Getwd()
|
||||
ciPath := strings.TrimSuffix(wd, "internal/suites")
|
||||
content := strings.ReplaceAll(string(data), "/node/src/app/", ciPath+"web/")
|
||||
|
||||
if err = os.WriteFile(path, []byte(content), 0); err != nil { //nolint:gosec
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func getDomainEnvInfo(domain string) (info map[string]string, err error) {
|
||||
info = make(map[string]string)
|
||||
|
||||
|
||||
Reference in new issue
Block a user