Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
9db8fdf
Add OIDC conformance workflow
H2CK Jun 5, 2026
ccfd803
Fix conformance test
H2CK Jun 8, 2026
3d16d6d
Update pipeline concerning reachability
H2CK Jun 8, 2026
5d3b12f
Switch to chrome headless
H2CK Jun 8, 2026
28577d1
Fix configuration
H2CK Jun 8, 2026
8e3e5d7
Enable Chrome headless
H2CK Jun 8, 2026
93ba442
Changes approach to switch to headless chrome
Jun 9, 2026
9fb6736
Add debug output
Jun 9, 2026
26f93ac
Fixed browser runner
Jun 9, 2026
745f5c8
Another fix
Jun 9, 2026
3dacc37
Update route
Jun 9, 2026
84fc0d0
Update routes
Jun 10, 2026
5030117
Fix browser-runner
Jun 10, 2026
baeb7fc
Fixes for workflow
Jun 10, 2026
8465b1d
Fix missing response type
Jun 10, 2026
8931b3f
Fix oidcc-refresh-token compliance
Jun 10, 2026
82e2cf6
Fix oidcc-scope-profile conformance error
Jun 10, 2026
c633157
Fix oidcc-unsigned-request-object-supported-correctly-or-rejected-as-…
Jun 10, 2026
e64b648
Fix oidcc-unsigned-request-object-supported-correctly-or-rejected-as-…
Jun 10, 2026
0848317
Fix for oidcc-prompt-none-not-logged-in
Jun 11, 2026
0493995
Fix conformance test oidcc-max-age-10000
Jun 11, 2026
a332c78
Implemented oidcc-max-age-1 fix
Jun 11, 2026
0882522
oidcc-prompt-login compliance fix
Jun 11, 2026
31d114b
Fixed oidcc-ensure-registered-redirect-uri and oidcc-ensure-request-…
Jun 11, 2026
31641cd
Fix oidcc-max-age-1 / oidcc-prompt-login interruption
Jun 11, 2026
3ac4029
Fixed browser runner
Jun 11, 2026
0e31d55
Change failure behaviour
Jun 11, 2026
0900922
Moved oidc conformance to separate workflow
Jun 11, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
471 changes: 471 additions & 0 deletions .github/conformance/browser-runner.py

Large diffs are not rendered by default.

110 changes: 110 additions & 0 deletions .github/conformance/check-results.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
#!/usr/bin/env python3
"""Exit non-zero only when exported conformance results contain blocking failures."""

from __future__ import annotations

import argparse
import json
import pathlib
import sys
import zipfile
from collections import Counter


BLOCKING_RESULTS = {"FAILED", "FAILURE", "ERROR", "INTERRUPTED", "UNKNOWN"}
NON_BLOCKING_RESULTS = {"PASSED", "SUCCESS", "SKIPPED", "WARNING", "REVIEW"}
FINISHED_STATUSES = {"FINISHED"}


def iter_export_logs(results_dir: pathlib.Path):
for zip_path in sorted(results_dir.glob("*.zip")):
with zipfile.ZipFile(zip_path) as export:
for name in sorted(export.namelist()):
if not name.endswith(".json"):
continue
with export.open(name) as handle:
yield zip_path.name, name, json.load(handle)

for json_path in sorted(results_dir.glob("test-log-*.json")):
with json_path.open(encoding="utf-8") as handle:
yield json_path.name, json_path.name, json.load(handle)


def normalized_result(test_info: dict) -> str:
result = test_info.get("result")
if result:
return str(result).upper()

status = test_info.get("status")
if status and status != "FINISHED":
return str(status).upper()

return "UNKNOWN"


def is_blocking(test_info: dict) -> bool:
result = normalized_result(test_info)
status = str(test_info.get("status") or "").upper()

if result in BLOCKING_RESULTS:
return True

if result not in NON_BLOCKING_RESULTS:
return True

return bool(status and status not in FINISHED_STATUSES and result != "SKIPPED")


def test_label(test_info: dict, log_name: str) -> str:
name = test_info.get("testName") or pathlib.Path(log_name).stem
test_id = test_info.get("testId") or test_info.get("_id")
if test_id:
return f"{name} ({test_id})"
return str(name)


def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--results-dir", default="conformance-results", type=pathlib.Path)
args = parser.parse_args()

tests = []
blocking = []
counts = Counter()

for export_name, log_name, data in iter_export_logs(args.results_dir):
test_info = data.get("testInfo", {})
result = normalized_result(test_info)
counts[result] += 1
item = {
"export": export_name,
"label": test_label(test_info, log_name),
"result": result,
"status": test_info.get("status") or "",
}
tests.append(item)
if is_blocking(test_info):
blocking.append(item)

if not tests:
print(f"No conformance test logs found in {args.results_dir}", file=sys.stderr)
return 1

summary = ", ".join(f"{result}={count}" for result, count in sorted(counts.items()))
print(f"Conformance result summary: {summary}")

if not blocking:
print("No blocking conformance failures found.")
return 0

print("Blocking conformance failures found:", file=sys.stderr)
for item in blocking:
print(
f"- {item['label']}: result={item['result']} status={item['status']} export={item['export']}",
file=sys.stderr,
)
return 1


if __name__ == "__main__":
sys.exit(main())
19 changes: 19 additions & 0 deletions .github/conformance/docker-compose.chrome.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
services:
# Selenium Chrome service for conformance testing
# Provides WebDriver endpoint at http://chrome:4444/wd/hub
chrome:
image: selenium/standalone-chromium:latest
container_name: conformance-chrome
shm_size: 2gb
ports:
- "4444:4444"
- "7900:7900" # VNC port for debugging
environment:
- SE_NODE_MAX_SESSIONS=3
- SE_SESSION_REQUEST_TIMEOUT=60
- SE_SESSION_RETRY_INTERVAL=2
- SE_BROWSER_ARGS_HEADLESS=--headless=new
- SE_BROWSER_ARGS_NO_SANDBOX=--no-sandbox
- SE_BROWSER_ARGS_DISABLE_DEV_SHM_USAGE=--disable-dev-shm-usage
extra_hosts:
- "host.docker.internal:host-gateway"
12 changes: 12 additions & 0 deletions .github/conformance/docker-compose.github-actions.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
services:
nginx:
extra_hosts:
- "host.docker.internal:host-gateway"

server:
extra_hosts:
- "host.docker.internal:host-gateway"

test:
extra_hosts:
- "host.docker.internal:host-gateway"
193 changes: 193 additions & 0 deletions .github/conformance/generate-report.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,193 @@
#!/usr/bin/env python3
"""Create a Markdown summary from OpenID conformance-suite exports."""

from __future__ import annotations

import argparse
import datetime as dt
import json
import pathlib
import re
import zipfile
from collections import Counter


ISSUE_RESULTS = {"FAILURE", "ERROR", "WARNING"}
RESULT_ORDER = {
"FAILED": 0,
"FAILURE": 0,
"ERROR": 1,
"INTERRUPTED": 2,
"WARNING": 3,
"PASSED": 4,
"SUCCESS": 4,
"SKIPPED": 5,
"UNKNOWN": 6,
}


def compact(value: object) -> str:
return re.sub(r"\s+", " ", str(value or "")).strip()


def table_cell(value: object) -> str:
text = compact(value)
return text.replace("|", "\\|")


def truncate(value: str, max_length: int) -> str:
if len(value) <= max_length:
return value
return value[: max_length - 3].rstrip() + "..."


def iter_export_logs(results_dir: pathlib.Path):
for zip_path in sorted(results_dir.glob("*.zip")):
with zipfile.ZipFile(zip_path) as export:
for name in sorted(export.namelist()):
if not name.endswith(".json"):
continue
with export.open(name) as handle:
yield zip_path.name, name, json.load(handle)

for json_path in sorted(results_dir.glob("test-log-*.json")):
with json_path.open(encoding="utf-8") as handle:
yield json_path.name, json_path.name, json.load(handle)


def normalized_result(test_info: dict) -> str:
result = test_info.get("result")
if result:
return str(result)
status = test_info.get("status")
if status and status != "FINISHED":
return str(status)
return "UNKNOWN"


def issue_summary(results: list[dict], max_issues: int) -> str:
issues = [
f"{item.get('result')}: {compact(item.get('msg'))}"
for item in results
if item.get("result") in ISSUE_RESULTS and item.get("msg")
]
if not issues:
return ""
extra = len(issues) - max_issues
selected = issues[:max_issues]
if extra > 0:
selected.append(f"+ {extra} more")
return "; ".join(selected)


def collect_tests(results_dir: pathlib.Path, max_issues: int) -> list[dict]:
tests = []
for export_name, log_name, data in iter_export_logs(results_dir):
test_info = data.get("testInfo", {})
test_results = data.get("results", [])
result = normalized_result(test_info)
tests.append(
{
"name": test_info.get("testName", pathlib.Path(log_name).stem),
"id": test_info.get("testId", test_info.get("_id", "")),
"status": test_info.get("status", ""),
"result": result,
"summary": compact(test_info.get("summary")),
"issues": issue_summary(test_results, max_issues),
"started": test_info.get("started", ""),
"export": export_name,
}
)
return sorted(
tests,
key=lambda item: (
RESULT_ORDER.get(item["result"], RESULT_ORDER["UNKNOWN"]),
item["name"],
item["id"],
),
)


def write_report(tests: list[dict], output: pathlib.Path, results_dir: pathlib.Path) -> None:
now = dt.datetime.now(dt.UTC).replace(microsecond=0).isoformat()
counts = Counter(test["result"] for test in tests)

lines = [
"# OIDC Conformance Report",
"",
f"Generated: {now}",
f"Results source: `{results_dir}`",
f"Executed tests: {len(tests)}",
"",
"## Result Summary",
"",
"| Result | Count |",
"| --- | ---: |",
]

if counts:
for result, count in sorted(counts.items(), key=lambda item: RESULT_ORDER.get(item[0], RESULT_ORDER["UNKNOWN"])):
lines.append(f"| {table_cell(result)} | {count} |")
else:
lines.append("| UNKNOWN | 0 |")

lines.extend(
[
"",
"## Executed Tests",
"",
"| Result | Status | Test | Description | Issues |",
"| --- | --- | --- | --- | --- |",
]
)

if tests:
for test in tests:
test_name = test["name"]
if test["id"]:
test_name = f"{test_name} ({test['id']})"
lines.append(
"| "
+ " | ".join(
[
table_cell(test["result"]),
table_cell(test["status"]),
table_cell(test_name),
table_cell(truncate(test["summary"], 260)),
table_cell(truncate(test["issues"], 320)),
]
)
+ " |"
)
else:
lines.append("| UNKNOWN | UNKNOWN | No conformance test logs found | | |")

lines.extend(
[
"",
"## Notes",
"",
"- `WARNING` is reported by the conformance suite for behavior that may still complete the test module.",
"- `INTERRUPTED` means the test module did not reach a final pass/fail result in the exported run.",
"- Full logs and signed test exports are available in the workflow artifact.",
"",
]
)

output.parent.mkdir(parents=True, exist_ok=True)
output.write_text("\n".join(lines), encoding="utf-8")


def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--results-dir", default="conformance-results", type=pathlib.Path)
parser.add_argument("--output", default="CONFORMANCE.md", type=pathlib.Path)
parser.add_argument("--max-issues", default=3, type=int)
args = parser.parse_args()

tests = collect_tests(args.results_dir, args.max_issues)
write_report(tests, args.output, args.results_dir)


if __name__ == "__main__":
main()
20 changes: 20 additions & 0 deletions .github/conformance/oidc-basic-config.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
{
"alias": "${CONFORMANCE_ALIAS}",
"description": "Nextcloud OIDC GitHub Actions basic conformance",
"publish": "no",
"server": {
"discoveryUrl": "${NEXTCLOUD_BASE_URL}/index.php/apps/oidc/openid-configuration"
},
"client": {
"client_id": "${OIDC_CLIENT_ID_1}",
"client_secret": "${OIDC_CLIENT_SECRET_1}"
},
"client_secret_post": {
"client_id": "${OIDC_CLIENT_ID_1}",
"client_secret": "${OIDC_CLIENT_SECRET_1}"
},
"client2": {
"client_id": "${OIDC_CLIENT_ID_2}",
"client_secret": "${OIDC_CLIENT_SECRET_2}"
}
}
25 changes: 25 additions & 0 deletions .github/conformance/write-config.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
#!/usr/bin/env python3

import os
import string
import sys


def main() -> int:
if len(sys.argv) != 3:
print("Usage: write-config.py TEMPLATE OUTPUT", file=sys.stderr)
return 2

with open(sys.argv[1], encoding="utf-8") as template_file:
template = string.Template(template_file.read())

rendered = template.safe_substitute(os.environ)

with open(sys.argv[2], "w", encoding="utf-8") as output_file:
output_file.write(rendered)

return 0


if __name__ == "__main__":
raise SystemExit(main())
2 changes: 0 additions & 2 deletions .github/workflows/build-test.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,6 @@ on:
branches:
- "*"
workflow_dispatch:
branches:
- "*"

env:
PHP_VERSION: 8.5
Expand Down
Loading
Loading