Add OAuth2 config flow scaffold (#28220)

* Add OAuth2 scaffold

* Generate integration if non-existing domain specified

* Update URL
This commit is contained in:
Paulus Schoutsen 2019-10-29 20:34:03 -07:00 committed by GitHub
parent e700384cce
commit 24c29f9227
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 567 additions and 209 deletions

View File

@ -40,6 +40,8 @@ CONF_BELOW = "below"
CONF_BINARY_SENSORS = "binary_sensors"
CONF_BLACKLIST = "blacklist"
CONF_BRIGHTNESS = "brightness"
CONF_CLIENT_ID = "client_id"
CONF_CLIENT_SECRET = "client_secret"
CONF_CODE = "code"
CONF_COLOR_TEMP = "color_temp"
CONF_COMMAND = "command"

View File

@ -48,34 +48,46 @@ def main():
args = get_arguments()
info = gather_info.gather_info(args)
print()
generate.generate(args.template, info)
# If we are calling scaffold on a non-existing integration,
# We're going to first make it. If we're making an integration,
# we will also make a config flow to go with it.
# If creating new integration, create config flow too
if args.template == "integration":
if info.authentication or not info.discoverable:
template = "config_flow"
else:
template = "config_flow_discovery"
if info.is_new:
generate.generate("integration", info)
generate.generate(template, info)
# If it's a new integration and it's not a config flow,
# create a config flow too.
if not args.template.startswith("config_flow"):
if info.oauth2:
template = "config_flow_oauth2"
elif info.authentication or not info.discoverable:
template = "config_flow"
else:
template = "config_flow_discovery"
generate.generate(template, info)
# If we wanted a new integration, we've already done our work.
if args.template != "integration":
generate.generate(args.template, info)
pipe_null = "" if args.develop else "> /dev/null"
print("Running hassfest to pick up new information.")
subprocess.run("python -m script.hassfest", shell=True)
subprocess.run(f"python -m script.hassfest {pipe_null}", shell=True)
print()
print("Running tests")
print(f"$ pytest -vvv tests/components/{info.domain}")
if (
subprocess.run(
f"pytest -vvv tests/components/{info.domain}", shell=True
).returncode
!= 0
):
return 1
print("Running gen_requirements_all to pick up new information.")
subprocess.run(f"python -m script.gen_requirements_all {pipe_null}", shell=True)
print()
print(f"Done!")
if args.develop:
print("Running tests")
print(f"$ pytest -vvv tests/components/{info.domain}")
subprocess.run(f"pytest -vvv tests/components/{info.domain}", shell=True)
print()
docs.print_relevant_docs(args.template, info)

View File

@ -2,72 +2,76 @@
from .model import Info
DATA = {
"config_flow": {
"title": "Config Flow",
"docs": "https://developers.home-assistant.io/docs/en/config_entries_config_flow_handler.html",
},
"config_flow_discovery": {
"title": "Discoverable Config Flow",
"docs": "https://developers.home-assistant.io/docs/en/config_entries_config_flow_handler.html#discoverable-integrations-that-require-no-authentication",
},
"config_flow_oauth2": {
"title": "OAuth2 Config Flow",
"docs": "https://developers.home-assistant.io/docs/en/next/config_entries_config_flow_handler.html#configuration-via-oauth2",
},
"device_action": {
"title": "Device Action",
"docs": "https://developers.home-assistant.io/docs/en/device_automation_action.html",
},
"device_condition": {
"title": "Device Condition",
"docs": "https://developers.home-assistant.io/docs/en/device_automation_condition.html",
},
"device_trigger": {
"title": "Device Trigger",
"docs": "https://developers.home-assistant.io/docs/en/device_automation_trigger.html",
},
"integration": {
"title": "Integration",
"docs": "https://developers.home-assistant.io/docs/en/creating_integration_file_structure.html",
},
"reproduce_state": {
"title": "Reproduce State",
"docs": "https://developers.home-assistant.io/docs/en/reproduce_state_index.html",
"extra": "You will now need to update the code to make sure that every attribute that can occur in the state will cause the right service to be called.",
},
}
def print_relevant_docs(template: str, info: Info) -> None:
"""Print relevant docs."""
if template == "integration":
data = DATA[template]
print()
print("**************************")
print()
print()
print(f"{data['title']} code has been generated")
print()
if info.files_added:
print("Added the following files:")
for file in info.files_added:
print(f"- {file}")
print()
if info.tests_added:
print("Added the following tests:")
for file in info.tests_added:
print(f"- {file}")
print()
if info.examples_added:
print(
f"""
Your integration has been created at {info.integration_dir} . Next step is to fill in the blanks for the code marked with TODO.
For a breakdown of each file, check the developer documentation at:
https://developers.home-assistant.io/docs/en/creating_integration_file_structure.html
"""
"Because some files already existed, we added the following example files. Please copy the relevant code to the existing files."
)
for file in info.examples_added:
print(f"- {file}")
print()
elif template == "config_flow":
print(
f"""
The config flow has been added to the {info.domain} integration. Next step is to fill in the blanks for the code marked with TODO.
"""
)
print(
f"The next step is to look at the files and deal with all areas marked as TODO."
)
elif template == "reproduce_state":
print(
f"""
Reproduce state code has been added to the {info.domain} integration:
- {info.integration_dir / "reproduce_state.py"}
- {info.tests_dir / "test_reproduce_state.py"}
You will now need to update the code to make sure that every attribute
that can occur in the state will cause the right service to be called.
"""
)
elif template == "device_trigger":
print(
f"""
Device trigger base has been added to the {info.domain} integration:
- {info.integration_dir / "device_trigger.py"}
- {info.integration_dir / "strings.json"} (translations)
- {info.tests_dir / "test_device_trigger.py"}
You will now need to update the code to make sure that relevant triggers
are exposed.
"""
)
elif template == "device_condition":
print(
f"""
Device condition base has been added to the {info.domain} integration:
- {info.integration_dir / "device_condition.py"}
- {info.integration_dir / "strings.json"} (translations)
- {info.tests_dir / "test_device_condition.py"}
You will now need to update the code to make sure that relevant condtions
are exposed.
"""
)
elif template == "device_action":
print(
f"""
Device action base has been added to the {info.domain} integration:
- {info.integration_dir / "device_action.py"}
- {info.integration_dir / "strings.json"} (translations)
- {info.tests_dir / "test_device_action.py"}
You will now need to update the code to make sure that relevant services
are exposed as actions.
"""
)
if "extra" in data:
print(data["extra"])

View File

@ -13,36 +13,14 @@ CHECK_EMPTY = ["Cannot be empty", lambda value: value]
def gather_info(arguments) -> Info:
"""Gather info."""
existing = arguments.template != "integration"
if arguments.develop:
if arguments.integration:
info = {"domain": arguments.integration}
elif arguments.develop:
print("Running in developer mode. Automatically filling in info.")
print()
if existing:
if arguments.develop:
return _load_existing_integration("develop")
if arguments.integration:
return _load_existing_integration(arguments.integration)
return gather_existing_integration()
if arguments.develop:
return Info(
domain="develop",
name="Develop Hub",
codeowner="@developer",
requirement="aiodevelop==1.2.3",
)
return gather_new_integration()
def gather_new_integration() -> Info:
"""Gather info about new integration from user."""
return Info(
**_gather_info(
info = {"domain": "develop"}
else:
info = _gather_info(
{
"domain": {
"prompt": "What is the domain?",
@ -52,84 +30,87 @@ def gather_new_integration() -> Info:
"Domains cannot contain spaces or special characters.",
lambda value: value == slugify(value),
],
[
"There already is an integration with this domain.",
lambda value: not (COMPONENT_DIR / value).exists(),
],
],
},
"name": {
"prompt": "What is the name of your integration?",
"validators": [CHECK_EMPTY],
},
"codeowner": {
"prompt": "What is your GitHub handle?",
"validators": [
CHECK_EMPTY,
[
'GitHub handles need to start with an "@"',
lambda value: value.startswith("@"),
],
],
},
"requirement": {
"prompt": "What PyPI package and version do you depend on? Leave blank for none.",
"validators": [
[
"Versions should be pinned using '=='.",
lambda value: not value or "==" in value,
]
],
},
}
}
)
info["is_new"] = not (COMPONENT_DIR / info["domain"] / "manifest.json").exists()
if not info["is_new"]:
return _load_existing_integration(info["domain"])
if arguments.develop:
info.update(
{
"name": "Develop Hub",
"codeowner": "@developer",
"requirement": "aiodevelop==1.2.3",
"oauth2": True,
}
)
else:
info.update(gather_new_integration(arguments.template == "integration"))
return Info(**info)
YES_NO = {
"validators": [["Type either 'yes' or 'no'", lambda value: value in ("yes", "no")]],
"convertor": lambda value: value == "yes",
}
def gather_new_integration(determine_auth: bool) -> Info:
"""Gather info about new integration from user."""
fields = {
"name": {
"prompt": "What is the name of your integration?",
"validators": [CHECK_EMPTY],
},
"codeowner": {
"prompt": "What is your GitHub handle?",
"validators": [
CHECK_EMPTY,
[
'GitHub handles need to start with an "@"',
lambda value: value.startswith("@"),
],
],
},
"requirement": {
"prompt": "What PyPI package and version do you depend on? Leave blank for none.",
"validators": [
[
"Versions should be pinned using '=='.",
lambda value: not value or "==" in value,
]
],
},
}
if determine_auth:
fields.update(
{
"authentication": {
"prompt": "Does Home Assistant need the user to authenticate to control the device/service? (yes/no)",
"default": "yes",
"validators": [
[
"Type either 'yes' or 'no'",
lambda value: value in ("yes", "no"),
]
],
"convertor": lambda value: value == "yes",
**YES_NO,
},
"discoverable": {
"prompt": "Is the device/service discoverable on the local network? (yes/no)",
"default": "no",
"validators": [
[
"Type either 'yes' or 'no'",
lambda value: value in ("yes", "no"),
]
],
"convertor": lambda value: value == "yes",
**YES_NO,
},
"oauth2": {
"prompt": "Can the user authenticate the device using OAuth2? (yes/no)",
"default": "no",
**YES_NO,
},
}
)
)
def gather_existing_integration() -> Info:
"""Gather info about existing integration from user."""
answers = _gather_info(
{
"domain": {
"prompt": "What is the domain?",
"validators": [
CHECK_EMPTY,
[
"Domains cannot contain spaces or special characters.",
lambda value: value == slugify(value),
],
[
"This integration does not exist.",
lambda value: (COMPONENT_DIR / value).exists(),
],
],
}
}
)
return _load_existing_integration(answers["domain"])
return _gather_info(fields)
def _load_existing_integration(domain) -> Info:
@ -179,5 +160,4 @@ def _gather_info(fields) -> dict:
value = info["convertor"](value)
answers[key] = value
print()
return answers

View File

@ -1,7 +1,6 @@
"""Generate an integration."""
from pathlib import Path
from .error import ExitApp
from .model import Info
TEMPLATE_DIR = Path(__file__).parent / "templates"
@ -11,8 +10,6 @@ TEMPLATE_TESTS = TEMPLATE_DIR / "tests"
def generate(template: str, info: Info) -> None:
"""Generate a template."""
_validate(template, info)
print(f"Scaffolding {template} for the {info.domain} integration...")
_ensure_tests_dir_exists(info)
_generate(TEMPLATE_DIR / template / "integration", info.integration_dir, info)
@ -21,13 +18,6 @@ def generate(template: str, info: Info) -> None:
print()
def _validate(template, info):
"""Validate we can run this task."""
if template == "config_flow":
if (info.integration_dir / "config_flow.py").exists():
raise ExitApp(f"Integration {info.domain} already has a config flow.")
def _generate(src_dir, target_dir, info: Info) -> None:
"""Generate an integration."""
replaces = {"NEW_DOMAIN": info.domain, "NEW_NAME": info.name}
@ -42,6 +32,20 @@ def _generate(src_dir, target_dir, info: Info) -> None:
content = content.replace(to_search, to_replace)
target_file = target_dir / source_file.relative_to(src_dir)
# If the target file exists, create our template as EXAMPLE_<filename>.
# Exception: If we are creating a new integration, we can end up running integration base
# and a config flows on top of one another. In that case, we want to override the files.
if not info.is_new and target_file.exists():
new_name = f"EXAMPLE_{target_file.name}"
print(f"File {target_file} already exists, creating {new_name} instead.")
target_file = target_file.parent / new_name
info.examples_added.add(target_file)
elif src_dir.name == "integration":
info.files_added.add(target_file)
else:
info.tests_added.add(target_file)
print(f"Writing {target_file}")
target_file.write_text(content)
@ -58,6 +62,11 @@ def _ensure_tests_dir_exists(info: Info) -> None:
)
def _append(path: Path, text):
"""Append some text to a path."""
path.write_text(path.read_text() + text)
def _custom_tasks(template, info) -> None:
"""Handle custom tasks for templates."""
if template == "integration":
@ -68,7 +77,7 @@ def _custom_tasks(template, info) -> None:
info.update_manifest(**changes)
if template == "device_trigger":
elif template == "device_trigger":
info.update_strings(
device_automation={
**info.strings().get("device_automation", {}),
@ -79,7 +88,7 @@ def _custom_tasks(template, info) -> None:
}
)
if template == "device_condition":
elif template == "device_condition":
info.update_strings(
device_automation={
**info.strings().get("device_automation", {}),
@ -90,7 +99,7 @@ def _custom_tasks(template, info) -> None:
}
)
if template == "device_action":
elif template == "device_action":
info.update_strings(
device_automation={
**info.strings().get("device_automation", {}),
@ -101,7 +110,7 @@ def _custom_tasks(template, info) -> None:
}
)
if template == "config_flow":
elif template == "config_flow":
info.update_manifest(config_flow=True)
info.update_strings(
config={
@ -118,7 +127,7 @@ def _custom_tasks(template, info) -> None:
}
)
if template == "config_flow_discovery":
elif template == "config_flow_discovery":
info.update_manifest(config_flow=True)
info.update_strings(
config={
@ -136,19 +145,28 @@ def _custom_tasks(template, info) -> None:
}
)
if template in ("config_flow", "config_flow_discovery"):
init_file = info.integration_dir / "__init__.py"
init_file.write_text(
init_file.read_text()
+ """
async def async_setup_entry(hass, entry):
\"\"\"Set up a config entry for NEW_NAME.\"\"\"
# TODO forward the entry for each platform that you want to set up.
# hass.async_create_task(
# hass.config_entries.async_forward_entry_setup(entry, "media_player")
# )
return True
"""
elif template == "config_flow_oauth2":
info.update_manifest(config_flow=True)
info.update_strings(
config={
"title": info.name,
"step": {
"pick_implementation": {"title": "Pick Authentication Method"}
},
"abort": {
"missing_configuration": "The Somfy component is not configured. Please follow the documentation."
},
"create_entry": {
"default": f"Successfully authenticated with {info.name}."
},
}
)
_append(
info.integration_dir / "const.py",
"""
# TODO Update with your own urls
OAUTH2_AUTHORIZE = "https://www.example.com/auth/authorize"
OAUTH2_TOKEN = "https://www.example.com/auth/token"
""",
)

View File

@ -1,6 +1,7 @@
"""Models for scaffolding."""
import json
from pathlib import Path
from typing import Set
import attr
@ -13,10 +14,16 @@ class Info:
domain: str = attr.ib()
name: str = attr.ib()
is_new: bool = attr.ib()
codeowner: str = attr.ib(default=None)
requirement: str = attr.ib(default=None)
authentication: str = attr.ib(default=None)
discoverable: str = attr.ib(default=None)
oauth2: str = attr.ib(default=None)
files_added: Set[Path] = attr.ib(factory=set)
tests_added: Set[Path] = attr.ib(factory=set)
examples_added: Set[Path] = attr.ib(factory=set)
@property
def integration_dir(self) -> Path:

View File

@ -0,0 +1,49 @@
"""The NEW_NAME integration."""
import asyncio
import voluptuous as vol
from homeassistant.core import HomeAssistant
from homeassistant.config_entries import ConfigEntry
from .const import DOMAIN
CONFIG_SCHEMA = vol.Schema({DOMAIN: vol.Schema({})}, extra=vol.ALLOW_EXTRA)
# TODO List the platforms that you want to support.
# For your initial PR, limit it to 1 platform.
PLATFORMS = ["light"]
async def async_setup(hass: HomeAssistant, config: dict):
"""Set up the NEW_NAME component."""
return True
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry):
"""Set up Somfy from a config entry."""
# TODO Store an API object for your platforms to access
# hass.data[DOMAIN][entry.entry_id] = MyApi(…)
for component in PLATFORMS:
hass.async_create_task(
hass.config_entries.async_forward_entry_setup(entry, component)
)
return True
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry):
"""Unload a config entry."""
unload_ok = all(
await asyncio.gather(
*[
hass.config_entries.async_forward_entry_unload(entry, component)
for component in PLATFORMS
]
)
)
if unload_ok:
hass.data[DOMAIN].pop(entry.entry_id)
return unload_ok

View File

@ -0,0 +1,49 @@
"""The NEW_NAME integration."""
import asyncio
import voluptuous as vol
from homeassistant.core import HomeAssistant
from homeassistant.config_entries import ConfigEntry
from .const import DOMAIN
CONFIG_SCHEMA = vol.Schema({DOMAIN: vol.Schema({})}, extra=vol.ALLOW_EXTRA)
# TODO List the platforms that you want to support.
# For your initial PR, limit it to 1 platform.
PLATFORMS = ["light"]
async def async_setup(hass: HomeAssistant, config: dict):
"""Set up the NEW_NAME component."""
return True
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry):
"""Set up Somfy from a config entry."""
# TODO Store an API object for your platforms to access
# hass.data[DOMAIN][entry.entry_id] = MyApi(…)
for component in PLATFORMS:
hass.async_create_task(
hass.config_entries.async_forward_entry_setup(entry, component)
)
return True
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry):
"""Unload a config entry."""
unload_ok = all(
await asyncio.gather(
*[
hass.config_entries.async_forward_entry_unload(entry, component)
for component in PLATFORMS
]
)
)
if unload_ok:
hass.data[DOMAIN].pop(entry.entry_id)
return unload_ok

View File

@ -0,0 +1,94 @@
"""The NEW_NAME integration."""
import asyncio
import voluptuous as vol
from homeassistant.core import HomeAssistant
from homeassistant.const import CONF_CLIENT_ID, CONF_CLIENT_SECRET
from homeassistant.helpers import (
config_validation as cv,
config_entry_oauth2_flow,
aiohttp_client,
)
from homeassistant.config_entries import ConfigEntry
from .const import DOMAIN, OAUTH2_AUTHORIZE, OAUTH2_TOKEN
from . import api, config_flow
CONFIG_SCHEMA = vol.Schema(
{
DOMAIN: vol.Schema(
{
vol.Required(CONF_CLIENT_ID): cv.string,
vol.Required(CONF_CLIENT_SECRET): cv.string,
}
)
},
extra=vol.ALLOW_EXTRA,
)
# TODO List the platforms that you want to support.
# For your initial PR, limit it to 1 platform.
PLATFORMS = ["light"]
async def async_setup(hass: HomeAssistant, config: dict):
"""Set up the NEW_NAME component."""
hass.data[DOMAIN] = {}
if DOMAIN not in config:
return True
config_flow.OAuth2FlowHandler.async_register_implementation(
hass,
config_entry_oauth2_flow.LocalOAuth2Implementation(
hass,
DOMAIN,
config[DOMAIN][CONF_CLIENT_ID],
config[DOMAIN][CONF_CLIENT_SECRET],
OAUTH2_AUTHORIZE,
OAUTH2_TOKEN,
),
)
return True
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry):
"""Set up Somfy from a config entry."""
implementation = await config_entry_oauth2_flow.async_get_config_entry_implementation(
hass, entry
)
session = config_entry_oauth2_flow.OAuth2Session(hass, entry, implementation)
# If using a requests-based API lib
hass.data[DOMAIN][entry.entry_id] = api.ConfigEntryAuth(hass, entry, session)
# If using an aiohttp-based API lib
hass.data[DOMAIN][entry.entry_id] = api.AsyncConfigEntryAuth(
aiohttp_client.async_get_clientsession(hass), session
)
for component in PLATFORMS:
hass.async_create_task(
hass.config_entries.async_forward_entry_setup(entry, component)
)
return True
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry):
"""Unload a config entry."""
unload_ok = all(
await asyncio.gather(
*[
hass.config_entries.async_forward_entry_unload(entry, component)
for component in PLATFORMS
]
)
)
if unload_ok:
hass.data[DOMAIN].pop(entry.entry_id)
return unload_ok

View File

@ -0,0 +1,58 @@
"""API for NEW_NAME bound to HASS OAuth."""
from asyncio import run_coroutine_threadsafe
from aiohttp import ClientSession
import my_pypi_package
from homeassistant import core, config_entries
from homeassistant.helpers import config_entry_oauth2_flow
# TODO the following two API examples are based on our suggested best practices
# for libraries using OAuth2 with requests or aiohttp. Delete the one you won't use.
# For more info see the docs at <insert url>.
class ConfigEntryAuth(my_pypi_package.AbstractAuth):
"""Provide NEW_NAME authentication tied to an OAuth2 based config entry."""
def __init__(
self,
hass: core.HomeAssistant,
config_entry: config_entries.ConfigEntry,
implementation: config_entry_oauth2_flow.AbstractOAuth2Implementation,
):
"""Initialize NEW_NAME Auth."""
self.hass = hass
self.config_entry = config_entry
self.session = config_entry_oauth2_flow.OAuth2Session(
hass, config_entry, implementation
)
super().__init__(self.session.token)
def refresh_tokens(self) -> dict:
"""Refresh and return new NEW_NAME tokens using Home Assistant OAuth2 session."""
run_coroutine_threadsafe(
self.session.async_ensure_token_valid(), self.hass.loop
).result()
return self.session.token
class AsyncConfigEntryAuth(my_pypi_package.AbstractAuth):
"""Provide NEW_NAME authentication tied to an OAuth2 based config entry."""
def __init__(
self,
websession: ClientSession,
oauth_session: config_entry_oauth2_flow.OAuth2Session,
):
"""Initialize NEW_NAME auth."""
super().__init__(websession)
self._oauth_session = oauth_session
async def async_get_access_token(self):
"""Return a valid access token."""
if not self._oauth_session.is_valid:
await self._oauth_session.async_ensure_token_valid()
return self._oauth_session.token

View File

@ -0,0 +1,23 @@
"""Config flow for NEW_NAME."""
import logging
from homeassistant import config_entries
from homeassistant.helpers import config_entry_oauth2_flow
from .const import DOMAIN
_LOGGER = logging.getLogger(__name__)
class OAuth2FlowHandler(
config_entry_oauth2_flow.AbstractOAuth2FlowHandler, domain=DOMAIN
):
"""Config flow to handle NEW_NAME OAuth2 authentication."""
DOMAIN = DOMAIN
# TODO Pick one from config_entries.CONN_CLASS_*
CONNECTION_CLASS = config_entries.CONN_CLASS_UNKNOWN
@property
def logger(self) -> logging.Logger:
"""Return logger."""
return logging.getLogger(__name__)

View File

@ -0,0 +1,60 @@
"""Test the NEW_NAME config flow."""
from homeassistant import config_entries, setup, data_entry_flow
from homeassistant.components.NEW_DOMAIN.const import (
DOMAIN,
OAUTH2_AUTHORIZE,
OAUTH2_TOKEN,
)
from homeassistant.helpers import config_entry_oauth2_flow
CLIENT_ID = "1234"
CLIENT_SECRET = "5678"
async def test_full_flow(hass, aiohttp_client, aioclient_mock):
"""Check full flow."""
assert await setup.async_setup_component(
hass,
"NEW_DOMAIN",
{
"NEW_DOMAIN": {
"type": "oauth2",
"client_id": CLIENT_ID,
"client_secret": CLIENT_SECRET,
},
"http": {"base_url": "https://example.com"},
},
)
result = await hass.config_entries.flow.async_init(
"NEW_DOMAIN", context={"source": config_entries.SOURCE_USER}
)
state = config_entry_oauth2_flow._encode_jwt(hass, {"flow_id": result["flow_id"]})
assert result["type"] == data_entry_flow.RESULT_TYPE_EXTERNAL_STEP
assert result["url"] == (
f"{OAUTH2_AUTHORIZE}?response_type=code&client_id={CLIENT_ID}"
"&redirect_uri=https://example.com/auth/external/callback"
f"&state={state}"
)
client = await aiohttp_client(hass.http.app)
resp = await client.get(f"/auth/external/callback?code=abcd&state={state}")
assert resp.status == 200
assert resp.headers["content-type"] == "text/html; charset=utf-8"
aioclient_mock.post(
OAUTH2_TOKEN,
json={
"refresh_token": "mock-refresh-token",
"access_token": "mock-access-token",
"type": "Bearer",
"expires_in": 60,
},
)
result = await hass.config_entries.flow.async_configure(result["flow_id"])
assert len(hass.config_entries.async_entries(DOMAIN)) == 1
entry = hass.config_entries.async_entries(DOMAIN)[0]
assert entry.data["type"] == "oauth2"

View File

@ -1,12 +1,14 @@
"""The NEW_NAME integration."""
import voluptuous as vol
from homeassistant.core import HomeAssistant
from .const import DOMAIN
CONFIG_SCHEMA = vol.Schema({vol.Optional(DOMAIN): {}})
CONFIG_SCHEMA = vol.Schema({vol.Optional(DOMAIN): {}}, extra=vol.ALLOW_EXTRA)
async def async_setup(hass, config):
async def async_setup(hass: HomeAssistant, config: dict):
"""Set up the NEW_NAME integration."""
return True