diff --git a/.github/ISSUE_TEMPLATE/template-submission.yml b/.github/ISSUE_TEMPLATE/template-submission.yml new file mode 100644 index 000000000..a8b9f226b --- /dev/null +++ b/.github/ISSUE_TEMPLATE/template-submission.yml @@ -0,0 +1,116 @@ +name: "Submit an azd Template" +description: "Add your azd template to the Awesome azd gallery" +title: "[Template]: " +labels: ["template-submission"] +body: + - type: markdown + attributes: + value: | + Thanks for submitting your azd template! Just provide the repository URL below. + Most fields are **auto-detected** from your repo — only fill them in if you want to override the detected values. + + - type: input + id: source-repo + attributes: + label: "Source Repository" + description: "GitHub repository URL for your template (we'll auto-detect the rest!)" + placeholder: "https://github.com/org/repo" + validations: + required: true + + - type: input + id: template-title + attributes: + label: "Template Title (optional — auto-detected from repo)" + description: "Override the auto-detected title" + validations: + required: false + + - type: textarea + id: description + attributes: + label: "Description (optional — auto-detected from repo)" + description: "Override the auto-detected description" + validations: + required: false + + - type: input + id: author + attributes: + label: "Author (optional — auto-detected from repo owner)" + description: "Override the auto-detected author name" + validations: + required: false + + - type: input + id: author-url + attributes: + label: "Author URL (optional — auto-detected from repo owner)" + description: "Override the auto-detected author URL" + validations: + required: false + + - type: dropdown + id: author-type + attributes: + label: "Author Type (optional — auto-detected)" + description: "Override the auto-detected author type" + options: + - "" + - Community + - Microsoft + validations: + required: false + + - type: input + id: preview-image + attributes: + label: "Preview Image URL (optional)" + description: "URL to an architecture diagram or screenshot" + validations: + required: false + + - type: dropdown + id: iac-provider + attributes: + label: "IaC Provider (optional — auto-detected from infra/ directory)" + description: "Override the auto-detected IaC provider" + options: + - "" + - Bicep + - Terraform + - Both + validations: + required: false + + - type: input + id: languages + attributes: + label: "Languages (optional — auto-detected from repo)" + description: "Comma-separated language tags (e.g., python, javascript)" + validations: + required: false + + - type: input + id: frameworks + attributes: + label: "Frameworks (optional)" + description: "Comma-separated framework tags (e.g., reactjs, fastapi)" + validations: + required: false + + - type: input + id: azure-services + attributes: + label: "Azure Services (optional — auto-detected from repo topics)" + description: "Comma-separated Azure service tags (e.g., aca, openai)" + validations: + required: false + + - type: textarea + id: additional-info + attributes: + label: "Additional Information" + description: "Any additional context about your template (optional)" + validations: + required: false diff --git a/.github/workflows/template-submission.yml b/.github/workflows/template-submission.yml new file mode 100644 index 000000000..5db09fa63 --- /dev/null +++ b/.github/workflows/template-submission.yml @@ -0,0 +1,214 @@ +name: Template Submission + +on: + issues: + types: [opened, labeled] + workflow_dispatch: + inputs: + source_repo: + description: "GitHub repository URL" + required: true + template_title: + description: "Template title (optional - auto-detected)" + required: false + description: + description: "Description (optional - auto-detected)" + required: false + author: + description: "Author name (optional - auto-detected)" + required: false + author_url: + description: "Author GitHub URL (optional - auto-detected)" + required: false + author_type: + description: "Microsoft or Community (optional - auto-detected)" + required: false + default: "" + type: choice + options: + - "" + - Community + - Microsoft + preview_image: + description: "Preview image URL (optional)" + required: false + iac_provider: + description: "IaC provider (optional - auto-detected)" + required: false + default: "" + type: choice + options: + - "" + - Bicep + - Terraform + - Both + languages: + description: "Comma-separated language tags (optional - auto-detected)" + required: false + frameworks: + description: "Comma-separated framework tags (optional)" + required: false + azure_services: + description: "Comma-separated Azure service tags (optional - auto-detected)" + required: false + +permissions: + contents: write + pull-requests: write + issues: write + +concurrency: + group: template-submission + cancel-in-progress: false + +jobs: + process-template: + if: >- + github.event_name == 'workflow_dispatch' || + contains(github.event.issue.labels.*.name, 'template-submission') + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@eca02f6b962f33b848cbe9e9f4dd5d23a0b61747 # v6 + + - name: Setup Node.js + uses: actions/setup-node@53b83942660f83e34d4f35ba9a3125d67d943f4c # v6 + with: + node-version: "24" + + - name: Parse issue body + id: parse + env: + GITHUB_EVENT_NAME: ${{ github.event_name }} + ISSUE_BODY: ${{ github.event.issue.body || '' }} + INPUT_SOURCE_REPO: ${{ inputs.source_repo }} + INPUT_TEMPLATE_TITLE: ${{ inputs.template_title }} + INPUT_DESCRIPTION: ${{ inputs.description }} + INPUT_AUTHOR: ${{ inputs.author }} + INPUT_AUTHOR_URL: ${{ inputs.author_url }} + INPUT_AUTHOR_TYPE: ${{ inputs.author_type }} + INPUT_PREVIEW_IMAGE: ${{ inputs.preview_image }} + INPUT_IAC_PROVIDER: ${{ inputs.iac_provider }} + INPUT_LANGUAGES: ${{ inputs.languages }} + INPUT_FRAMEWORKS: ${{ inputs.frameworks }} + INPUT_AZURE_SERVICES: ${{ inputs.azure_services }} + run: node website/scripts/parse-template-issue.js + + - name: Install extraction dependencies + run: cd website && npm ci --ignore-scripts + + - name: Auto-extract metadata from repository + id: extract + env: + SOURCE_REPO: ${{ steps.parse.outputs.source_repo }} + FORM_TITLE: ${{ steps.parse.outputs.template_title }} + FORM_DESCRIPTION: ${{ steps.parse.outputs.description }} + FORM_AUTHOR: ${{ steps.parse.outputs.author }} + FORM_AUTHOR_URL: ${{ steps.parse.outputs.author_url }} + FORM_AUTHOR_TYPE: ${{ steps.parse.outputs.author_type }} + FORM_IAC_PROVIDER: ${{ steps.parse.outputs.iac_provider }} + FORM_LANGUAGES: ${{ steps.parse.outputs.languages }} + FORM_FRAMEWORKS: ${{ steps.parse.outputs.frameworks }} + FORM_AZURE_SERVICES: ${{ steps.parse.outputs.azure_services }} + FORM_PREVIEW_IMAGE: ${{ steps.parse.outputs.preview_image }} + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: node website/scripts/merge-extracted-metadata.js + + - name: Validate template repository + id: validate + env: + SOURCE_REPO: ${{ steps.extract.outputs.source_repo }} + run: | + node website/scripts/validate-template.js "$SOURCE_REPO" > validation_result.json 2>validation_errors.log + if [ $? -ne 0 ]; then + echo "valid=false" >> $GITHUB_OUTPUT + { + echo 'errors<> $GITHUB_OUTPUT + else + echo "valid=true" >> $GITHUB_OUTPUT + fi + + - name: Comment on validation failure + if: steps.validate.outputs.valid == 'false' && github.event_name != 'workflow_dispatch' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + ERRORS: ${{ steps.validate.outputs.errors }} + ISSUE_NUMBER: ${{ github.event.issue.number }} + run: | + gh issue comment "$ISSUE_NUMBER" \ + --repo "$GITHUB_REPOSITORY" \ + --body "❌ **Template validation failed** + + Please check your repository URL and try again. + + \`\`\` + $ERRORS + \`\`\`" + + - name: Update templates.json + id: update + if: steps.validate.outputs.valid == 'true' + env: + TPL_SOURCE_REPO: ${{ steps.extract.outputs.source_repo }} + TPL_TITLE: ${{ steps.extract.outputs.template_title }} + TPL_DESCRIPTION: ${{ steps.extract.outputs.description }} + TPL_AUTHOR: ${{ steps.extract.outputs.author }} + TPL_AUTHOR_URL: ${{ steps.extract.outputs.author_url }} + TPL_AUTHOR_TYPE: ${{ steps.extract.outputs.author_type }} + TPL_PREVIEW_IMAGE: ${{ steps.extract.outputs.preview_image }} + TPL_IAC_PROVIDER: ${{ steps.extract.outputs.iac_provider }} + TPL_LANGUAGES: ${{ steps.extract.outputs.languages }} + TPL_FRAMEWORKS: ${{ steps.extract.outputs.frameworks }} + TPL_AZURE_SERVICES: ${{ steps.extract.outputs.azure_services }} + run: node website/scripts/update-templates-json.js + + - name: Create Pull Request + id: create-pr + if: steps.validate.outputs.valid == 'true' && steps.update.outputs.skipped != 'true' + uses: peter-evans/create-pull-request@e0fcfd740c5df573cb2b99ba5cf7bd41528dd2b3 # v8 + with: + token: ${{ secrets.GITHUB_TOKEN }} + commit-message: "Add template from ${{ github.event_name == 'workflow_dispatch' && 'manual dispatch' || format('issue #{0}', github.event.issue.number) }}" + branch: "template-submission-${{ github.event_name == 'workflow_dispatch' && github.run_id || github.event.issue.number }}" + title: "Add template: ${{ steps.extract.outputs.template_title }}" + body: | + This PR was automatically generated${{ github.event_name != 'workflow_dispatch' && format(' from issue #{0}', github.event.issue.number) || '' }}. + + **Source Repository**: ${{ steps.extract.outputs.source_repo }} + **Title**: ${{ steps.extract.outputs.template_title }} + **Author**: ${{ steps.extract.outputs.author }} + **IaC**: ${{ steps.extract.outputs.iac_provider }} + **Added**: ${{ steps.update.outputs.added || 'None' }} + + Please review the changes to `website/static/templates.json`. + labels: template-submission + + - name: Comment on success + if: steps.validate.outputs.valid == 'true' && steps.update.outputs.skipped != 'true' && github.event_name != 'workflow_dispatch' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + ISSUE_NUMBER: ${{ github.event.issue.number }} + run: | + gh issue comment "$ISSUE_NUMBER" \ + --repo "$GITHUB_REPOSITORY" \ + --body "✅ **Template validated successfully!** + + A pull request has been created to add your template to the gallery. It will be reviewed by the maintainers shortly." + + - name: Comment on duplicate + if: steps.update.outputs.skipped == 'true' && github.event_name != 'workflow_dispatch' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + SKIP_REASON: ${{ steps.update.outputs.skip_reason }} + ISSUE_NUMBER: ${{ github.event.issue.number }} + run: | + gh issue comment "$ISSUE_NUMBER" \ + --repo "$GITHUB_REPOSITORY" \ + --body "⚠️ **Template submission skipped** + + $SKIP_REASON + + If you believe this is an error, please comment on this issue." diff --git a/website/docs/contribute.md b/website/docs/contribute.md index 03ca9a155..4a16e14ce 100644 --- a/website/docs/contribute.md +++ b/website/docs/contribute.md @@ -36,6 +36,14 @@ If you would like to contribute a template but are not sure where to start, [mak 2. Add Bicep files 3. Update azure.yaml +### Automated Template Submission + +For a faster submission experience, use the automated pipeline: + +1. **[Submit a template issue](https://github.com/Azure/awesome-azd/issues/new?template=template-submission.yml)** — Fill out the form with your template details +2. **Automatic validation** — A workflow validates your repository and creates a PR automatically +3. **Review & merge** — A maintainer reviews and approves the PR + ### [Submit a Resource](https://github.com/Azure/awesome-azd/compare) Did you write or find an article that helped you get started with `azd`? Or maybe you created or found a video that showed you how to create an azd template? Whatever the resource might be, we would love for you to share it with our community! Submit content you think should be included in `awesome-azd/README.md` diff --git a/website/jest.config.js b/website/jest.config.js index 253caca35..3c1ecf3b3 100644 --- a/website/jest.config.js +++ b/website/jest.config.js @@ -1,3 +1,6 @@ module.exports = { - testMatch: ['/test/**/*.test.ts'] + testMatch: ['/test/**/*.test.ts'], + transformIgnorePatterns: [ + 'node_modules/(?!@babel/runtime/helpers/esm)' + ] }; diff --git a/website/package-lock.json b/website/package-lock.json index a93dde76b..31e9c4d3e 100644 --- a/website/package-lock.json +++ b/website/package-lock.json @@ -30,6 +30,7 @@ "babel-jest": "^29.7.0", "clsx": "^2.0.0", "jest": "^29.7.0", + "js-yaml": "^4.1.1", "prism-react-renderer": "^2.3.0", "react": "^18.2.0", "react-dom": "^18.2.0", @@ -263,6 +264,7 @@ "integrity": "sha512-/FRKUM1G4xn3vV8+9xH1WJ9XknU8rkBGlefruq9jDhYUAvYozKimhrmC2pRqw/RyHhPivmgZCRuC8jHP8piz4Q==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@algolia/client-common": "5.44.0", "@algolia/requester-browser-xhr": "5.44.0", @@ -412,6 +414,7 @@ "integrity": "sha512-lWBYIrF7qK5+GjY5Uy+/hEgp8OJWOD/rpy74GplYRhEauvbHDeFB8t5hPOZxCZ0Oxf4Cc36tK51/l3ymJysrKw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@ampproject/remapping": "^2.2.0", "@babel/code-frame": "^7.26.2", @@ -2448,6 +2451,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=18" }, @@ -2471,6 +2475,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=18" } @@ -2585,6 +2590,7 @@ "integrity": "sha512-8sLjZwK0R+JlxlYcTuVnyT2v+htpdrjDOKuMcOVdYjt52Lh8hWRYpxBPoKx/Zg+bcjc3wx6fmQevMmUztS/ccA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" @@ -3022,6 +3028,7 @@ "integrity": "sha512-8sLjZwK0R+JlxlYcTuVnyT2v+htpdrjDOKuMcOVdYjt52Lh8hWRYpxBPoKx/Zg+bcjc3wx6fmQevMmUztS/ccA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" @@ -3998,6 +4005,7 @@ "integrity": "sha512-C5wZsGuKTY8jEYsqdxhhFOe1ZDjH0uIYJ9T/jebHwkyxqnr4wW0jTkB72OMqNjsoQRcb0JN3PcSeTwFlVgzCZg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@docusaurus/core": "3.9.2", "@docusaurus/logger": "3.9.2", @@ -4539,6 +4547,7 @@ "integrity": "sha512-umqzocjDgNRGTuO7Q8CU32dkHkECqI8ZdMZ5Swb6QAM0t5rnlrN3lGo1hdpscRd3WS8T6DKYK4ephgIH9iRh3w==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@floating-ui/core": "^1.6.0", "@floating-ui/utils": "^0.2.9" @@ -6985,6 +6994,7 @@ "integrity": "sha512-QjHtSaoameoalGnKDT3FoIl4+9RwyTmo9ZJGBdLOks/YOiWHoRDI3PUwEzOE7kEmGcV3AFcp9K6dYu9rEuKLAQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/mdx": "^2.0.0" }, @@ -7130,6 +7140,7 @@ "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz", "integrity": "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==", "license": "MIT", + "peer": true, "funding": { "type": "opencollective", "url": "https://opencollective.com/popperjs" @@ -7387,6 +7398,7 @@ "integrity": "sha512-8QqtOQT5ACVlmsvKOJNEaWmRPmcojMOzCz4Hs2BGG/toAp/K38LcsMRyLp349glq5AzJbCEeimEoxaX6v/fLrA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/core": "^7.21.3", "@svgr/babel-preset": "8.1.0", @@ -7868,6 +7880,7 @@ "integrity": "sha512-t4yC+vtgnkYjNSKlFx1jkAhH8LgTo2N/7Qvi83kdEaUtMDiwpbLAktKDaAMlRcJ5eSxZkH74eEGt1ky31d7kfQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/prop-types": "*", "csstype": "^3.0.2" @@ -7879,6 +7892,7 @@ "integrity": "sha512-P4t6saawp+b/dFrUr2cvkVsfvPguwsxtH6dNIYRllMsefqFzkZk5UIjzyDOv5g1dXIPdG4Sp1yCR4Z6RCUsG/Q==", "dev": true, "license": "MIT", + "peer": true, "peerDependencies": { "@types/react": "^18.0.0" } @@ -8274,6 +8288,7 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -8366,6 +8381,7 @@ "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -8414,6 +8430,7 @@ "integrity": "sha512-f8IpsbdQjzTjr/4mJ/jv5UplrtyMnnciGax6/B0OnLCs2/GJTK13O4Y7Ff1AvJVAaztanH+m5nzPoUq6EAy+aA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@algolia/abtesting": "1.10.0", "@algolia/client-abtesting": "5.44.0", @@ -9151,6 +9168,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -10386,6 +10404,7 @@ "integrity": "sha512-8sLjZwK0R+JlxlYcTuVnyT2v+htpdrjDOKuMcOVdYjt52Lh8hWRYpxBPoKx/Zg+bcjc3wx6fmQevMmUztS/ccA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" @@ -11201,7 +11220,8 @@ "resolved": "https://registry.npmjs.org/embla-carousel/-/embla-carousel-8.5.2.tgz", "integrity": "sha512-xQ9oVLrun/eCG/7ru3R+I5bJ7shsD8fFwLEY7yPe27/+fDHCNj0OT5EoG5ZbFyOxOcG6yTwW8oTz/dWyFnyGpg==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/embla-carousel-autoplay": { "version": "8.5.2", @@ -12032,6 +12052,7 @@ "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -13988,6 +14009,7 @@ "integrity": "sha512-NIy3oAFp9shda19hy4HK0HRTWKtPJmGdnvywu01nOqNC2vZg+Z+fvJDxpMQA88eb2I9EcafcdjYgsDthnYTvGw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@jest/core": "^29.7.0", "@jest/types": "^29.6.3", @@ -17763,6 +17785,7 @@ "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -18521,6 +18544,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -19485,6 +19509,7 @@ "integrity": "sha512-8sLjZwK0R+JlxlYcTuVnyT2v+htpdrjDOKuMcOVdYjt52Lh8hWRYpxBPoKx/Zg+bcjc3wx6fmQevMmUztS/ccA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" @@ -20473,6 +20498,7 @@ "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "loose-envify": "^1.1.0" }, @@ -20486,6 +20512,7 @@ "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" @@ -20557,6 +20584,7 @@ "integrity": "sha512-YMMxTUQV/QFSnbgrP3tjDzLHRg7vsbMn8e9HAa8o/1iXoiomo48b7sk/kkmWEuWNDPJVlKSJRB6Y2fHqdJk+SQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/react": "*" }, @@ -20603,6 +20631,7 @@ "integrity": "sha512-Ys9K+ppnJah3QuaRiLxk+jDWOR1MekYQrlytiXxC1RyfbdsZkS5pvKAzCCr031xHixZwpnsYNT5xysdFHQaYsA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/runtime": "^7.12.13", "history": "^4.9.0", @@ -21416,6 +21445,7 @@ "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.20.2.tgz", "integrity": "sha512-2eWfGgAqqWFGqtdMmcL5zCMK1U8KlXv8SQFGglL3CEtd0aDVDWgeF/YoCmvln55m5zSk3J/20hTaSBeSObsQDQ==", "license": "MIT", + "peer": true, "dependencies": { "loose-envify": "^1.1.0", "object-assign": "^4.1.1" @@ -21452,7 +21482,8 @@ "version": "2.17.3", "resolved": "https://registry.npmjs.org/search-insights/-/search-insights-2.17.3.tgz", "integrity": "sha512-RQPdCYTa8A68uM2jwxoY842xDhvx3E5LFL1LxvxCNMev4o5mLuokczhzjAgGwUZBAmOKZknArSxLKmXtIi2AxQ==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/section-matter": { "version": "1.0.0", @@ -22934,7 +22965,8 @@ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "dev": true, - "license": "0BSD" + "license": "0BSD", + "peer": true }, "node_modules/tunnel-agent": { "version": "0.6.0", @@ -23025,6 +23057,7 @@ "integrity": "sha512-aJn6wq13/afZp/jT9QZmwEjDqqvSGp1VT5GVg+f/t6/oVyrgXM6BY1h9BRh/O5p3PlUPAe+WuiEZOmb/49RqoQ==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -23403,6 +23436,7 @@ "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -23678,6 +23712,7 @@ "integrity": "sha512-gX/dMkRQc7QOMzgTe6KsYFM7DxeIONQSui1s0n/0xht36HvrgbxtM1xBlgx596NbpHuQU8P7QpKwrZYwUX48nw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/eslint-scope": "^3.7.7", "@types/estree": "^1.0.8", @@ -24370,6 +24405,7 @@ "integrity": "sha512-JInaHOamG8pt5+Ey8kGmdcAcg3OL9reK8ltczgHTAwNhMys/6ThXHityHxVV2p3fkw/c+MAvBHFVYHFZDmjMCQ==", "dev": true, "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/website/package.json b/website/package.json index e0fb8a616..27a241db5 100644 --- a/website/package.json +++ b/website/package.json @@ -31,6 +31,7 @@ "babel-jest": "^29.7.0", "clsx": "^2.0.0", "jest": "^29.7.0", + "js-yaml": "^4.1.1", "prism-react-renderer": "^2.3.0", "react": "^18.2.0", "react-dom": "^18.2.0", diff --git a/website/scripts/extract-template-metadata.js b/website/scripts/extract-template-metadata.js new file mode 100644 index 000000000..89fa6b1ec --- /dev/null +++ b/website/scripts/extract-template-metadata.js @@ -0,0 +1,785 @@ +#!/usr/bin/env node + +"use strict"; + +const https = require("https"); +const { URL } = require("url"); +const yaml = require("js-yaml"); +const { validateUrl, safeLookup } = require("./validate-template"); +const { sanitize } = require("./update-templates-json"); + +/** + * Map GitHub API language names to gallery tag names. + */ +const LANGUAGE_MAP = { + Python: "python", + JavaScript: "javascript", + TypeScript: "typescript", + Java: "java", + "C#": "csharp", + Go: "go", + Rust: "rust", + PHP: "php", + Ruby: "ruby", + Swift: "swift", + Kotlin: "kotlin", + Dart: "dart", +}; + +/** + * Map GitHub topics to Azure service gallery tags. + */ +const AZURE_SERVICE_MAP = { + "azure-openai": "openai", + "azure-functions": "functions", + "azure-container-apps": "aca", + "azure-app-service": "appservice", + "azure-cosmos-db": "cosmosdb", + "azure-sql": "azuresql", + "azure-storage": "storage", + "azure-key-vault": "keyvault", + "azure-monitor": "monitor", + "azure-cognitive-services": "cognitiveservices", + "azure-search": "aisearch", + "azure-ai-search": "aisearch", +}; + +/** + * GitHub organisations treated as Microsoft-authored. + */ +const MICROSOFT_ORGS = ["azure", "azure-samples", "microsoft"]; + +/** + * Map dependency-file patterns to gallery framework tags. + * + * Each detector lists: + * tag – the gallery tag name (must exist in tags.tsx) + * patterns – substrings to search for (case-insensitive) + * files – which dependency files to search in + */ +const FRAMEWORK_DETECTORS = [ + // Python frameworks (check requirements.txt, pyproject.toml) + { tag: "fastapi", patterns: ["fastapi"], files: ["requirements.txt", "pyproject.toml"] }, + { tag: "flask", patterns: ["flask"], files: ["requirements.txt", "pyproject.toml"] }, + { tag: "django", patterns: ["django"], files: ["requirements.txt", "pyproject.toml"] }, + { tag: "streamlit", patterns: ["streamlit"], files: ["requirements.txt", "pyproject.toml"] }, + { tag: "langchain", patterns: ["langchain"], files: ["requirements.txt", "pyproject.toml"] }, + { tag: "chainlit", patterns: ["chainlit"], files: ["requirements.txt", "pyproject.toml"] }, + { tag: "autogen", patterns: ["autogen", "pyautogen"], files: ["requirements.txt", "pyproject.toml"] }, + + // JavaScript/TypeScript frameworks (check package.json) + { tag: "reactjs", patterns: ['"react"'], files: ["package.json"] }, + { tag: "vuejs", patterns: ['"vue"'], files: ["package.json"] }, + { tag: "angular", patterns: ['"@angular/core"'], files: ["package.json"] }, + { tag: "nextjs", patterns: ['"next"'], files: ["package.json"] }, + { tag: "nestjs", patterns: ['"@nestjs/core"'], files: ["package.json"] }, + + // Java frameworks (check pom.xml, build.gradle) + { tag: "spring", patterns: ["spring-boot", "spring-framework", "org.springframework"], files: ["pom.xml", "build.gradle", "build.gradle.kts"] }, + { tag: "quarkus", patterns: ["quarkus", "io.quarkus"], files: ["pom.xml", "build.gradle", "build.gradle.kts"] }, + { tag: "javaee", patterns: ["jakarta.", "javax.servlet", "jakarta.servlet"], files: ["pom.xml", "build.gradle", "build.gradle.kts"] }, + { tag: "langchain4j", patterns: ["langchain4j"], files: ["pom.xml", "build.gradle", "build.gradle.kts"] }, + + // .NET frameworks (detected from topics / README) + { tag: "blazor", patterns: ["blazor", "Microsoft.AspNetCore.Components"], files: ["package.json"] }, + { tag: "semantickernel", patterns: ["semantic-kernel", "Microsoft.SemanticKernel", "semantic_kernel"], files: ["requirements.txt", "pyproject.toml", "package.json"] }, + + // Ruby frameworks + { tag: "rubyonrails", patterns: ["rails", 'gem "rails"', "gem 'rails'"], files: ["Gemfile"] }, + + // AI/ML frameworks (cross-language — detected from topics only) + { tag: "rag", patterns: ["retrieval", "rag", "vector-search", "embedding"], files: [] }, + { tag: "kernelmemory", patterns: ["kernel-memory", "Microsoft.KernelMemory"], files: ["package.json"] }, +]; + +/** + * Badge-image hostname patterns to skip when looking for a preview image. + */ +const BADGE_PATTERNS = [ + "shields.io", + "img.shields.io", + "badge", + "codecov.io", + "travis-ci.org", + "travis-ci.com", + "github.com/workflows", + "github.com/actions", + "coveralls.io", + "david-dm.org", + "snyk.io", +]; + +const REQUEST_TIMEOUT_MS = 10000; +const MAX_AZURE_YAML_BYTES = 100 * 1024; // 100 KB +const MAX_README_BYTES = 50 * 1024; // 50 KB +const MAX_DEP_FILE_BYTES = 100 * 1024; // 100 KB +const DEFAULT_MAX_BYTES = 512 * 1024; // 512 KB + +// --------------------------------------------------------------------------- +// HTTP helpers +// --------------------------------------------------------------------------- + +/** + * HTTPS GET with safeLookup (DNS-rebinding protection), timeout, and size cap. + * + * @param {string} url + * @param {{ maxSize?: number, headers?: Record }} [options] + * @returns {Promise} + */ +function fetchHttps(url, options = {}) { + return new Promise((resolve, reject) => { + const parsed = new URL(url); + const maxSize = options.maxSize || DEFAULT_MAX_BYTES; + + const req = https.request( + { + hostname: parsed.hostname, + path: parsed.pathname + parsed.search, + method: "GET", + timeout: REQUEST_TIMEOUT_MS, + lookup: safeLookup, + headers: { + "User-Agent": "awesome-azd-metadata-extractor", + ...(options.headers || {}), + }, + }, + (res) => { + if (res.statusCode !== 200) { + res.resume(); + reject(new Error(`HTTP ${res.statusCode}`)); + return; + } + + const chunks = []; + let size = 0; + + res.on("data", (chunk) => { + size += chunk.length; + if (size > maxSize) { + req.destroy(); + reject(new Error(`Response exceeds ${maxSize} byte limit`)); + return; + } + chunks.push(chunk); + }); + + res.on("end", () => { + resolve(Buffer.concat(chunks).toString("utf8")); + }); + } + ); + + req.on("timeout", () => { + req.destroy(); + reject(new Error("Request timed out")); + }); + + req.on("error", reject); + req.end(); + }); +} + +/** + * HTTPS HEAD request. Resolves to `true` when status is 2xx. + * + * @param {string} url + * @returns {Promise} + */ +function headHttps(url) { + return new Promise((resolve) => { + const parsed = new URL(url); + + const req = https.request( + { + hostname: parsed.hostname, + path: parsed.pathname, + method: "HEAD", + timeout: REQUEST_TIMEOUT_MS, + lookup: safeLookup, + headers: { "User-Agent": "awesome-azd-metadata-extractor" }, + }, + (res) => { + res.resume(); + resolve(res.statusCode >= 200 && res.statusCode < 300); + } + ); + + req.on("timeout", () => { + req.destroy(); + resolve(false); + }); + req.on("error", () => resolve(false)); + req.end(); + }); +} + +// --------------------------------------------------------------------------- +// Exported functions +// --------------------------------------------------------------------------- + +/** + * Parse a GitHub repository URL into owner and repo. + * + * @param {string} url + * @returns {{ owner: string, repo: string }} + */ +function parseRepoUrl(url) { + validateUrl(url, "Repository"); + + const parsed = new URL(url); + if (parsed.hostname !== "github.com") { + throw new Error("Only GitHub repository URLs are supported"); + } + + const parts = parsed.pathname.replace(/^\/+|\/+$/g, "").split("/"); + if (parts.length < 2 || !parts[0] || !parts[1]) { + throw new Error( + "URL must be in the format https://github.com/{owner}/{repo}" + ); + } + + return { owner: parts[0], repo: parts[1].replace(/\.git$/, "") }; +} + +/** + * Fetch GitHub API metadata (repo info + languages). + * + * @param {string} owner + * @param {string} repo + * @returns {Promise<{ repoData: object, languages: object }>} + */ +async function fetchGitHubApi(owner, repo) { + const headers = { Accept: "application/vnd.github.v3+json" }; + const token = process.env.GITHUB_TOKEN; + if (token) { + headers.Authorization = `Bearer ${token}`; + } + + const eo = encodeURIComponent(owner); + const er = encodeURIComponent(repo); + + const repoData = JSON.parse( + await fetchHttps(`https://api.github.com/repos/${eo}/${er}`, { + headers, + maxSize: DEFAULT_MAX_BYTES, + }) + ); + + let languages = {}; + try { + languages = JSON.parse( + await fetchHttps( + `https://api.github.com/repos/${eo}/${er}/languages`, + { headers, maxSize: DEFAULT_MAX_BYTES } + ) + ); + } catch { + // Languages endpoint may fail — non-fatal + } + + return { repoData, languages }; +} + +/** + * Fetch and parse `azure.yaml` using FAILSAFE_SCHEMA. + * Tries the given branch first, falls back to main → master when no branch + * is specified. + * + * @param {string} owner + * @param {string} repo + * @param {string} [branch] + * @returns {Promise} + */ +async function fetchAzureYaml(owner, repo, branch) { + const eo = encodeURIComponent(owner); + const er = encodeURIComponent(repo); + + let content; + if (branch) { + content = await fetchHttps( + `https://raw.githubusercontent.com/${eo}/${er}/${encodeURIComponent(branch)}/azure.yaml`, + { maxSize: MAX_AZURE_YAML_BYTES } + ); + } else { + try { + content = await fetchHttps( + `https://raw.githubusercontent.com/${eo}/${er}/main/azure.yaml`, + { maxSize: MAX_AZURE_YAML_BYTES } + ); + } catch { + content = await fetchHttps( + `https://raw.githubusercontent.com/${eo}/${er}/master/azure.yaml`, + { maxSize: MAX_AZURE_YAML_BYTES } + ); + } + } + + return yaml.load(content, { schema: yaml.FAILSAFE_SCHEMA }); +} + +/** + * Fetch the full README.md content (up to 50 KB). + * + * The content is shared between title extraction and image extraction to + * avoid a duplicate HTTP request. + * + * @param {string} owner + * @param {string} repo + * @param {string} [branch] + * @returns {Promise} + */ +async function fetchReadme(owner, repo, branch) { + const eo = encodeURIComponent(owner); + const er = encodeURIComponent(repo); + + if (branch) { + return fetchHttps( + `https://raw.githubusercontent.com/${eo}/${er}/${encodeURIComponent(branch)}/README.md`, + { maxSize: MAX_README_BYTES } + ); + } + + try { + return await fetchHttps( + `https://raw.githubusercontent.com/${eo}/${er}/main/README.md`, + { maxSize: MAX_README_BYTES } + ); + } catch { + return fetchHttps( + `https://raw.githubusercontent.com/${eo}/${er}/master/README.md`, + { maxSize: MAX_README_BYTES } + ); + } +} + +/** + * Extract the first `# Heading` from README content. + * + * @param {string} content — raw README.md text + * @returns {string} + */ +function extractReadmeTitle(content) { + const match = content.match(/^#\s+(.+)$/m); + if (!match) return ""; + + return match[1] + .replace(/\[([^\]]*)\]\([^)]*\)/g, "$1") // strip markdown links + .replace(/[*_`~]/g, "") // strip emphasis / code markers + .trim(); +} + +/** + * Backwards-compatible wrapper — fetches the README and returns the title. + * + * @param {string} owner + * @param {string} repo + * @param {string} [branch] + * @returns {Promise} + */ +async function fetchReadmeTitle(owner, repo, branch) { + const content = await fetchReadme(owner, repo, branch); + return extractReadmeTitle(content); +} + +/** + * Determine whether a URL looks like a CI/CD badge image. + * + * @param {string} url + * @returns {boolean} + */ +function isBadgeUrl(url) { + const lower = url.toLowerCase(); + return BADGE_PATTERNS.some((p) => lower.includes(p)); +} + +/** + * Convert a (possibly relative) image path to an absolute raw.githubusercontent URL. + * + * @param {string} rawUrl — the URL as written in the README + * @param {string} owner + * @param {string} repo + * @param {string} branch + * @returns {string} absolute URL or empty string when invalid + */ +function resolveImageUrl(rawUrl, owner, repo, branch) { + if (!rawUrl) return ""; + + const trimmed = rawUrl.trim(); + if (!trimmed) return ""; + + // Absolute URL — use as-is after SSRF validation + if (/^https?:\/\//i.test(trimmed)) { + try { + validateUrl(trimmed, "Preview image"); + return trimmed; + } catch { + return ""; + } + } + + // Relative path → convert to raw.githubusercontent.com + let relPath = trimmed; + + // Strip query params / fragment + relPath = relPath.split("?")[0].split("#")[0]; + + // Normalise leading ./ ../ or / + if (relPath.startsWith("./")) { + relPath = relPath.slice(2); + } else if (relPath.startsWith("/")) { + relPath = relPath.slice(1); + } + // ../path stays as-is (raw.githubusercontent will resolve it) + + if (!relPath) return ""; + + const eo = encodeURIComponent(owner); + const er = encodeURIComponent(repo); + const eb = encodeURIComponent(branch); + + // Encode each path segment individually so slashes are preserved + const encodedPath = relPath + .split("/") + .map(encodeURIComponent) + .join("/"); + + return `https://raw.githubusercontent.com/${eo}/${er}/${eb}/${encodedPath}`; +} + +/** + * Extract the first non-badge image from README content. + * + * Supports both markdown `![alt](url)` and HTML `` formats. + * + * @param {string} content — raw README.md text + * @param {string} owner + * @param {string} repo + * @param {string} branch + * @returns {string} absolute image URL or empty string + */ +function extractReadmeImage(content, owner, repo, branch) { + if (!content) return ""; + + // Collect candidate image URLs from markdown and HTML patterns + const candidates = []; + + // Markdown images: ![alt](url) + const mdImageRe = /!\[[^\]]*\]\(([^)]+)\)/g; + let match; + while ((match = mdImageRe.exec(content)) !== null) { + candidates.push(match[1].trim()); + } + + // HTML img tags: or or + const htmlImageRe = /]*?src\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]*))/gi; + while ((match = htmlImageRe.exec(content)) !== null) { + const src = (match[1] || match[2] || match[3] || "").trim(); + if (src) candidates.push(src); + } + + // Return the first non-badge image + for (const raw of candidates) { + if (isBadgeUrl(raw)) continue; + + const resolved = resolveImageUrl(raw, owner, repo, branch); + if (resolved) return resolved; + } + + return ""; +} + +/** + * Detect frameworks by scanning dependency files and GitHub topics. + * + * Every individual file fetch is non-fatal — a 404 is silently skipped. + * + * @param {string} owner + * @param {string} repo + * @param {string} branch + * @param {string[]} topics — GitHub repository topics + * @param {string} readmeContent — already-fetched README text + * @returns {Promise} deduplicated array of framework tag names + */ +async function detectFrameworks(owner, repo, branch, topics, readmeContent) { + const eo = encodeURIComponent(owner); + const er = encodeURIComponent(repo); + const eb = encodeURIComponent(branch); + + // Determine which files we actually need to fetch + const neededFiles = new Set(); + for (const d of FRAMEWORK_DETECTORS) { + for (const f of d.files) neededFiles.add(f); + } + + // Fetch all dependency files in parallel (non-fatal) + /** @type {Record} */ + const fileContents = {}; + + const fetchPromises = Array.from(neededFiles).map(async (filename) => { + const maxSize = + filename === "package.json" || filename === "pom.xml" + ? MAX_DEP_FILE_BYTES + : MAX_README_BYTES; // 50 KB for others + try { + const body = await fetchHttps( + `https://raw.githubusercontent.com/${eo}/${er}/${eb}/${encodeURIComponent(filename)}`, + { maxSize } + ); + fileContents[filename] = body; + } catch { + // File not found or error — skip silently + } + }); + + await Promise.all(fetchPromises); + + const detected = new Set(); + const topicsLower = (topics || []).map((t) => t.toLowerCase()); + const readmeLower = (readmeContent || "").toLowerCase(); + + for (const detector of FRAMEWORK_DETECTORS) { + // Check GitHub topics + if (topicsLower.includes(detector.tag.toLowerCase())) { + detected.add(detector.tag); + continue; + } + + // Check dependency file contents + let found = false; + for (const filename of detector.files) { + const content = fileContents[filename]; + if (!content) continue; + + const contentLower = content.toLowerCase(); + for (const pattern of detector.patterns) { + if (contentLower.includes(pattern.toLowerCase())) { + found = true; + break; + } + } + if (found) break; + } + + if (found) { + detected.add(detector.tag); + continue; + } + + // Fallback: check README for prominent mention + if (readmeLower) { + for (const pattern of detector.patterns) { + if (readmeLower.includes(pattern.toLowerCase())) { + detected.add(detector.tag); + break; + } + } + } + } + + return Array.from(detected); +} + +/** + * Extract all available metadata from a GitHub repository URL. + * + * Every sub-fetch is wrapped so that a single failure never blocks the rest. + * + * @param {string} repoUrl + * @returns {Promise<{ + * title: string, + * description: string, + * author: string, + * authorUrl: string, + * authorType: string, + * languages: string[], + * frameworks: string[], + * azureServices: string[], + * iacProvider: string, + * }>} + */ +async function extractMetadata(repoUrl) { + const { owner, repo } = parseRepoUrl(repoUrl); + + const result = { + title: "", + description: "", + author: "", + authorUrl: "", + authorType: "", + languages: [], + frameworks: [], + azureServices: [], + iacProvider: "", + previewImage: "", + }; + + let defaultBranch = "main"; + let topics = []; + + // ---- GitHub API --------------------------------------------------------- + try { + const { repoData, languages } = await fetchGitHubApi(owner, repo); + + defaultBranch = repoData.default_branch || "main"; + result.description = sanitize(repoData.description || "", 500); + result.author = sanitize( + (repoData.owner && repoData.owner.login) || "", + 100 + ); + result.authorUrl = + (repoData.owner && repoData.owner.html_url) || ""; + + const ownerLogin = ( + (repoData.owner && repoData.owner.login) || "" + ).toLowerCase(); + result.authorType = MICROSOFT_ORGS.includes(ownerLogin) + ? "Microsoft" + : "Community"; + + result.languages = Object.keys(languages) + .filter((lang) => LANGUAGE_MAP[lang]) + .map((lang) => LANGUAGE_MAP[lang]); + + topics = repoData.topics || []; + result.azureServices = topics + .filter((topic) => AZURE_SERVICE_MAP[topic]) + .map((topic) => AZURE_SERVICE_MAP[topic]) + .filter((v, i, a) => a.indexOf(v) === i); // deduplicate + } catch { + // API errors are non-fatal — continue with other sources + } + + // ---- azure.yaml --------------------------------------------------------- + try { + const azureYaml = await fetchAzureYaml(owner, repo, defaultBranch); + if (azureYaml && typeof azureYaml === "object") { + if (!result.title && azureYaml.name) { + result.title = sanitize(String(azureYaml.name), 200); + } + + if ( + azureYaml.services && + typeof azureYaml.services === "object" + ) { + const hostServiceMap = { + containerapp: "aca", + appservice: "appservice", + function: "functions", + }; + for (const svc of Object.values(azureYaml.services)) { + if (svc && typeof svc === "object" && svc.host) { + const tag = hostServiceMap[String(svc.host)]; + if (tag && !result.azureServices.includes(tag)) { + result.azureServices.push(tag); + } + } + } + } + } + } catch { + // azure.yaml not found or invalid — non-fatal + } + + // ---- README (title + preview image) -------------------------------------- + let readmeContent = ""; + try { + readmeContent = await fetchReadme(owner, repo, defaultBranch); + if (!result.title) { + const readmeTitle = extractReadmeTitle(readmeContent); + if (readmeTitle) { + result.title = sanitize(readmeTitle, 200); + } + } + + const previewImage = extractReadmeImage( + readmeContent, + owner, + repo, + defaultBranch + ); + if (previewImage) { + result.previewImage = previewImage; + } + } catch { + // README not available — non-fatal + } + + // ---- Framework detection ------------------------------------------------ + try { + result.frameworks = await detectFrameworks( + owner, + repo, + defaultBranch, + topics, + readmeContent + ); + } catch { + // Framework detection failed — non-fatal + } + + // ---- IaC detection ------------------------------------------------------ + try { + const eo = encodeURIComponent(owner); + const er = encodeURIComponent(repo); + const eb = encodeURIComponent(defaultBranch); + + const [hasBicep, hasTerraform] = await Promise.all([ + headHttps( + `https://raw.githubusercontent.com/${eo}/${er}/${eb}/infra/main.bicep` + ), + headHttps( + `https://raw.githubusercontent.com/${eo}/${er}/${eb}/infra/main.tf` + ), + ]); + + if (hasBicep && hasTerraform) { + result.iacProvider = "Both"; + } else if (hasBicep) { + result.iacProvider = "Bicep"; + } else if (hasTerraform) { + result.iacProvider = "Terraform"; + } + } catch { + // IaC detection failed — non-fatal + } + + return result; +} + +// --------------------------------------------------------------------------- +// CLI entry point +// --------------------------------------------------------------------------- +if (typeof require !== "undefined" && require.main === module) { + const url = process.argv[2]; + if (!url) { + console.error( + "Usage: node extract-template-metadata.js " + ); + process.exit(1); + } + + extractMetadata(url) + .then((metadata) => { + console.log(JSON.stringify(metadata, null, 2)); + }) + .catch((err) => { + console.error(`Error: ${err.message}`); + process.exit(1); + }); +} + +module.exports = { + extractMetadata, + parseRepoUrl, + fetchGitHubApi, + fetchAzureYaml, + fetchReadme, + fetchReadmeTitle, + extractReadmeTitle, + extractReadmeImage, + detectFrameworks, + LANGUAGE_MAP, + AZURE_SERVICE_MAP, + MICROSOFT_ORGS, + FRAMEWORK_DETECTORS, +}; diff --git a/website/scripts/merge-extracted-metadata.js b/website/scripts/merge-extracted-metadata.js new file mode 100644 index 000000000..5ca31fd73 --- /dev/null +++ b/website/scripts/merge-extracted-metadata.js @@ -0,0 +1,80 @@ +#!/usr/bin/env node + +"use strict"; + +const fs = require("fs"); +const { extractMetadata } = require("./extract-template-metadata"); + +/** + * Merge form-supplied values with auto-extracted metadata. + * + * Form values (FORM_*) always take precedence. Empty / missing form values + * fall back to the corresponding auto-extracted value. + * + * The merged output is appended to $GITHUB_OUTPUT. + */ +async function main() { + const outputPath = process.env.GITHUB_OUTPUT; + if (!outputPath) { + console.error("GITHUB_OUTPUT environment variable is not set"); + process.exit(1); + } + + const sourceRepo = (process.env.SOURCE_REPO || "").trim(); + if (!sourceRepo) { + console.error("SOURCE_REPO environment variable is required"); + process.exit(1); + } + + let extracted = {}; + try { + extracted = await extractMetadata(sourceRepo); + console.log( + "Auto-extracted metadata:", + JSON.stringify(extracted, null, 2) + ); + } catch (err) { + console.warn( + `Metadata extraction failed (non-fatal): ${err.message}` + ); + } + + // Form values take precedence; fall back to extracted values + const merged = { + source_repo: sourceRepo, + template_title: + process.env.FORM_TITLE || extracted.title || "", + description: + process.env.FORM_DESCRIPTION || extracted.description || "", + author: process.env.FORM_AUTHOR || extracted.author || "", + author_url: + process.env.FORM_AUTHOR_URL || extracted.authorUrl || "", + author_type: + process.env.FORM_AUTHOR_TYPE || extracted.authorType || "", + preview_image: process.env.FORM_PREVIEW_IMAGE || extracted.previewImage || "", + iac_provider: + process.env.FORM_IAC_PROVIDER || extracted.iacProvider || "", + languages: + process.env.FORM_LANGUAGES || + (extracted.languages || []).join(", "), + frameworks: + process.env.FORM_FRAMEWORKS || + (extracted.frameworks || []).join(", "), + azure_services: + process.env.FORM_AZURE_SERVICES || + (extracted.azureServices || []).join(", "), + }; + + // Sanitize newlines to prevent output injection + const lines = Object.entries(merged) + .map(([k, v]) => `${k}=${String(v).replace(/[\r\n]+/g, " ").trim()}`) + .join("\n"); + fs.appendFileSync(outputPath, lines + "\n"); + + console.log("Merged metadata written to GITHUB_OUTPUT"); +} + +main().catch((err) => { + console.error(err.message); + process.exit(1); +}); diff --git a/website/scripts/parse-template-issue.js b/website/scripts/parse-template-issue.js new file mode 100644 index 000000000..36fc0e88e --- /dev/null +++ b/website/scripts/parse-template-issue.js @@ -0,0 +1,142 @@ +#!/usr/bin/env node + +"use strict"; + +const fs = require("fs"); + +/** + * Extract a field value from a GitHub issue body. + * + * Issue bodies generated by the template form use the pattern: + * ### Field Name + * value + * + * @param {string} body - The full issue body text. + * @param {string} fieldName - The heading name to search for. + * @returns {string} The extracted value, or empty string if not found. + */ +function extractField(body, fieldName) { + // Capture everything after the heading up to the next heading or end of body. + // Allows an optional parenthetical suffix on the heading line. + const regex = new RegExp( + `### ${fieldName}(?:\\s*\\([^)]*\\))?\\s*\\n([\\s\\S]*?)(?=\\n### |$)`, + "i" + ); + const match = body.match(regex); + if (!match) return ""; + const value = match[1].trim(); + // Treat GitHub's default placeholder as empty + if (!value || value === "_No response_") return ""; + return value; +} + +/** + * Parse template fields from either an issue body or workflow_dispatch inputs. + * + * @param {object} options + * @param {string} options.eventName - 'workflow_dispatch' or 'issues' + * @param {string} [options.issueBody] - Issue body text (for issues event) + * @param {object} [options.inputs] - workflow_dispatch inputs keyed by field name + * @returns {{ fields: Record, error?: string }} + */ +function parseIssueBody({ eventName, issueBody, inputs }) { + const fields = {}; + + if (eventName === "workflow_dispatch") { + fields.source_repo = (inputs.source_repo || "").trim(); + fields.template_title = (inputs.template_title || "").trim(); + fields.description = (inputs.description || "").trim(); + fields.author = (inputs.author || "").trim(); + fields.author_url = (inputs.author_url || "").trim(); + fields.author_type = (inputs.author_type || "").trim(); + fields.preview_image = (inputs.preview_image || "").trim(); + fields.iac_provider = (inputs.iac_provider || "").trim(); + fields.languages = (inputs.languages || "").trim(); + fields.frameworks = (inputs.frameworks || "").trim(); + fields.azure_services = (inputs.azure_services || "").trim(); + } else { + const body = issueBody || ""; + fields.source_repo = extractField(body, "Source Repository"); + fields.template_title = extractField(body, "Template Title"); + fields.description = extractField(body, "Description"); + fields.author = extractField(body, "Author"); + fields.author_url = extractField(body, "Author URL"); + fields.author_type = extractField(body, "Author Type"); + fields.preview_image = extractField(body, "Preview Image URL"); + fields.iac_provider = extractField(body, "IaC Provider"); + fields.languages = extractField(body, "Languages"); + fields.frameworks = extractField(body, "Frameworks"); + fields.azure_services = extractField(body, "Azure Services"); + } + + // Only the repository URL is required — the rest can be auto-extracted. + const required = ["source_repo"]; + const missing = required.filter((k) => !fields[k]); + if (missing.length > 0) { + return { + fields, + error: `Missing required fields: ${missing.join(", ")}`, + }; + } + + return { fields }; +} + +/** + * Sanitize a value for safe use as a GitHub Actions single-line output. + * Newlines are replaced with spaces to prevent output injection. + * @param {unknown} value + * @returns {string} + */ +function sanitizeOutputValue(value) { + if (value === null || value === undefined) return ""; + return String(value).replace(/[\r\n]+/g, " ").trim(); +} + +/** + * Write key=value pairs to the GITHUB_OUTPUT file. + * @param {string} outputPath - File path from $GITHUB_OUTPUT + * @param {Record} fields + */ +function writeOutputs(outputPath, fields) { + const lines = Object.entries(fields) + .map(([k, v]) => `${k}=${sanitizeOutputValue(v)}`) + .join("\n"); + fs.appendFileSync(outputPath, lines + "\n"); +} + +// --- CLI entry point --- +if (typeof require !== "undefined" && require.main === module) { + const outputPath = process.env.GITHUB_OUTPUT; + if (!outputPath) { + console.error("GITHUB_OUTPUT environment variable is not set"); + process.exit(1); + } + + const result = parseIssueBody({ + eventName: process.env.GITHUB_EVENT_NAME, + issueBody: process.env.ISSUE_BODY, + inputs: { + source_repo: process.env.INPUT_SOURCE_REPO, + template_title: process.env.INPUT_TEMPLATE_TITLE, + description: process.env.INPUT_DESCRIPTION, + author: process.env.INPUT_AUTHOR, + author_url: process.env.INPUT_AUTHOR_URL, + author_type: process.env.INPUT_AUTHOR_TYPE, + preview_image: process.env.INPUT_PREVIEW_IMAGE, + iac_provider: process.env.INPUT_IAC_PROVIDER, + languages: process.env.INPUT_LANGUAGES, + frameworks: process.env.INPUT_FRAMEWORKS, + azure_services: process.env.INPUT_AZURE_SERVICES, + }, + }); + + if (result.error) { + console.error(result.error); + process.exit(1); + } + + writeOutputs(outputPath, result.fields); +} + +module.exports = { extractField, parseIssueBody }; diff --git a/website/scripts/update-templates-json.js b/website/scripts/update-templates-json.js new file mode 100644 index 000000000..6dd0137e2 --- /dev/null +++ b/website/scripts/update-templates-json.js @@ -0,0 +1,198 @@ +#!/usr/bin/env node + +"use strict"; + +const fs = require("fs"); +const path = require("path"); +const crypto = require("crypto"); +const { + validateUrl, + canonicalizeUrl, +} = require("./validate-template"); + +/** + * Strip HTML tags and angle brackets, trim, and truncate. + * @param {string} value + * @param {number} maxLength + * @returns {string} + */ +function sanitize(value, maxLength) { + return value + .replace(/<[^>]*>?/g, "") + .replace(/[<>]/g, "") + .trim() + .slice(0, maxLength); +} + +/** + * Parse a comma-separated tag string into a clean array. + * + * - Filters characters to an alphanumeric + punctuation allowlist + * - Limits each tag to 50 chars and the list to 20 tags + * - Treats `_No response_` as empty + * + * @param {string} csv + * @returns {string[]} + */ +function parseTags(csv) { + if (!csv || csv === "_No response_") return []; + return csv + .split(",") + .map((t) => t.trim()) + .filter(Boolean) + .map((t) => + t + .replace(/[^a-zA-Z0-9._\-+ ]/g, "") + .trim() + .slice(0, 50) + ) + .filter((t) => t.length > 0) + .slice(0, 20); +} + +/** + * Build and append a new template entry to templates.json. + * + * @param {object} options + * @param {string} options.sourceRepo + * @param {string} options.title + * @param {string} options.description + * @param {string} options.author + * @param {string} options.authorUrl + * @param {string} options.authorType + * @param {string} options.previewImage + * @param {string} options.iacProvider + * @param {string[]} options.languages + * @param {string[]} options.frameworks + * @param {string[]} options.azureServices + * @param {string} [options.templatesPath] - Override path for testing + * @param {function} [options.uuidGenerator] - Override UUID generation for testing + * @returns {{ skipped: boolean, skipReason?: string, added?: string }} + */ +function updateTemplatesJson({ + sourceRepo, + title, + description, + author, + authorUrl, + authorType, + previewImage, + iacProvider, + languages, + frameworks, + azureServices, + templatesPath, + uuidGenerator, +}) { + validateUrl(sourceRepo, "Source repo"); + validateUrl(authorUrl, "Author"); + if (previewImage) validateUrl(previewImage, "Preview image"); + + const resolvedPath = + templatesPath || path.join("website", "static", "templates.json"); + const templates = JSON.parse(fs.readFileSync(resolvedPath, "utf8")); + + const canonicalSource = canonicalizeUrl(sourceRepo); + const duplicate = templates.find( + (t) => canonicalizeUrl(t.source) === canonicalSource + ); + if (duplicate) { + return { + skipped: true, + skipReason: `Template with source ${sourceRepo} already exists ("${duplicate.title}")`, + }; + } + + let iac; + if (iacProvider === "Both") { + iac = ["bicep", "terraform"]; + } else if (iacProvider === "Terraform") { + iac = ["terraform"]; + } else { + iac = ["bicep"]; + } + + const tags = + authorType === "Microsoft" ? ["msft", "new"] : ["community", "new"]; + + const generateId = uuidGenerator || (() => crypto.randomUUID()); + + const entry = { + title, + description, + preview: previewImage || "templates/images/default-template.png", + authorUrl, + author, + source: canonicalSource, + tags, + IaC: iac, + id: generateId(), + }; + + if (languages.length > 0) entry.languages = languages; + if (frameworks.length > 0) entry.frameworks = frameworks; + if (azureServices.length > 0) entry.azureServices = azureServices; + + templates.push(entry); + fs.writeFileSync(resolvedPath, JSON.stringify(templates, null, 2) + "\n"); + + return { skipped: false, added: title }; +} + +/** + * Write key=value pairs to the GITHUB_OUTPUT file. + * Normalizes values to a single line to prevent output injection. + * @param {string} outputPath + * @param {Record} outputs + */ +function writeOutputs(outputPath, outputs) { + const lines = Object.entries(outputs) + .map(([k, v]) => { + const safeValue = String(v).replace(/[\r\n]+/g, " ").trim(); + return `${k}=${safeValue}`; + }) + .join("\n"); + fs.appendFileSync(outputPath, lines + "\n"); +} + +// --- CLI entry point --- +if (typeof require !== "undefined" && require.main === module) { + const outputPath = process.env.GITHUB_OUTPUT; + if (!outputPath) { + console.error("GITHUB_OUTPUT environment variable is not set"); + process.exit(1); + } + + try { + const result = updateTemplatesJson({ + sourceRepo: process.env.TPL_SOURCE_REPO, + title: sanitize(process.env.TPL_TITLE || "", 200), + description: sanitize(process.env.TPL_DESCRIPTION || "", 500), + author: sanitize(process.env.TPL_AUTHOR || "", 100), + authorUrl: process.env.TPL_AUTHOR_URL, + authorType: process.env.TPL_AUTHOR_TYPE, + previewImage: process.env.TPL_PREVIEW_IMAGE, + iacProvider: process.env.TPL_IAC_PROVIDER, + languages: parseTags(process.env.TPL_LANGUAGES), + frameworks: parseTags(process.env.TPL_FRAMEWORKS), + azureServices: parseTags(process.env.TPL_AZURE_SERVICES), + }); + + if (result.skipped) { + writeOutputs(outputPath, { + skipped: "true", + skip_reason: result.skipReason, + }); + } else { + writeOutputs(outputPath, { + skipped: "false", + added: result.added, + }); + } + } catch (err) { + console.error(err.message); + process.exit(1); + } +} + +module.exports = { sanitize, parseTags, updateTemplatesJson }; diff --git a/website/scripts/validate-template.js b/website/scripts/validate-template.js new file mode 100644 index 000000000..26049fbb3 --- /dev/null +++ b/website/scripts/validate-template.js @@ -0,0 +1,247 @@ +#!/usr/bin/env node + +const https = require("https"); +const dns = require("dns"); +const net = require("net"); +const { URL } = require("url"); + +const PRIVATE_IPV4_RANGES = [ + // Loopback + /^127\./, + // RFC1918 private space + /^10\./, + /^172\.(1[6-9]|2\d|3[01])\./, + /^192\.168\./, + // Link-local + /^169\.254\./, + // "This" network + /^0\./, + // Carrier-Grade NAT (RFC 6598) 100.64.0.0/10 + /^100\.(6[4-9]|[7-9]\d|1[01]\d|12[0-7])\./, + // IETF protocol assignments 192.0.0.0/24 + /^192\.0\.0\./, + // Deprecated 6to4 relay anycast 192.88.99.0/24 + /^192\.88\.99\./, + // Documentation ranges (RFC 5737) + /^192\.0\.2\./, + /^198\.51\.100\./, + /^203\.0\.113\./, + // Benchmarking (RFC 2544) 198.18.0.0/15 + /^198\.1[89]\./, + // Multicast 224.0.0.0/4 + /^(22[4-9]|23\d)\./, + // Reserved for future use 240.0.0.0/4 (includes broadcast) + /^(24\d|25[0-5])\./, +]; + +/** + * Check if a resolved IP address belongs to a private/reserved range. + * Handles IPv4, IPv6, and IPv4-mapped IPv6 addresses. + */ +function isPrivateIP(ip) { + if (!ip) return true; // No IP = deny by default + + if (net.isIPv4(ip)) { + return PRIVATE_IPV4_RANGES.some((re) => re.test(ip)); + } + + if (net.isIPv6(ip)) { + // Loopback (::1) + if (ip === "::1") return true; + // Unspecified (::) + if (ip === "::") return true; + // Link-local (fe80::/10) + if (/^fe[89ab]/i.test(ip)) return true; + // Unique local (fc00::/7 — fc00:: through fdff::) + if (/^f[cd]/i.test(ip)) return true; + // IPv4-mapped IPv6 dotted form (::ffff:x.x.x.x) + const v4mapped = ip.match( + /^::ffff:(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/i + ); + if (v4mapped) { + return PRIVATE_IPV4_RANGES.some((re) => re.test(v4mapped[1])); + } + // IPv4-mapped IPv6 hex form (::ffff:HHHH:HHHH) + const v4hex = ip.match(/^::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/i); + if (v4hex) { + const hi = parseInt(v4hex[1], 16); + const lo = parseInt(v4hex[2], 16); + const mapped = `${(hi >> 8) & 0xff}.${hi & 0xff}.${(lo >> 8) & 0xff}.${ + lo & 0xff + }`; + return PRIVATE_IPV4_RANGES.some((re) => re.test(mapped)); + } + return false; + } + + // Unknown format — deny by default + return true; +} + +/** + * Fast pre-check: does the hostname string (as returned by WHATWG URL parser) + * point to an obviously private/reserved address? + */ +function isPrivateHost(hostname) { + if (/^localhost$/i.test(hostname)) return true; + + // Strip brackets for IPv6 literal check + const bare = hostname.replace(/^\[|\]$/g, ""); + // net.isIP detects real IP literals (URL parser normalises hex/octal); + // safeLookup catches DNS-resolved private IPs for non-literal hostnames. + if (net.isIP(bare)) return isPrivateIP(bare); + + return false; +} + +/** + * Custom DNS lookup that rejects private/reserved resolved IPs. + * Prevents DNS-rebinding attacks where a public domain resolves to an + * internal address. + */ +function safeLookup(hostname, options, callback) { + dns.lookup(hostname, options, (err, address, family) => { + if (err) return callback(err); + + // Node 24+ passes {all:true} from https.request, yielding an array + // of {address, family} objects. We must validate each and return the + // same array format the caller expects. + if (Array.isArray(address)) { + for (const entry of address) { + if (isPrivateIP(entry.address)) { + return callback( + new Error( + `Hostname "${hostname}" resolves to private/reserved IP: ${entry.address}` + ) + ); + } + } + if (address.length === 0) { + return callback(new Error(`No DNS results for "${hostname}"`)); + } + return callback(null, address, family); + } + + if (isPrivateIP(address)) { + return callback( + new Error( + `Hostname "${hostname}" resolves to private/reserved IP: ${address}` + ) + ); + } + callback(null, address, family); + }); +} + +function canonicalizeUrl(url) { + let normalized = url.trim().toLowerCase(); + normalized = normalized.replace(/\/+$/, ""); + normalized = normalized.replace(/\.git$/, ""); + normalized = normalized.replace(/\/+$/, ""); + // Strip query string and fragment to prevent duplicate-detection bypass + try { + const parsed = new URL(normalized); + return `${parsed.protocol}//${parsed.host}${parsed.pathname}`.replace( + /\/+$/, + "" + ); + } catch { + return normalized; + } +} + +function validateUrl(value, label) { + if (!value) return; + let parsed; + try { + parsed = new URL(value); + } catch { + throw new Error(`Invalid ${label} URL: "${value}"`); + } + if (parsed.protocol !== "https:") { + throw new Error( + `${label} URL must use HTTPS (got "${parsed.protocol}")` + ); + } + // Reject credentials in URLs to prevent accidental token leakage + if (parsed.username || parsed.password) { + throw new Error( + `${label} URL must not contain credentials (userinfo)` + ); + } + if (isPrivateHost(parsed.hostname)) { + throw new Error(`${label} URL points to a private/reserved address`); + } +} + +function validateTemplate(repoUrl) { + if (!repoUrl || typeof repoUrl !== "string" || !repoUrl.trim()) { + return { valid: false, errors: ["Repository URL is required"] }; + } + + const errors = []; + + try { + validateUrl(repoUrl, "Repository"); + } catch (err) { + errors.push(err.message); + } + + if (errors.length > 0) { + return { valid: false, errors }; + } + + return new Promise((resolve) => { + const parsed = new URL(repoUrl); + const req = https.request( + { + hostname: parsed.hostname, + path: parsed.pathname, + method: "HEAD", + timeout: 10000, + lookup: safeLookup, + }, + (res) => { + // Accept only 2xx — reject redirects (3xx) to prevent open-redirect abuse + if (res.statusCode >= 200 && res.statusCode < 300) { + resolve({ valid: true }); + } else { + resolve({ + valid: false, + errors: [`Repository returned HTTP ${res.statusCode}`], + }); + } + } + ); + req.on("timeout", () => { + req.destroy(); + resolve({ valid: false, errors: ["Request timed out after 10s"] }); + }); + req.on("error", (err) => { + resolve({ valid: false, errors: [`Request failed: ${err.message}`] }); + }); + req.end(); + }); +} + +if (typeof require !== "undefined" && require.main === module) { + const url = process.argv[2]; + if (!url) { + console.error("Usage: node validate-template.js "); + process.exit(1); + } + + Promise.resolve(validateTemplate(url)).then((result) => { + console.log(JSON.stringify(result)); + process.exit(result.valid ? 0 : 1); + }); +} + +module.exports = { + validateTemplate, + canonicalizeUrl, + validateUrl, + isPrivateIP, + isPrivateHost, + safeLookup, +}; diff --git a/website/test/extract-template-metadata.test.ts b/website/test/extract-template-metadata.test.ts new file mode 100644 index 000000000..4352aaaf9 --- /dev/null +++ b/website/test/extract-template-metadata.test.ts @@ -0,0 +1,1290 @@ +import { describe, expect, test, jest, beforeEach } from "@jest/globals"; +import { EventEmitter } from "events"; + +// --------------------------------------------------------------------------- +// Mock the https module — all HTTP calls are intercepted +// --------------------------------------------------------------------------- +jest.mock("https", () => ({ + request: jest.fn(), +})); + +const https = require("https") as any; + +const { + parseRepoUrl, + fetchGitHubApi, + fetchAzureYaml, + fetchReadmeTitle, + fetchReadme, + extractReadmeTitle, + extractReadmeImage, + detectFrameworks, + extractMetadata, + LANGUAGE_MAP, + AZURE_SERVICE_MAP, + MICROSOFT_ORGS, + FRAMEWORK_DETECTORS, +} = require("../scripts/extract-template-metadata"); + +// --------------------------------------------------------------------------- +// Helper — wire up a mock https.request that returns predetermined responses +// keyed by "hostname + path". +// --------------------------------------------------------------------------- +type MockEntry = { status: number; body?: string }; + +function setupMock(responses: Record) { + https.request.mockImplementation((opts: any, callback: Function) => { + const key = `${opts.hostname}${opts.path}`; + + let matched: MockEntry | undefined; + + // Try exact match first + if (responses[key]) { + matched = responses[key]; + } else { + // Fall back to substring match — longest pattern first so + // "/repos/o/r/languages" beats "/repos/o/r" when both are present. + const sorted = Object.keys(responses).sort( + (a, b) => b.length - a.length + ); + for (const pattern of sorted) { + if (key.includes(pattern)) { + matched = responses[pattern]; + break; + } + } + } + if (!matched) matched = { status: 404 }; + + const res = new EventEmitter() as any; + res.statusCode = matched.status; + res.resume = jest.fn(); + + const data = matched.body; + const status = matched.status; + + process.nextTick(() => { + callback(res); + if (status === 200 && data !== undefined) { + res.emit("data", Buffer.from(data)); + } + res.emit("end"); + }); + + const req = new EventEmitter() as any; + req.end = jest.fn(); + req.destroy = jest.fn(); + return req; + }); +} + +/** + * Helper — mock https.request so that the *request* object emits an error + * (simulates a network failure / timeout before a response is received). + */ +function setupMockError(errorMessage: string) { + https.request.mockImplementation((_opts: any, _callback: Function) => { + const req = new EventEmitter() as any; + req.end = jest.fn(); + req.destroy = jest.fn(); + process.nextTick(() => req.emit("error", new Error(errorMessage))); + return req; + }); +} + +/** + * Helper — mock https.request to trigger the timeout event. + */ +function setupMockTimeout() { + https.request.mockImplementation((_opts: any, _callback: Function) => { + const req = new EventEmitter() as any; + req.end = jest.fn(); + req.destroy = jest.fn(); + process.nextTick(() => req.emit("timeout")); + return req; + }); +} + +beforeEach(() => { + jest.clearAllMocks(); + delete process.env.GITHUB_TOKEN; +}); + +// =========================================================================== +// parseRepoUrl +// =========================================================================== +describe("parseRepoUrl", () => { + test("parses a standard GitHub URL", () => { + const result = parseRepoUrl( + "https://github.com/Azure-Samples/todo-python-mongo" + ); + expect(result).toEqual({ + owner: "Azure-Samples", + repo: "todo-python-mongo", + }); + }); + + test("strips trailing .git suffix", () => { + const result = parseRepoUrl( + "https://github.com/Azure-Samples/todo-python-mongo.git" + ); + expect(result.repo).toBe("todo-python-mongo"); + }); + + test("strips trailing slash", () => { + const result = parseRepoUrl( + "https://github.com/Azure-Samples/todo-python-mongo/" + ); + expect(result.repo).toBe("todo-python-mongo"); + }); + + test("throws on non-HTTPS URL", () => { + expect(() => + parseRepoUrl("http://github.com/Azure-Samples/todo-python-mongo") + ).toThrow(/HTTPS/); + }); + + test("throws on non-GitHub URL", () => { + expect(() => + parseRepoUrl("https://gitlab.com/user/repo") + ).toThrow(/Only GitHub/); + }); + + test("throws on URL without owner/repo path", () => { + expect(() => parseRepoUrl("https://github.com/")).toThrow(); + }); + + test("throws on URL with only owner", () => { + expect(() => + parseRepoUrl("https://github.com/Azure-Samples") + ).toThrow(); + }); + + test("throws on completely invalid URL", () => { + expect(() => parseRepoUrl("not-a-url")).toThrow(); + }); + + test("throws on private/local URL", () => { + expect(() => + parseRepoUrl("https://localhost/owner/repo") + ).toThrow(/private/); + }); + + test("handles URL with sub-paths (only first two segments used)", () => { + const result = parseRepoUrl( + "https://github.com/Azure-Samples/todo-python-mongo/tree/main" + ); + expect(result).toEqual({ + owner: "Azure-Samples", + repo: "todo-python-mongo", + }); + }); +}); + +// =========================================================================== +// LANGUAGE_MAP +// =========================================================================== +describe("LANGUAGE_MAP", () => { + test("maps known GitHub languages to gallery tags", () => { + expect(LANGUAGE_MAP["Python"]).toBe("python"); + expect(LANGUAGE_MAP["TypeScript"]).toBe("typescript"); + expect(LANGUAGE_MAP["C#"]).toBe("csharp"); + expect(LANGUAGE_MAP["Go"]).toBe("go"); + }); + + test("does not contain unmapped languages", () => { + expect(LANGUAGE_MAP["HTML"]).toBeUndefined(); + expect(LANGUAGE_MAP["CSS"]).toBeUndefined(); + expect(LANGUAGE_MAP["Shell"]).toBeUndefined(); + }); +}); + +// =========================================================================== +// AZURE_SERVICE_MAP +// =========================================================================== +describe("AZURE_SERVICE_MAP", () => { + test("maps known topics to service tags", () => { + expect(AZURE_SERVICE_MAP["azure-openai"]).toBe("openai"); + expect(AZURE_SERVICE_MAP["azure-container-apps"]).toBe("aca"); + expect(AZURE_SERVICE_MAP["azure-cosmos-db"]).toBe("cosmosdb"); + }); + + test("deduplicates azure-search and azure-ai-search to aisearch", () => { + expect(AZURE_SERVICE_MAP["azure-search"]).toBe("aisearch"); + expect(AZURE_SERVICE_MAP["azure-ai-search"]).toBe("aisearch"); + }); + + test("unknown topics are not in the map", () => { + expect(AZURE_SERVICE_MAP["react"]).toBeUndefined(); + expect(AZURE_SERVICE_MAP["python"]).toBeUndefined(); + }); +}); + +// =========================================================================== +// MICROSOFT_ORGS +// =========================================================================== +describe("MICROSOFT_ORGS", () => { + test("includes expected organisations (lowercase)", () => { + expect(MICROSOFT_ORGS).toContain("azure"); + expect(MICROSOFT_ORGS).toContain("azure-samples"); + expect(MICROSOFT_ORGS).toContain("microsoft"); + }); +}); + +// =========================================================================== +// fetchGitHubApi +// =========================================================================== +describe("fetchGitHubApi", () => { + const repoBody = JSON.stringify({ + description: "A todo app with Python and MongoDB", + owner: { login: "Azure-Samples", html_url: "https://github.com/Azure-Samples" }, + topics: ["azure-container-apps", "azure-cosmos-db"], + default_branch: "main", + }); + + const langBody = JSON.stringify({ Python: 5000, JavaScript: 2000, HTML: 500 }); + + test("returns repo data and languages", async () => { + setupMock({ + "api.github.com/repos/Azure-Samples/todo-python-mongo": { + status: 200, + body: repoBody, + }, + "api.github.com/repos/Azure-Samples/todo-python-mongo/languages": { + status: 200, + body: langBody, + }, + }); + + const { repoData, languages } = await fetchGitHubApi( + "Azure-Samples", + "todo-python-mongo" + ); + expect(repoData.description).toBe("A todo app with Python and MongoDB"); + expect(languages.Python).toBe(5000); + }); + + test("returns empty languages when languages endpoint fails", async () => { + setupMock({ + "api.github.com/repos/Azure-Samples/todo-python-mongo": { + status: 200, + body: repoBody, + }, + // No languages endpoint — will 404 + }); + + // Override the mock so that the second call (languages) gets a 404 + const calls: number[] = []; + https.request.mockImplementation((opts: any, callback: Function) => { + calls.push(1); + const isLanguages = opts.path.endsWith("/languages"); + const status = isLanguages ? 404 : 200; + const body = isLanguages ? "" : repoBody; + + const res = new EventEmitter() as any; + res.statusCode = status; + res.resume = jest.fn(); + + process.nextTick(() => { + callback(res); + if (status === 200) res.emit("data", Buffer.from(body)); + res.emit("end"); + }); + + const req = new EventEmitter() as any; + req.end = jest.fn(); + req.destroy = jest.fn(); + return req; + }); + + const { repoData, languages } = await fetchGitHubApi( + "Azure-Samples", + "todo-python-mongo" + ); + expect(repoData.description).toBe("A todo app with Python and MongoDB"); + expect(languages).toEqual({}); + }); + + test("includes Authorization header when GITHUB_TOKEN is set", async () => { + process.env.GITHUB_TOKEN = "ghp_test123"; + setupMock({ + "api.github.com/repos/owner/repo": { + status: 200, + body: JSON.stringify({ description: "test", owner: { login: "owner" } }), + }, + "api.github.com/repos/owner/repo/languages": { + status: 200, + body: "{}", + }, + }); + + await fetchGitHubApi("owner", "repo"); + + const firstCallOpts = https.request.mock.calls[0][0]; + expect(firstCallOpts.headers.Authorization).toBe("Bearer ghp_test123"); + }); +}); + +// =========================================================================== +// fetchAzureYaml +// =========================================================================== +describe("fetchAzureYaml", () => { + const azureYamlContent = [ + "name: todo-python-mongo", + "services:", + " web:", + " host: containerapp", + " language: python", + " api:", + " host: appservice", + " language: python", + ].join("\n"); + + test("parses azure.yaml from specified branch", async () => { + setupMock({ + "raw.githubusercontent.com/Azure-Samples/todo-python-mongo/main/azure.yaml": { + status: 200, + body: azureYamlContent, + }, + }); + + const result = await fetchAzureYaml( + "Azure-Samples", + "todo-python-mongo", + "main" + ); + expect(result.name).toBe("todo-python-mongo"); + expect(result.services.web.host).toBe("containerapp"); + expect(result.services.api.host).toBe("appservice"); + }); + + test("falls back to master when main fails (no branch specified)", async () => { + // main → 404, master → 200 + https.request.mockImplementation((opts: any, callback: Function) => { + const isMain = opts.path.includes("/main/"); + const status = isMain ? 404 : 200; + const body = isMain ? "" : azureYamlContent; + + const res = new EventEmitter() as any; + res.statusCode = status; + res.resume = jest.fn(); + + process.nextTick(() => { + callback(res); + if (status === 200) res.emit("data", Buffer.from(body)); + res.emit("end"); + }); + + const req = new EventEmitter() as any; + req.end = jest.fn(); + req.destroy = jest.fn(); + return req; + }); + + const result = await fetchAzureYaml( + "Azure-Samples", + "todo-python-mongo" + ); + expect(result.name).toBe("todo-python-mongo"); + }); + + test("uses FAILSAFE_SCHEMA — no type coercion", async () => { + const yamlWithTypes = [ + "name: my-template", + "version: 1.0", + "enabled: true", + "count: 42", + ].join("\n"); + + setupMock({ + "raw.githubusercontent.com/owner/repo/main/azure.yaml": { + status: 200, + body: yamlWithTypes, + }, + }); + + const result = await fetchAzureYaml("owner", "repo", "main"); + // FAILSAFE_SCHEMA: everything stays as strings + expect(result.version).toBe("1.0"); + expect(result.enabled).toBe("true"); + expect(result.count).toBe("42"); + expect(typeof result.version).toBe("string"); + expect(typeof result.enabled).toBe("string"); + expect(typeof result.count).toBe("string"); + }); + + test("rejects when both main and master fail", async () => { + setupMock({}); // all requests 404 + await expect( + fetchAzureYaml("owner", "repo") + ).rejects.toThrow(/HTTP 404/); + }); +}); + +// =========================================================================== +// fetchReadmeTitle +// =========================================================================== +describe("fetchReadmeTitle", () => { + test("extracts first # heading", async () => { + setupMock({ + "raw.githubusercontent.com/owner/repo/main/README.md": { + status: 200, + body: "# My Awesome Template\n\nSome description here.", + }, + }); + + const title = await fetchReadmeTitle("owner", "repo", "main"); + expect(title).toBe("My Awesome Template"); + }); + + test("strips markdown link formatting", async () => { + setupMock({ + "raw.githubusercontent.com/owner/repo/main/README.md": { + status: 200, + body: "# [My Template](https://example.com)\n", + }, + }); + + const title = await fetchReadmeTitle("owner", "repo", "main"); + expect(title).toBe("My Template"); + }); + + test("strips emphasis and code markers", async () => { + setupMock({ + "raw.githubusercontent.com/owner/repo/main/README.md": { + status: 200, + body: "# **Bold** and `code` _italic_ title\n", + }, + }); + + const title = await fetchReadmeTitle("owner", "repo", "main"); + expect(title).toBe("Bold and code italic title"); + }); + + test("returns empty string when no heading found", async () => { + setupMock({ + "raw.githubusercontent.com/owner/repo/main/README.md": { + status: 200, + body: "No heading here, just plain text.", + }, + }); + + const title = await fetchReadmeTitle("owner", "repo", "main"); + expect(title).toBe(""); + }); + + test("falls back to master branch (no branch specified)", async () => { + https.request.mockImplementation((opts: any, callback: Function) => { + const isMain = opts.path.includes("/main/"); + const status = isMain ? 404 : 200; + const body = isMain ? "" : "# Master Title\n"; + + const res = new EventEmitter() as any; + res.statusCode = status; + res.resume = jest.fn(); + + process.nextTick(() => { + callback(res); + if (status === 200) res.emit("data", Buffer.from(body)); + res.emit("end"); + }); + + const req = new EventEmitter() as any; + req.end = jest.fn(); + req.destroy = jest.fn(); + return req; + }); + + const title = await fetchReadmeTitle("owner", "repo"); + expect(title).toBe("Master Title"); + }); +}); + +// =========================================================================== +// extractReadmeTitle (pure function) +// =========================================================================== +describe("extractReadmeTitle", () => { + test("extracts first # heading from content", () => { + expect(extractReadmeTitle("# Hello World\nsome text")).toBe("Hello World"); + }); + + test("returns empty string when no heading", () => { + expect(extractReadmeTitle("No heading here")).toBe(""); + }); + + test("strips markdown links", () => { + expect( + extractReadmeTitle("# [My App](https://example.com)\n") + ).toBe("My App"); + }); +}); + +// =========================================================================== +// extractReadmeImage +// =========================================================================== +describe("extractReadmeImage", () => { + test("extracts first markdown image and returns absolute URL", () => { + const content = "# Title\n\n![screenshot](https://example.com/img.png)\n"; + const result = extractReadmeImage(content, "owner", "repo", "main"); + expect(result).toBe("https://example.com/img.png"); + }); + + test("extracts HTML img src with double quotes", () => { + const content = + '# Title\n\nphoto\n'; + const result = extractReadmeImage(content, "owner", "repo", "main"); + expect(result).toBe("https://example.com/photo.jpg"); + }); + + test("extracts HTML img src with single quotes", () => { + const content = + "# Title\n\nphoto\n"; + const result = extractReadmeImage(content, "owner", "repo", "main"); + expect(result).toBe("https://example.com/photo.jpg"); + }); + + test("converts relative path to raw.githubusercontent.com URL", () => { + const content = "# Title\n\n![preview](docs/images/preview.png)\n"; + const result = extractReadmeImage(content, "owner", "repo", "main"); + expect(result).toBe( + "https://raw.githubusercontent.com/owner/repo/main/docs/images/preview.png" + ); + }); + + test("handles ./ prefix in relative paths", () => { + const content = "# Title\n\n![preview](./assets/demo.gif)\n"; + const result = extractReadmeImage(content, "owner", "repo", "main"); + expect(result).toBe( + "https://raw.githubusercontent.com/owner/repo/main/assets/demo.gif" + ); + }); + + test("handles / prefix in absolute paths", () => { + const content = "# Title\n\n![preview](/assets/demo.gif)\n"; + const result = extractReadmeImage(content, "owner", "repo", "main"); + expect(result).toBe( + "https://raw.githubusercontent.com/owner/repo/main/assets/demo.gif" + ); + }); + + test("returns empty string when no images in README", () => { + const content = "# Title\n\nJust text, no images.\n"; + const result = extractReadmeImage(content, "owner", "repo", "main"); + expect(result).toBe(""); + }); + + test("returns empty string for empty content", () => { + expect(extractReadmeImage("", "owner", "repo", "main")).toBe(""); + }); + + test("skips shields.io badge images", () => { + const content = + "# Title\n\n![badge](https://img.shields.io/badge/build-passing-green)\n![real](./screenshot.png)\n"; + const result = extractReadmeImage(content, "owner", "repo", "main"); + expect(result).toBe( + "https://raw.githubusercontent.com/owner/repo/main/screenshot.png" + ); + }); + + test("skips codecov badge images", () => { + const content = + "# Title\n\n![coverage](https://codecov.io/gh/owner/repo/graph/badge.svg)\n![real](https://example.com/preview.png)\n"; + const result = extractReadmeImage(content, "owner", "repo", "main"); + expect(result).toBe("https://example.com/preview.png"); + }); + + test("prefers first non-badge image when multiple badges precede real image", () => { + const content = [ + "# Title", + "![badge1](https://img.shields.io/badge/one)", + "![badge2](https://img.shields.io/badge/two)", + "![preview](./docs/preview.png)", + "![another](./docs/another.png)", + ].join("\n"); + const result = extractReadmeImage(content, "owner", "repo", "main"); + expect(result).toBe( + "https://raw.githubusercontent.com/owner/repo/main/docs/preview.png" + ); + }); + + test("strips query params from relative paths", () => { + const content = "# Title\n\n![img](./docs/img.png?raw=true)\n"; + const result = extractReadmeImage(content, "owner", "repo", "main"); + expect(result).toBe( + "https://raw.githubusercontent.com/owner/repo/main/docs/img.png" + ); + }); + + test("handles HTML img with additional attributes before src", () => { + const content = + '# Title\n\n\n'; + const result = extractReadmeImage(content, "owner", "repo", "main"); + expect(result).toBe("https://example.com/wide.png"); + }); +}); + +// =========================================================================== +// detectFrameworks +// =========================================================================== +describe("detectFrameworks", () => { + test("detects fastapi from requirements.txt", async () => { + setupMock({ + "raw.githubusercontent.com/owner/repo/main/requirements.txt": { + status: 200, + body: "fastapi==0.100.0\nuvicorn>=0.22\n", + }, + }); + + const result = await detectFrameworks("owner", "repo", "main", [], ""); + expect(result).toContain("fastapi"); + }); + + test("detects react from package.json", async () => { + setupMock({ + "raw.githubusercontent.com/owner/repo/main/package.json": { + status: 200, + body: JSON.stringify({ + dependencies: { "react": "^18.2.0", "react-dom": "^18.2.0" }, + }), + }, + }); + + const result = await detectFrameworks("owner", "repo", "main", [], ""); + expect(result).toContain("reactjs"); + }); + + test("detects spring from pom.xml", async () => { + setupMock({ + "raw.githubusercontent.com/owner/repo/main/pom.xml": { + status: 200, + body: "org.springframework.boot", + }, + }); + + const result = await detectFrameworks("owner", "repo", "main", [], ""); + expect(result).toContain("spring"); + }); + + test("detects django from pyproject.toml", async () => { + setupMock({ + "raw.githubusercontent.com/owner/repo/main/pyproject.toml": { + status: 200, + body: '[project]\ndependencies = [\n "django>=4.2",\n]\n', + }, + }); + + const result = await detectFrameworks("owner", "repo", "main", [], ""); + expect(result).toContain("django"); + }); + + test("detects rails from Gemfile", async () => { + setupMock({ + "raw.githubusercontent.com/owner/repo/main/Gemfile": { + status: 200, + body: 'source "https://rubygems.org"\ngem "rails", "~> 7.0"\n', + }, + }); + + const result = await detectFrameworks("owner", "repo", "main", [], ""); + expect(result).toContain("rubyonrails"); + }); + + test("detects multiple frameworks", async () => { + setupMock({ + "raw.githubusercontent.com/owner/repo/main/requirements.txt": { + status: 200, + body: "fastapi\nlangchain\nstreamlit\n", + }, + }); + + const result = await detectFrameworks("owner", "repo", "main", [], ""); + expect(result).toContain("fastapi"); + expect(result).toContain("langchain"); + expect(result).toContain("streamlit"); + }); + + test("returns empty array when no frameworks detected", async () => { + setupMock({}); // all 404s + + const result = await detectFrameworks("owner", "repo", "main", [], ""); + expect(result).toEqual([]); + }); + + test("detects from GitHub topics", async () => { + setupMock({}); // no dependency files + + const result = await detectFrameworks( + "owner", + "repo", + "main", + ["fastapi", "some-other-topic"], + "" + ); + expect(result).toContain("fastapi"); + }); + + test("deduplicates results from topics and file content", async () => { + setupMock({ + "raw.githubusercontent.com/owner/repo/main/requirements.txt": { + status: 200, + body: "fastapi>=0.100\n", + }, + }); + + const result = await detectFrameworks( + "owner", + "repo", + "main", + ["fastapi"], + "" + ); + // Should contain fastapi only once + const count = result.filter((f: string) => f === "fastapi").length; + expect(count).toBe(1); + }); + + test("case-insensitive matching in file content", async () => { + setupMock({ + "raw.githubusercontent.com/owner/repo/main/requirements.txt": { + status: 200, + body: "FastAPI==0.100.0\n", + }, + }); + + const result = await detectFrameworks("owner", "repo", "main", [], ""); + expect(result).toContain("fastapi"); + }); + + test("detects framework from README as fallback", async () => { + setupMock({}); // no dependency files + + const result = await detectFrameworks( + "owner", + "repo", + "main", + [], + "# My App\n\nBuilt with Django and deployed on Azure.\n" + ); + expect(result).toContain("django"); + }); + + test("detects spring from build.gradle", async () => { + setupMock({ + "raw.githubusercontent.com/owner/repo/main/build.gradle": { + status: 200, + body: "implementation 'org.springframework.boot:spring-boot-starter-web'\n", + }, + }); + + const result = await detectFrameworks("owner", "repo", "main", [], ""); + expect(result).toContain("spring"); + }); + + test("detects nextjs from package.json", async () => { + setupMock({ + "raw.githubusercontent.com/owner/repo/main/package.json": { + status: 200, + body: JSON.stringify({ + dependencies: { "next": "^14.0.0" }, + }), + }, + }); + + const result = await detectFrameworks("owner", "repo", "main", [], ""); + expect(result).toContain("nextjs"); + }); +}); + +// =========================================================================== +// FRAMEWORK_DETECTORS +// =========================================================================== +describe("FRAMEWORK_DETECTORS", () => { + test("is a non-empty array", () => { + expect(Array.isArray(FRAMEWORK_DETECTORS)).toBe(true); + expect(FRAMEWORK_DETECTORS.length).toBeGreaterThan(0); + }); + + test("every detector has tag, patterns, and files", () => { + for (const d of FRAMEWORK_DETECTORS) { + expect(typeof d.tag).toBe("string"); + expect(Array.isArray(d.patterns)).toBe(true); + expect(Array.isArray(d.files)).toBe(true); + } + }); +}); + +// =========================================================================== +// extractMetadata — integration of all sub-fetches +// =========================================================================== +describe("extractMetadata", () => { + // Standard mock responses for a "happy path" repo + const REPO_API = JSON.stringify({ + description: "A todo app with Python and MongoDB", + owner: { + login: "Azure-Samples", + html_url: "https://github.com/Azure-Samples", + }, + topics: ["azure-container-apps", "azure-openai"], + default_branch: "main", + }); + + const LANGUAGES_API = JSON.stringify({ + Python: 5000, + JavaScript: 2000, + HTML: 500, + }); + + const AZURE_YAML = [ + "name: todo-python-mongo", + "services:", + " web:", + " host: containerapp", + " language: python", + ].join("\n"); + + const README = + "# Todo Python Mongo\n\nA sample template.\n\n![screenshot](./docs/screenshot.png)\n"; + + const PACKAGE_JSON = JSON.stringify({ + dependencies: { "react": "^18.2.0", "next": "^14.0.0" }, + devDependencies: {}, + }); + + function setupFullMock(overrides: Record = {}) { + const defaults: Record = { + "api.github.com/repos/Azure-Samples/todo-python-mongo": { + status: 200, + body: REPO_API, + }, + "api.github.com/repos/Azure-Samples/todo-python-mongo/languages": { + status: 200, + body: LANGUAGES_API, + }, + "raw.githubusercontent.com/Azure-Samples/todo-python-mongo/main/azure.yaml": { + status: 200, + body: AZURE_YAML, + }, + "raw.githubusercontent.com/Azure-Samples/todo-python-mongo/main/README.md": { + status: 200, + body: README, + }, + "raw.githubusercontent.com/Azure-Samples/todo-python-mongo/main/package.json": { + status: 200, + body: PACKAGE_JSON, + }, + "raw.githubusercontent.com/Azure-Samples/todo-python-mongo/main/infra/main.bicep": { + status: 200, + body: "", + }, + "raw.githubusercontent.com/Azure-Samples/todo-python-mongo/main/infra/main.tf": { + status: 404, + }, + }; + + // Merge overrides + const merged = { ...defaults, ...overrides }; + + https.request.mockImplementation((opts: any, callback: Function) => { + const key = `${opts.hostname}${opts.path}`; + + let matched: MockEntry | undefined; + if (merged[key]) { + matched = merged[key]; + } else { + const sorted = Object.keys(merged).sort( + (a, b) => b.length - a.length + ); + for (const pattern of sorted) { + if (key.includes(pattern)) { + matched = merged[pattern]; + break; + } + } + } + if (!matched) matched = { status: 404 }; + + const res = new EventEmitter() as any; + res.statusCode = matched.status; + res.resume = jest.fn(); + const data = matched.body; + const st = matched.status; + + process.nextTick(() => { + callback(res); + if (st === 200 && data !== undefined) { + res.emit("data", Buffer.from(data)); + } + res.emit("end"); + }); + + const req = new EventEmitter() as any; + req.end = jest.fn(); + req.destroy = jest.fn(); + return req; + }); + } + + test("full extraction — happy path", async () => { + setupFullMock(); + + const meta = await extractMetadata( + "https://github.com/Azure-Samples/todo-python-mongo" + ); + expect(meta.title).toBe("todo-python-mongo"); + expect(meta.description).toBe("A todo app with Python and MongoDB"); + expect(meta.author).toBe("Azure-Samples"); + expect(meta.authorUrl).toBe("https://github.com/Azure-Samples"); + expect(meta.authorType).toBe("Microsoft"); + expect(meta.languages).toEqual( + expect.arrayContaining(["python", "javascript"]) + ); + expect(meta.languages).not.toContain("html"); // HTML not in LANGUAGE_MAP + expect(meta.azureServices).toContain("aca"); + expect(meta.azureServices).toContain("openai"); + expect(meta.iacProvider).toBe("Bicep"); + expect(meta.previewImage).toBe( + "https://raw.githubusercontent.com/Azure-Samples/todo-python-mongo/main/docs/screenshot.png" + ); + expect(meta.frameworks).toEqual( + expect.arrayContaining(["reactjs", "nextjs"]) + ); + }); + + test("uses README title when azure.yaml has no name", async () => { + const azureYamlNoName = "services:\n web:\n host: containerapp\n"; + setupFullMock({ + "raw.githubusercontent.com/Azure-Samples/todo-python-mongo/main/azure.yaml": { + status: 200, + body: azureYamlNoName, + }, + }); + + const meta = await extractMetadata( + "https://github.com/Azure-Samples/todo-python-mongo" + ); + expect(meta.title).toBe("Todo Python Mongo"); + }); + + test("detects Terraform when only main.tf exists", async () => { + setupFullMock({ + "raw.githubusercontent.com/Azure-Samples/todo-python-mongo/main/infra/main.bicep": { + status: 404, + }, + "raw.githubusercontent.com/Azure-Samples/todo-python-mongo/main/infra/main.tf": { + status: 200, + body: "", + }, + }); + + const meta = await extractMetadata( + "https://github.com/Azure-Samples/todo-python-mongo" + ); + expect(meta.iacProvider).toBe("Terraform"); + }); + + test("detects Both when bicep and terraform exist", async () => { + setupFullMock({ + "raw.githubusercontent.com/Azure-Samples/todo-python-mongo/main/infra/main.bicep": { + status: 200, + body: "", + }, + "raw.githubusercontent.com/Azure-Samples/todo-python-mongo/main/infra/main.tf": { + status: 200, + body: "", + }, + }); + + const meta = await extractMetadata( + "https://github.com/Azure-Samples/todo-python-mongo" + ); + expect(meta.iacProvider).toBe("Both"); + }); + + test("empty iacProvider when neither bicep nor terraform exist", async () => { + setupFullMock({ + "raw.githubusercontent.com/Azure-Samples/todo-python-mongo/main/infra/main.bicep": { + status: 404, + }, + "raw.githubusercontent.com/Azure-Samples/todo-python-mongo/main/infra/main.tf": { + status: 404, + }, + }); + + const meta = await extractMetadata( + "https://github.com/Azure-Samples/todo-python-mongo" + ); + expect(meta.iacProvider).toBe(""); + }); + + test("detects Community author type for non-Microsoft orgs", async () => { + setupFullMock({ + "api.github.com/repos/Azure-Samples/todo-python-mongo": { + status: 200, + body: JSON.stringify({ + description: "Community template", + owner: { + login: "random-user", + html_url: "https://github.com/random-user", + }, + topics: [], + default_branch: "main", + }), + }, + }); + + const meta = await extractMetadata( + "https://github.com/Azure-Samples/todo-python-mongo" + ); + expect(meta.authorType).toBe("Community"); + expect(meta.author).toBe("random-user"); + }); + + test("detects Microsoft author type for 'microsoft' org", async () => { + setupFullMock({ + "api.github.com/repos/Azure-Samples/todo-python-mongo": { + status: 200, + body: JSON.stringify({ + description: "MS template", + owner: { + login: "microsoft", + html_url: "https://github.com/microsoft", + }, + topics: [], + default_branch: "main", + }), + }, + }); + + const meta = await extractMetadata( + "https://github.com/Azure-Samples/todo-python-mongo" + ); + expect(meta.authorType).toBe("Microsoft"); + }); + + test("deduplicates azure services from topics and azure.yaml hosts", async () => { + // Topic provides 'aca', azure.yaml host also provides 'aca' + setupFullMock(); + + const meta = await extractMetadata( + "https://github.com/Azure-Samples/todo-python-mongo" + ); + const acaCount = meta.azureServices.filter( + (s: string) => s === "aca" + ).length; + expect(acaCount).toBe(1); + }); + + test("survives API failure — still returns partial results", async () => { + // API fails (404), but azure.yaml and README work + setupFullMock({ + "api.github.com/repos/Azure-Samples/todo-python-mongo": { status: 500 }, + "api.github.com/repos/Azure-Samples/todo-python-mongo/languages": { + status: 500, + }, + }); + + const meta = await extractMetadata( + "https://github.com/Azure-Samples/todo-python-mongo" + ); + // Title from azure.yaml (API failed so no description) + expect(meta.title).toBe("todo-python-mongo"); + expect(meta.description).toBe(""); + expect(meta.author).toBe(""); + expect(meta.iacProvider).toBe("Bicep"); // IaC still detected + }); + + test("survives azure.yaml failure — still returns other data", async () => { + setupFullMock({ + "raw.githubusercontent.com/Azure-Samples/todo-python-mongo/main/azure.yaml": { + status: 404, + }, + }); + + const meta = await extractMetadata( + "https://github.com/Azure-Samples/todo-python-mongo" + ); + expect(meta.description).toBe("A todo app with Python and MongoDB"); + // Title falls back to README + expect(meta.title).toBe("Todo Python Mongo"); + }); + + test("survives README failure — still returns other data", async () => { + setupFullMock({ + "raw.githubusercontent.com/Azure-Samples/todo-python-mongo/main/README.md": { + status: 404, + }, + }); + + const meta = await extractMetadata( + "https://github.com/Azure-Samples/todo-python-mongo" + ); + // Title from azure.yaml + expect(meta.title).toBe("todo-python-mongo"); + }); + + test("throws on invalid URL (does not swallow parseRepoUrl errors)", async () => { + await expect(extractMetadata("not-a-url")).rejects.toThrow(); + }); + + test("throws on non-GitHub URL", async () => { + await expect( + extractMetadata("https://gitlab.com/user/repo") + ).rejects.toThrow(/Only GitHub/); + }); + + test("extracts 'function' host as 'functions' service tag", async () => { + const yamlWithFunction = + "name: fn-app\nservices:\n func:\n host: function\n"; + setupFullMock({ + "raw.githubusercontent.com/Azure-Samples/todo-python-mongo/main/azure.yaml": { + status: 200, + body: yamlWithFunction, + }, + }); + + const meta = await extractMetadata( + "https://github.com/Azure-Samples/todo-python-mongo" + ); + expect(meta.azureServices).toContain("functions"); + }); +}); + +// =========================================================================== +// Error handling — network errors and timeouts +// =========================================================================== +describe("error handling", () => { + test("fetchGitHubApi rejects on network error", async () => { + setupMockError("ECONNREFUSED"); + await expect(fetchGitHubApi("owner", "repo")).rejects.toThrow( + /ECONNREFUSED/ + ); + }); + + test("fetchAzureYaml rejects on timeout", async () => { + setupMockTimeout(); + await expect( + fetchAzureYaml("owner", "repo", "main") + ).rejects.toThrow(/timed out/); + }); + + test("fetchReadmeTitle rejects on timeout", async () => { + setupMockTimeout(); + await expect( + fetchReadmeTitle("owner", "repo", "main") + ).rejects.toThrow(/timed out/); + }); + + test("extractMetadata survives all sub-fetch errors gracefully", async () => { + setupMockError("ENOTFOUND"); + + const meta = await extractMetadata( + "https://github.com/Azure-Samples/todo-python-mongo" + ); + // Everything empty but no throw + expect(meta.title).toBe(""); + expect(meta.description).toBe(""); + expect(meta.iacProvider).toBe(""); + }); +}); + +// =========================================================================== +// Size-limit enforcement +// =========================================================================== +describe("size limits", () => { + test("rejects when response exceeds maxSize", async () => { + // Create a response body larger than 50KB (the README limit) + const largeBody = "# Title\n" + "x".repeat(55 * 1024); + + setupMock({ + "raw.githubusercontent.com/owner/repo/main/README.md": { + status: 200, + body: largeBody, + }, + }); + + // fetchReadmeTitle uses MAX_README_BYTES = 50KB + await expect( + fetchReadmeTitle("owner", "repo", "main") + ).rejects.toThrow(/byte limit/); + }); +}); + +// =========================================================================== +// YAML safety — FAILSAFE_SCHEMA prevents code execution +// =========================================================================== +describe("YAML safety", () => { + test("does not execute JavaScript embedded in YAML", async () => { + // If the YAML parser evaluated code, this would throw or execute + const maliciousYaml = [ + "name: !!js/function 'function(){ throw new Error(\"pwned\") }'", + "services: {}", + ].join("\n"); + + setupMock({ + "raw.githubusercontent.com/owner/repo/main/azure.yaml": { + status: 200, + body: maliciousYaml, + }, + }); + + // With FAILSAFE_SCHEMA the unknown !!js/function tag causes a + // YAMLException — which is the *correct* security behaviour: the + // code is never executed, just rejected. + await expect( + fetchAzureYaml("owner", "repo", "main") + ).rejects.toThrow(); + }); + + test("handles malformed YAML without crashing extractMetadata", async () => { + const badYaml = "{{{{invalid yaml\n:::no good"; + + // Mock all endpoints - API works, but azure.yaml is malformed + const repoApi = JSON.stringify({ + description: "test", + owner: { login: "owner", html_url: "https://github.com/owner" }, + topics: [], + default_branch: "main", + }); + + https.request.mockImplementation((opts: any, callback: Function) => { + const key = `${opts.hostname}${opts.path}`; + let status = 404; + let body = ""; + + if (key.includes("api.github.com") && !key.includes("/languages")) { + status = 200; + body = repoApi; + } else if (key.includes("/languages")) { + status = 200; + body = "{}"; + } else if (key.includes("azure.yaml")) { + status = 200; + body = badYaml; + } else if (key.includes("README.md")) { + status = 200; + body = "# Title\n"; + } + + const res = new EventEmitter() as any; + res.statusCode = status; + res.resume = jest.fn(); + + process.nextTick(() => { + callback(res); + if (status === 200) res.emit("data", Buffer.from(body)); + res.emit("end"); + }); + + const req = new EventEmitter() as any; + req.end = jest.fn(); + req.destroy = jest.fn(); + return req; + }); + + // Should not throw — malformed YAML is caught internally + const meta = await extractMetadata( + "https://github.com/Azure-Samples/todo-python-mongo" + ); + expect(meta.description).toBe("test"); + }); +}); diff --git a/website/test/parse-template-issue.test.ts b/website/test/parse-template-issue.test.ts new file mode 100644 index 000000000..350329753 --- /dev/null +++ b/website/test/parse-template-issue.test.ts @@ -0,0 +1,371 @@ +import { describe, expect, test } from "@jest/globals"; + +const { + extractField, + parseIssueBody, +} = require("../scripts/parse-template-issue"); + +// --------------------------------------------------------------------------- +// extractField +// --------------------------------------------------------------------------- +describe("extractField", () => { + const body = [ + "### Source Repository", + "https://github.com/org/repo", + "", + "### Template Title", + "My Template", + "", + "### Description", + "A great template for demos", + "", + "### Author", + "Jane Doe", + "", + "### Author URL", + "https://github.com/janedoe", + "", + "### Author Type", + "Community", + "", + "### Preview Image URL", + "_No response_", + "", + "### IaC Provider", + "Bicep", + "", + "### Languages", + "Python, JavaScript", + "", + "### Frameworks", + "_No response_", + "", + "### Azure Services", + "App Service, Cosmos DB", + ].join("\n"); + + test("extracts a simple field", () => { + expect(extractField(body, "Template Title")).toBe("My Template"); + }); + + test("extracts URL field", () => { + expect(extractField(body, "Source Repository")).toBe( + "https://github.com/org/repo" + ); + }); + + test("extracts multi-word field name", () => { + expect(extractField(body, "Preview Image URL")).toBe(""); + }); + + test("returns empty string for _No response_", () => { + expect(extractField(body, "Frameworks")).toBe(""); + }); + + test("returns empty string for missing field", () => { + expect(extractField(body, "Nonexistent Field")).toBe(""); + }); + + test("trims whitespace around value", () => { + const padded = "### Field\n value with spaces \n"; + expect(extractField(padded, "Field")).toBe("value with spaces"); + }); + + test("is case-insensitive on heading", () => { + const lower = "### source repository\nhttps://example.com\n"; + expect(extractField(lower, "Source Repository")).toBe( + "https://example.com" + ); + }); + + test("handles empty body gracefully", () => { + expect(extractField("", "Field")).toBe(""); + }); + + test("handles body with only heading, no value line", () => { + expect(extractField("### Field\n", "Field")).toBe(""); + }); +}); + +// --------------------------------------------------------------------------- +// parseIssueBody — issues event +// --------------------------------------------------------------------------- +describe("parseIssueBody (issues event)", () => { + const fullBody = [ + "### Source Repository", + "https://github.com/org/repo", + "", + "### Template Title", + "My Template", + "", + "### Description", + "A great template", + "", + "### Author", + "Jane Doe", + "", + "### Author URL", + "https://github.com/janedoe", + "", + "### Author Type", + "Community", + "", + "### Preview Image URL", + "_No response_", + "", + "### IaC Provider", + "Bicep", + "", + "### Languages", + "Python, JavaScript", + "", + "### Frameworks", + "_No response_", + "", + "### Azure Services", + "App Service, Cosmos DB", + ].join("\n"); + + test("parses all fields from a complete issue body", () => { + const { fields, error } = parseIssueBody({ + eventName: "issues", + issueBody: fullBody, + }); + expect(error).toBeUndefined(); + expect(fields.source_repo).toBe("https://github.com/org/repo"); + expect(fields.template_title).toBe("My Template"); + expect(fields.description).toBe("A great template"); + expect(fields.author).toBe("Jane Doe"); + expect(fields.author_url).toBe("https://github.com/janedoe"); + expect(fields.author_type).toBe("Community"); + expect(fields.preview_image).toBe(""); + expect(fields.iac_provider).toBe("Bicep"); + expect(fields.languages).toBe("Python, JavaScript"); + expect(fields.frameworks).toBe(""); + expect(fields.azure_services).toBe("App Service, Cosmos DB"); + }); + + test("succeeds when only source_repo is provided (other fields optional)", () => { + const partialBody = [ + "### Source Repository", + "https://github.com/org/repo", + "", + "### Template Title", + "My Template", + ].join("\n"); + + const { fields, error } = parseIssueBody({ + eventName: "issues", + issueBody: partialBody, + }); + expect(error).toBeUndefined(); + expect(fields.source_repo).toBe("https://github.com/org/repo"); + expect(fields.template_title).toBe("My Template"); + // Optional fields that were not in the body are empty strings + expect(fields.description).toBe(""); + expect(fields.author).toBe(""); + expect(fields.author_url).toBe(""); + }); + + test("returns error when body is empty (source_repo missing)", () => { + const { error } = parseIssueBody({ + eventName: "issues", + issueBody: "", + }); + expect(error).toMatch(/Missing required fields/); + expect(error).toMatch(/source_repo/); + }); + + test("treats _No response_ in required fields as missing", () => { + const body = [ + "### Source Repository", + "_No response_", + "", + "### Template Title", + "My Template", + "", + "### Description", + "A description", + "", + "### Author", + "Jane", + "", + "### Author URL", + "https://github.com/jane", + ].join("\n"); + + const { error } = parseIssueBody({ + eventName: "issues", + issueBody: body, + }); + expect(error).toMatch(/source_repo/); + }); + + test("handles undefined issueBody", () => { + const { error } = parseIssueBody({ + eventName: "issues", + issueBody: undefined, + }); + expect(error).toMatch(/Missing required fields/); + }); +}); + +// --------------------------------------------------------------------------- +// parseIssueBody — workflow_dispatch event +// --------------------------------------------------------------------------- +describe("parseIssueBody (workflow_dispatch)", () => { + test("reads fields from inputs object", () => { + const { fields, error } = parseIssueBody({ + eventName: "workflow_dispatch", + inputs: { + source_repo: "https://github.com/org/repo", + template_title: "My Template", + description: "A great template", + author: "Jane Doe", + author_url: "https://github.com/janedoe", + author_type: "Microsoft", + preview_image: "", + iac_provider: "Terraform", + languages: "Go", + frameworks: "", + azure_services: "", + }, + }); + expect(error).toBeUndefined(); + expect(fields.source_repo).toBe("https://github.com/org/repo"); + expect(fields.template_title).toBe("My Template"); + expect(fields.author_type).toBe("Microsoft"); + expect(fields.iac_provider).toBe("Terraform"); + expect(fields.languages).toBe("Go"); + expect(fields.preview_image).toBe(""); + }); + + test("returns error when source_repo is empty", () => { + const { error } = parseIssueBody({ + eventName: "workflow_dispatch", + inputs: { + source_repo: "", + template_title: "Title", + description: "Desc", + author: "Auth", + author_url: "", + }, + }); + expect(error).toMatch(/source_repo/); + // author_url is no longer required — should not appear in error + expect(error).not.toMatch(/author_url/); + }); + + test("handles undefined inputs gracefully", () => { + const { error } = parseIssueBody({ + eventName: "workflow_dispatch", + inputs: {}, + }); + expect(error).toMatch(/Missing required fields/); + expect(error).toMatch(/source_repo/); + }); + + test("trims whitespace from input values", () => { + const { fields } = parseIssueBody({ + eventName: "workflow_dispatch", + inputs: { + source_repo: " https://github.com/org/repo ", + template_title: " My Template ", + description: " Desc ", + author: " Auth ", + author_url: " https://github.com/auth ", + author_type: "Community", + preview_image: "", + iac_provider: "Bicep", + languages: "", + frameworks: "", + azure_services: "", + }, + }); + expect(fields.source_repo).toBe("https://github.com/org/repo"); + expect(fields.template_title).toBe("My Template"); + }); +}); + +// --------------------------------------------------------------------------- +// parseIssueBody — new heading format with "(optional ...)" suffixes +// --------------------------------------------------------------------------- +describe("parseIssueBody (auto-detect heading format)", () => { + const autoDetectBody = [ + "### Source Repository", + "https://github.com/org/repo", + "", + "### Template Title (optional \u2014 auto-detected from repo)", + "My Custom Title", + "", + "### Description (optional \u2014 auto-detected from repo)", + "A great template", + "", + "### Author (optional \u2014 auto-detected from repo owner)", + "Jane Doe", + "", + "### Author URL (optional \u2014 auto-detected from repo owner)", + "https://github.com/janedoe", + "", + "### Author Type (optional \u2014 auto-detected)", + "Community", + "", + "### Preview Image URL (optional)", + "_No response_", + "", + "### IaC Provider (optional \u2014 auto-detected from infra/ directory)", + "Bicep", + "", + "### Languages (optional \u2014 auto-detected from repo)", + "Python, JavaScript", + "", + "### Frameworks (optional)", + "_No response_", + "", + "### Azure Services (optional \u2014 auto-detected from repo topics)", + "aca, openai", + ].join("\n"); + + test("parses all fields from auto-detect heading format", () => { + const { fields, error } = parseIssueBody({ + eventName: "issues", + issueBody: autoDetectBody, + }); + expect(error).toBeUndefined(); + expect(fields.source_repo).toBe("https://github.com/org/repo"); + expect(fields.template_title).toBe("My Custom Title"); + expect(fields.description).toBe("A great template"); + expect(fields.author).toBe("Jane Doe"); + expect(fields.author_url).toBe("https://github.com/janedoe"); + expect(fields.author_type).toBe("Community"); + expect(fields.preview_image).toBe(""); + expect(fields.iac_provider).toBe("Bicep"); + expect(fields.languages).toBe("Python, JavaScript"); + expect(fields.frameworks).toBe(""); + expect(fields.azure_services).toBe("aca, openai"); + }); + + test("empty optional fields are returned as empty strings", () => { + const minimalBody = [ + "### Source Repository", + "https://github.com/org/repo", + ].join("\n"); + + const { fields, error } = parseIssueBody({ + eventName: "issues", + issueBody: minimalBody, + }); + expect(error).toBeUndefined(); + expect(fields.source_repo).toBe("https://github.com/org/repo"); + expect(fields.template_title).toBe(""); + expect(fields.description).toBe(""); + expect(fields.author).toBe(""); + expect(fields.author_url).toBe(""); + expect(fields.author_type).toBe(""); + expect(fields.preview_image).toBe(""); + expect(fields.iac_provider).toBe(""); + expect(fields.languages).toBe(""); + expect(fields.frameworks).toBe(""); + expect(fields.azure_services).toBe(""); + }); +}); diff --git a/website/test/update-templates-json.test.ts b/website/test/update-templates-json.test.ts new file mode 100644 index 000000000..9b2f813f9 --- /dev/null +++ b/website/test/update-templates-json.test.ts @@ -0,0 +1,300 @@ +import { describe, expect, test, beforeEach, afterEach } from "@jest/globals"; +import * as fs from "fs"; +import * as path from "path"; +import * as os from "os"; + +const { + sanitize, + parseTags, + updateTemplatesJson, +} = require("../scripts/update-templates-json"); + +// --------------------------------------------------------------------------- +// sanitize +// --------------------------------------------------------------------------- +describe("sanitize", () => { + test("strips HTML tags", () => { + expect(sanitize("bold text", 200)).toBe("bold text"); + }); + + test("strips unclosed tags", () => { + expect(sanitize("before { + // The tag regex matches `< c` as an unclosed tag, leaving `a > b ` + // Then the angle-bracket pass strips the `>`, producing `a b` + expect(sanitize("a > b < c", 200)).toBe("a b"); + }); + + test("truncates to maxLength", () => { + expect(sanitize("abcdefghij", 5)).toBe("abcde"); + }); + + test("trims whitespace", () => { + expect(sanitize(" hello world ", 200)).toBe("hello world"); + }); + + test("handles empty string", () => { + expect(sanitize("", 200)).toBe(""); + }); + + test("handles nested tags", () => { + expect(sanitize("

text

", 200)).toBe("text"); + }); + + test("strips self-closing tags", () => { + expect(sanitize("before
after", 200)).toBe("beforeafter"); + }); +}); + +// --------------------------------------------------------------------------- +// parseTags +// --------------------------------------------------------------------------- +describe("parseTags", () => { + test("parses comma-separated values", () => { + expect(parseTags("Python, JavaScript, Go")).toEqual([ + "Python", + "JavaScript", + "Go", + ]); + }); + + test("returns empty array for _No response_", () => { + expect(parseTags("_No response_")).toEqual([]); + }); + + test("returns empty array for empty string", () => { + expect(parseTags("")).toEqual([]); + }); + + test("returns empty array for null/undefined", () => { + expect(parseTags(null)).toEqual([]); + expect(parseTags(undefined)).toEqual([]); + }); + + test("strips disallowed characters", () => { + expect(parseTags("Node.js, C++, C#")).toEqual(["Node.js", "C++", "C"]); + }); + + test("filters out empty tags after cleaning", () => { + expect(parseTags("Python, , , Go")).toEqual(["Python", "Go"]); + }); + + test("truncates individual tags to 50 chars", () => { + const longTag = "a".repeat(60); + const result = parseTags(longTag); + expect(result[0].length).toBe(50); + }); + + test("limits to 20 tags", () => { + const csv = Array.from({ length: 25 }, (_, i) => `tag${i}`).join(", "); + expect(parseTags(csv).length).toBe(20); + }); + + test("trims whitespace on each tag", () => { + expect(parseTags(" Python , Go ")).toEqual(["Python", "Go"]); + }); + + test("allows dots, dashes, plus signs, and spaces", () => { + expect(parseTags("ASP.NET, Vue.js, C++, Azure App Service")).toEqual([ + "ASP.NET", + "Vue.js", + "C++", + "Azure App Service", + ]); + }); +}); + +// --------------------------------------------------------------------------- +// updateTemplatesJson +// --------------------------------------------------------------------------- +describe("updateTemplatesJson", () => { + let tmpDir: string; + let templatesPath: string; + + const baseTemplates = [ + { + title: "Existing Template", + description: "Already there", + preview: "img.png", + authorUrl: "https://github.com/existing", + author: "Existing Author", + source: "https://github.com/org/existing-repo", + tags: ["community"], + IaC: ["bicep"], + id: "existing-id", + }, + ]; + + function writeTemplates(data: any[]) { + fs.writeFileSync(templatesPath, JSON.stringify(data, null, 2) + "\n"); + } + + function readTemplates() { + return JSON.parse(fs.readFileSync(templatesPath, "utf8")); + } + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "tpl-test-")); + templatesPath = path.join(tmpDir, "templates.json"); + writeTemplates(baseTemplates); + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + const defaultOpts = () => ({ + sourceRepo: "https://github.com/org/new-repo", + title: "New Template", + description: "A brand new template", + author: "Jane Doe", + authorUrl: "https://github.com/janedoe", + authorType: "Community", + previewImage: "", + iacProvider: "Bicep", + languages: [] as string[], + frameworks: [] as string[], + azureServices: [] as string[], + templatesPath, + uuidGenerator: () => "test-uuid-1234", + }); + + test("adds a new template to the JSON file", () => { + const result = updateTemplatesJson(defaultOpts()); + expect(result.skipped).toBe(false); + expect(result.added).toBe("New Template"); + + const templates = readTemplates(); + expect(templates.length).toBe(2); + expect(templates[1].title).toBe("New Template"); + expect(templates[1].id).toBe("test-uuid-1234"); + }); + + test("detects duplicate by canonical URL match", () => { + const opts = defaultOpts(); + opts.sourceRepo = "https://github.com/org/existing-repo.git"; + const result = updateTemplatesJson(opts); + expect(result.skipped).toBe(true); + expect(result.skipReason).toMatch(/already exists/); + expect(result.skipReason).toMatch(/Existing Template/); + }); + + test("detects duplicate with trailing slash", () => { + const opts = defaultOpts(); + opts.sourceRepo = "https://github.com/org/existing-repo/"; + const result = updateTemplatesJson(opts); + expect(result.skipped).toBe(true); + }); + + test("detects duplicate case-insensitively", () => { + const opts = defaultOpts(); + opts.sourceRepo = "https://GitHub.com/Org/Existing-Repo"; + const result = updateTemplatesJson(opts); + expect(result.skipped).toBe(true); + }); + + test("maps iacProvider 'Bicep' to ['bicep']", () => { + const opts = defaultOpts(); + opts.iacProvider = "Bicep"; + updateTemplatesJson(opts); + const templates = readTemplates(); + expect(templates[1].IaC).toEqual(["bicep"]); + }); + + test("maps iacProvider 'Terraform' to ['terraform']", () => { + const opts = defaultOpts(); + opts.iacProvider = "Terraform"; + updateTemplatesJson(opts); + const templates = readTemplates(); + expect(templates[1].IaC).toEqual(["terraform"]); + }); + + test("maps iacProvider 'Both' to ['bicep', 'terraform']", () => { + const opts = defaultOpts(); + opts.iacProvider = "Both"; + updateTemplatesJson(opts); + const templates = readTemplates(); + expect(templates[1].IaC).toEqual(["bicep", "terraform"]); + }); + + test("sets Microsoft author tags", () => { + const opts = defaultOpts(); + opts.authorType = "Microsoft"; + updateTemplatesJson(opts); + const templates = readTemplates(); + expect(templates[1].tags).toEqual(["msft", "new"]); + }); + + test("sets Community author tags", () => { + updateTemplatesJson(defaultOpts()); + const templates = readTemplates(); + expect(templates[1].tags).toEqual(["community", "new"]); + }); + + test("uses default preview image when none provided", () => { + updateTemplatesJson(defaultOpts()); + const templates = readTemplates(); + expect(templates[1].preview).toBe( + "templates/images/default-template.png" + ); + }); + + test("uses provided preview image", () => { + const opts = defaultOpts(); + opts.previewImage = "https://example.com/img.png"; + updateTemplatesJson(opts); + const templates = readTemplates(); + expect(templates[1].preview).toBe("https://example.com/img.png"); + }); + + test("includes optional tag arrays only when non-empty", () => { + const opts = defaultOpts(); + opts.languages = ["Python"]; + opts.frameworks = []; + opts.azureServices = ["App Service", "Cosmos DB"]; + updateTemplatesJson(opts); + const templates = readTemplates(); + const entry = templates[1]; + expect(entry.languages).toEqual(["Python"]); + expect(entry.frameworks).toBeUndefined(); + expect(entry.azureServices).toEqual(["App Service", "Cosmos DB"]); + }); + + test("throws on invalid source repo URL", () => { + const opts = defaultOpts(); + opts.sourceRepo = "not-a-url"; + expect(() => updateTemplatesJson(opts)).toThrow(/Invalid/); + }); + + test("throws on HTTP source repo URL", () => { + const opts = defaultOpts(); + opts.sourceRepo = "http://github.com/org/repo"; + expect(() => updateTemplatesJson(opts)).toThrow(/HTTPS/); + }); + + test("throws on private IP in source repo URL", () => { + const opts = defaultOpts(); + opts.sourceRepo = "https://10.0.0.1/org/repo"; + expect(() => updateTemplatesJson(opts)).toThrow(/private/); + }); + + test("throws on invalid author URL", () => { + const opts = defaultOpts(); + opts.authorUrl = "ftp://example.com"; + expect(() => updateTemplatesJson(opts)).toThrow(); + }); + + test("throws on private IP in preview image URL", () => { + const opts = defaultOpts(); + opts.previewImage = "https://192.168.1.1/img.png"; + expect(() => updateTemplatesJson(opts)).toThrow(/private/); + }); + + test("does not modify existing entries", () => { + updateTemplatesJson(defaultOpts()); + const templates = readTemplates(); + expect(templates[0]).toEqual(baseTemplates[0]); + }); +}); diff --git a/website/test/validate-template.test.ts b/website/test/validate-template.test.ts new file mode 100644 index 000000000..bf9041287 --- /dev/null +++ b/website/test/validate-template.test.ts @@ -0,0 +1,373 @@ +import { describe, expect, test, jest, beforeEach, afterEach } from '@jest/globals'; + +const https = require('https'); +const dns = require('dns'); +const { validateTemplate, canonicalizeUrl, validateUrl, isPrivateIP, isPrivateHost, safeLookup } = require('../scripts/validate-template'); + +let requestSpy: ReturnType; + +function mockRequestResponse(statusCode: number) { + const req = { + on: jest.fn().mockReturnThis() as any, + end: jest.fn() as any, + destroy: jest.fn() as any, + }; + requestSpy = jest.spyOn(https, 'request').mockImplementation((_opts: any, callback: any) => { + process.nextTick(() => callback({ statusCode })); + return req; + }); + return req; +} + +function mockRequestError(errorMessage: string) { + const req = { + on: jest.fn().mockImplementation((event: string, cb: any) => { + if (event === 'error') { + process.nextTick(() => cb(new Error(errorMessage))); + } + return req; + }) as any, + end: jest.fn() as any, + destroy: jest.fn() as any, + }; + requestSpy = jest.spyOn(https, 'request').mockImplementation(() => req); + return req; +} + +describe('canonicalizeUrl', () => { + test('lowercases the URL', () => { + expect(canonicalizeUrl('HTTPS://GitHub.com/Org/Repo')).toBe('https://github.com/org/repo'); + }); + + test('strips trailing slash', () => { + expect(canonicalizeUrl('https://github.com/org/repo/')).toBe('https://github.com/org/repo'); + }); + + test('strips trailing .git', () => { + expect(canonicalizeUrl('https://github.com/org/repo.git')).toBe('https://github.com/org/repo'); + }); + + test('strips both trailing slash and .git', () => { + expect(canonicalizeUrl('https://github.com/org/repo.git/')).toBe('https://github.com/org/repo'); + }); + + test('trims whitespace', () => { + expect(canonicalizeUrl(' https://github.com/org/repo ')).toBe('https://github.com/org/repo'); + }); + + test('handles already canonical URL', () => { + expect(canonicalizeUrl('https://github.com/org/repo')).toBe('https://github.com/org/repo'); + }); +}); + +describe('validateUrl', () => { + test('accepts valid HTTPS URL', () => { + expect(() => validateUrl('https://github.com/org/repo', 'test')).not.toThrow(); + }); + + test('rejects HTTP URL', () => { + expect(() => validateUrl('http://github.com/org/repo', 'test')).toThrow('HTTPS'); + }); + + test('rejects invalid URL string', () => { + expect(() => validateUrl('not-a-url', 'test')).toThrow('Invalid'); + }); + + test('allows empty/undefined value', () => { + expect(() => validateUrl('', 'test')).not.toThrow(); + expect(() => validateUrl(undefined, 'test')).not.toThrow(); + }); + + test('rejects 127.0.0.1 (loopback)', () => { + expect(() => validateUrl('https://127.0.0.1/repo', 'test')).toThrow('private'); + }); + + test('rejects 10.x private range', () => { + expect(() => validateUrl('https://10.0.0.1/repo', 'test')).toThrow('private'); + }); + + test('rejects 192.168.x private range', () => { + expect(() => validateUrl('https://192.168.1.1/repo', 'test')).toThrow('private'); + }); + + test('rejects 172.16.x private range', () => { + expect(() => validateUrl('https://172.16.0.1/repo', 'test')).toThrow('private'); + }); + + test('rejects localhost', () => { + expect(() => validateUrl('https://localhost/repo', 'test')).toThrow('private'); + }); + + test('rejects [::1] IPv6 loopback', () => { + expect(() => validateUrl('https://[::1]/repo', 'test')).toThrow('private'); + }); + + test('rejects 169.254.x link-local', () => { + expect(() => validateUrl('https://169.254.1.1/repo', 'test')).toThrow('private'); + }); + + test('rejects IPv4-mapped IPv6 loopback [::ffff:7f00:1]', () => { + expect(() => validateUrl('https://[::ffff:7f00:1]/repo', 'test')).toThrow('private'); + }); + + test('rejects IPv4-mapped IPv6 private [::ffff:a00:1]', () => { + expect(() => validateUrl('https://[::ffff:a00:1]/repo', 'test')).toThrow('private'); + }); + + test('rejects IPv4-mapped IPv6 192.168 [::ffff:c0a8:101]', () => { + expect(() => validateUrl('https://[::ffff:c0a8:101]/repo', 'test')).toThrow('private'); + }); + + test('rejects IPv6 unique local [fc00::1]', () => { + expect(() => validateUrl('https://[fc00::1]/repo', 'test')).toThrow('private'); + }); + + test('rejects IPv6 unique local [fd12::1]', () => { + expect(() => validateUrl('https://[fd12::1]/repo', 'test')).toThrow('private'); + }); + + test('rejects IPv6 link-local [fe80::1]', () => { + expect(() => validateUrl('https://[fe80::1]/repo', 'test')).toThrow('private'); + }); + + test('rejects IPv6 unspecified [::]', () => { + expect(() => validateUrl('https://[::]/repo', 'test')).toThrow('private'); + }); +}); + +describe('validateTemplate', () => { + afterEach(() => { + if (requestSpy) requestSpy.mockRestore(); + }); + + test('returns error for empty URL', async () => { + const result = await validateTemplate(''); + expect(result.valid).toBe(false); + expect(result.errors).toContain('Repository URL is required'); + }); + + test('returns error for undefined URL', async () => { + const result = await validateTemplate(undefined); + expect(result.valid).toBe(false); + }); + + test('returns error for HTTP URL', async () => { + const result = await validateTemplate('http://github.com/org/repo'); + expect(result.valid).toBe(false); + expect(result.errors[0]).toMatch(/HTTPS/); + }); + + test('returns error for private IP', async () => { + const result = await validateTemplate('https://10.0.0.1/org/repo'); + expect(result.valid).toBe(false); + expect(result.errors[0]).toMatch(/private/); + }); + + test('returns error for non-URL string', async () => { + const result = await validateTemplate('not a url'); + expect(result.valid).toBe(false); + }); + + test('returns valid for reachable HTTPS repo', async () => { + mockRequestResponse(200); + const result = await validateTemplate('https://github.com/Azure-Samples/azd-starter-bicep'); + expect(result.valid).toBe(true); + }); + + test('returns error for 404 response', async () => { + mockRequestResponse(404); + const result = await validateTemplate('https://github.com/org/nonexistent'); + expect(result.valid).toBe(false); + expect(result.errors[0]).toMatch(/404/); + }); + + test('returns error on network failure', async () => { + mockRequestError('ECONNREFUSED'); + const result = await validateTemplate('https://github.com/org/repo'); + expect(result.valid).toBe(false); + expect(result.errors[0]).toMatch(/ECONNREFUSED/); + }); + + test('rejects 301 redirect as invalid', async () => { + mockRequestResponse(301); + const result = await validateTemplate('https://github.com/org/redirect-repo'); + expect(result.valid).toBe(false); + expect(result.errors[0]).toMatch(/301/); + }); + + test('rejects 302 redirect as invalid', async () => { + mockRequestResponse(302); + const result = await validateTemplate('https://github.com/org/moved-repo'); + expect(result.valid).toBe(false); + expect(result.errors[0]).toMatch(/302/); + }); +}); + +describe('isPrivateIP', () => { + test('detects IPv4 loopback 127.0.0.1', () => { + expect(isPrivateIP('127.0.0.1')).toBe(true); + }); + + test('detects IPv4 10.x range', () => { + expect(isPrivateIP('10.0.0.1')).toBe(true); + }); + + test('detects IPv4 172.16.x range', () => { + expect(isPrivateIP('172.16.0.1')).toBe(true); + }); + + test('detects IPv4 192.168.x range', () => { + expect(isPrivateIP('192.168.1.1')).toBe(true); + }); + + test('detects IPv4 link-local 169.254.x', () => { + expect(isPrivateIP('169.254.1.1')).toBe(true); + }); + + test('allows public IPv4', () => { + expect(isPrivateIP('140.82.121.4')).toBe(false); + }); + + test('allows another public IPv4', () => { + expect(isPrivateIP('8.8.8.8')).toBe(false); + }); + + test('detects IPv6 loopback ::1', () => { + expect(isPrivateIP('::1')).toBe(true); + }); + + test('detects IPv6 unspecified ::', () => { + expect(isPrivateIP('::')).toBe(true); + }); + + test('detects IPv6 link-local fe80::1', () => { + expect(isPrivateIP('fe80::1')).toBe(true); + }); + + test('detects IPv6 unique local fc00::1', () => { + expect(isPrivateIP('fc00::1')).toBe(true); + }); + + test('detects IPv6 unique local fd12::1', () => { + expect(isPrivateIP('fd12::1')).toBe(true); + }); + + test('detects IPv4-mapped IPv6 loopback ::ffff:127.0.0.1', () => { + expect(isPrivateIP('::ffff:127.0.0.1')).toBe(true); + }); + + test('detects IPv4-mapped IPv6 hex form ::ffff:7f00:1', () => { + expect(isPrivateIP('::ffff:7f00:1')).toBe(true); + }); + + test('detects IPv4-mapped IPv6 10.x hex form ::ffff:a00:1', () => { + expect(isPrivateIP('::ffff:a00:1')).toBe(true); + }); + + test('detects IPv4-mapped IPv6 192.168 hex form ::ffff:c0a8:101', () => { + expect(isPrivateIP('::ffff:c0a8:101')).toBe(true); + }); + + test('allows public IPv6', () => { + expect(isPrivateIP('2607:f8b0:4004:800::200e')).toBe(false); + }); + + test('denies null/undefined', () => { + expect(isPrivateIP(null as any)).toBe(true); + expect(isPrivateIP(undefined as any)).toBe(true); + expect(isPrivateIP('')).toBe(true); + }); +}); + +describe('safeLookup', () => { + let lookupSpy: ReturnType; + + afterEach(() => { + if (lookupSpy) lookupSpy.mockRestore(); + }); + + test('rejects hostname resolving to private IPv4', (done) => { + lookupSpy = jest.spyOn(dns, 'lookup').mockImplementation( + (_hostname: string, _options: any, callback: any) => { + callback(null, '10.0.0.1', 4); + } + ); + safeLookup('evil.com', {}, (err: any) => { + expect(err).toBeTruthy(); + expect(err.message).toMatch(/private\/reserved/); + done(); + }); + }); + + test('rejects hostname resolving to loopback', (done) => { + lookupSpy = jest.spyOn(dns, 'lookup').mockImplementation( + (_hostname: string, _options: any, callback: any) => { + callback(null, '127.0.0.1', 4); + } + ); + safeLookup('evil.com', {}, (err: any) => { + expect(err).toBeTruthy(); + expect(err.message).toMatch(/private\/reserved/); + done(); + }); + }); + + test('rejects hostname resolving to IPv6 loopback', (done) => { + lookupSpy = jest.spyOn(dns, 'lookup').mockImplementation( + (_hostname: string, _options: any, callback: any) => { + callback(null, '::1', 6); + } + ); + safeLookup('evil.com', {}, (err: any) => { + expect(err).toBeTruthy(); + expect(err.message).toMatch(/private\/reserved/); + done(); + }); + }); + + test('allows hostname resolving to public IP', (done) => { + lookupSpy = jest.spyOn(dns, 'lookup').mockImplementation( + (_hostname: string, _options: any, callback: any) => { + callback(null, '140.82.121.4', 4); + } + ); + safeLookup('github.com', {}, (err: any, address: string) => { + expect(err).toBeNull(); + expect(address).toBe('140.82.121.4'); + done(); + }); + }); + + test('propagates DNS lookup errors', (done) => { + lookupSpy = jest.spyOn(dns, 'lookup').mockImplementation( + (_hostname: string, _options: any, callback: any) => { + callback(new Error('ENOTFOUND')); + } + ); + safeLookup('nonexistent.invalid', {}, (err: any) => { + expect(err).toBeTruthy(); + expect(err.message).toBe('ENOTFOUND'); + done(); + }); + }); +}); + +describe('canonicalizeUrl - security', () => { + test('strips query string to prevent duplicate bypass', () => { + expect(canonicalizeUrl('https://github.com/org/repo?ref=main')).toBe( + 'https://github.com/org/repo' + ); + }); + + test('strips fragment to prevent duplicate bypass', () => { + expect(canonicalizeUrl('https://github.com/org/repo#readme')).toBe( + 'https://github.com/org/repo' + ); + }); + + test('strips both query and fragment', () => { + expect(canonicalizeUrl('https://github.com/org/repo?ref=main#section')).toBe( + 'https://github.com/org/repo' + ); + }); +});