diff --git a/docs/webhook_events.md b/docs/webhook_events.md index 1254d4ce6..6e911136e 100644 --- a/docs/webhook_events.md +++ b/docs/webhook_events.md @@ -25,6 +25,7 @@ Each event will be submitted via HTTP POST to the user provided URL. * [crash_reported](#crash_reported) * [file_added](#file_added) +* [instance_config_updated](#instance_config_updated) * [job_created](#job_created) * [job_stopped](#job_stopped) * [node_created](#node_created) @@ -613,6 +614,59 @@ Each event will be submitted via HTTP POST to the user provided URL. } ``` +### instance_config_updated + +#### Example + +```json +{ + "config": { + "admins": [ + "00000000-0000-0000-0000-000000000000" + ], + "allow_pool_management": true + } +} +``` + +#### Schema + +```json +{ + "definitions": { + "InstanceConfig": { + "properties": { + "admins": { + "items": { + "format": "uuid", + "type": "string" + }, + "title": "Admins", + "type": "array" + }, + "allow_pool_management": { + "default": true, + "title": "Allow Pool Management", + "type": "boolean" + } + }, + "title": "InstanceConfig", + "type": "object" + } + }, + "properties": { + "config": { + "$ref": "#/definitions/InstanceConfig" + } + }, + "required": [ + "config" + ], + "title": "EventInstanceConfigUpdated", + "type": "object" +} +``` + ### job_created #### Example @@ -4792,6 +4846,18 @@ Each event will be submitted via HTTP POST to the user provided URL. "title": "EventFileAdded", "type": "object" }, + "EventInstanceConfigUpdated": { + "properties": { + "config": { + "$ref": "#/definitions/InstanceConfig" + } + }, + "required": [ + "config" + ], + "title": "EventInstanceConfigUpdated", + "type": "object" + }, "EventJobCreated": { "properties": { "config": { @@ -5376,10 +5442,30 @@ Each event will be submitted via HTTP POST to the user provided URL. "regression_reported", "file_added", "task_heartbeat", - "node_heartbeat" + "node_heartbeat", + "instance_config_updated" ], "title": "EventType" }, + "InstanceConfig": { + "properties": { + "admins": { + "items": { + "format": "uuid", + "type": "string" + }, + "title": "Admins", + "type": "array" + }, + "allow_pool_management": { + "default": true, + "title": "Allow Pool Management", + "type": "boolean" + } + }, + "title": "InstanceConfig", + "type": "object" + }, "JobConfig": { "properties": { "build": { @@ -6071,6 +6157,9 @@ Each event will be submitted via HTTP POST to the user provided URL. }, { "$ref": "#/definitions/EventFileAdded" + }, + { + "$ref": "#/definitions/EventInstanceConfigUpdated" } ], "title": "Event" diff --git a/src/api-service/__app__/instance_config/__init__.py b/src/api-service/__app__/instance_config/__init__.py new file mode 100644 index 000000000..115052ac1 --- /dev/null +++ b/src/api-service/__app__/instance_config/__init__.py @@ -0,0 +1,48 @@ +#!/usr/bin/env python +# +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +import azure.functions as func +from onefuzztypes.enums import ErrorCode +from onefuzztypes.models import Error +from onefuzztypes.requests import InstanceConfigUpdate + +from ..onefuzzlib.config import InstanceConfig +from ..onefuzzlib.endpoint_authorization import call_if_user, can_modify_config +from ..onefuzzlib.events import get_events +from ..onefuzzlib.request import not_ok, ok, parse_request + + +def get(req: func.HttpRequest) -> func.HttpResponse: + return ok(InstanceConfig.fetch()) + + +def post(req: func.HttpRequest) -> func.HttpResponse: + request = parse_request(InstanceConfigUpdate, req) + if isinstance(request, Error): + return not_ok(request, context="instance_config_update") + + config = InstanceConfig.fetch() + + if not can_modify_config(req, config): + return not_ok( + Error(code=ErrorCode.INVALID_PERMISSION, errors=["unauthorized"]), + context="instance_config_update", + ) + + config.update(request.config) + config.save() + return ok(config) + + +def main(req: func.HttpRequest, dashboard: func.Out[str]) -> func.HttpResponse: + methods = {"GET": get, "POST": post} + method = methods[req.method] + result = call_if_user(req, method) + + events = get_events() + if events: + dashboard.set(events) + + return result diff --git a/src/api-service/__app__/instance_config/function.json b/src/api-service/__app__/instance_config/function.json new file mode 100644 index 000000000..635c311aa --- /dev/null +++ b/src/api-service/__app__/instance_config/function.json @@ -0,0 +1,26 @@ +{ + "scriptFile": "__init__.py", + "bindings": [ + { + "authLevel": "anonymous", + "type": "httpTrigger", + "direction": "in", + "name": "req", + "methods": [ + "get", + "post" + ] + }, + { + "type": "http", + "direction": "out", + "name": "$return" + }, + { + "type": "signalR", + "direction": "out", + "name": "dashboard", + "hubName": "dashboard" + } + ] +} diff --git a/src/api-service/__app__/onefuzzlib/config.py b/src/api-service/__app__/onefuzzlib/config.py new file mode 100644 index 000000000..c3f124d7d --- /dev/null +++ b/src/api-service/__app__/onefuzzlib/config.py @@ -0,0 +1,34 @@ +#!/usr/bin/env python +# +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +from typing import Optional, Tuple + +from onefuzztypes.events import EventInstanceConfigUpdated +from onefuzztypes.models import InstanceConfig as BASE_CONFIG +from pydantic import Field + +from .azure.creds import get_instance_name +from .events import send_event +from .orm import ORMMixin + + +class InstanceConfig(BASE_CONFIG, ORMMixin): + instance_name: str = Field(default_factory=get_instance_name) + + @classmethod + def key_fields(cls) -> Tuple[str, Optional[str]]: + return ("instance_name", None) + + @classmethod + def fetch(cls) -> "InstanceConfig": + entry = cls.get(get_instance_name()) + if entry is None: + entry = cls() + entry.save() + return entry + + def save(self, new: bool = False, require_etag: bool = False) -> None: + super().save(new=new, require_etag=require_etag) + send_event(EventInstanceConfigUpdated(config=self)) diff --git a/src/api-service/__app__/onefuzzlib/endpoint_authorization.py b/src/api-service/__app__/onefuzzlib/endpoint_authorization.py index b82b2cdea..4ac743e96 100644 --- a/src/api-service/__app__/onefuzzlib/endpoint_authorization.py +++ b/src/api-service/__app__/onefuzzlib/endpoint_authorization.py @@ -4,7 +4,7 @@ # Licensed under the MIT License. import logging -from typing import Callable +from typing import Callable, Optional from uuid import UUID import azure.functions as func @@ -13,6 +13,7 @@ from onefuzztypes.enums import ErrorCode from onefuzztypes.models import Error, UserInfo from .azure.creds import get_scaleset_principal_id +from .config import InstanceConfig from .request import not_ok from .user_credentials import parse_jwt_token from .workers.pools import Pool @@ -43,6 +44,57 @@ def is_agent(token_data: UserInfo) -> bool: return False +def can_modify_config_impl(config: InstanceConfig, user_info: UserInfo) -> bool: + if config.admins is None: + return True + + return user_info.object_id in config.admins + + +def can_modify_config(req: func.HttpRequest, config: InstanceConfig) -> bool: + user_info = parse_jwt_token(req) + if not isinstance(user_info, UserInfo): + return False + + return can_modify_config_impl(config, user_info) + + +def check_can_manage_pools_impl( + config: InstanceConfig, user_info: UserInfo +) -> Optional[Error]: + if config.allow_pool_management: + return None + + if config.admins is None: + return Error(code=ErrorCode.UNAUTHORIZED, errors=["pool modification disabled"]) + + if user_info.object_id in config.admins: + return None + + return Error(code=ErrorCode.UNAUTHORIZED, errors=["not authorized to manage pools"]) + + +def check_can_manage_pools(req: func.HttpRequest) -> Optional[Error]: + user_info = parse_jwt_token(req) + if isinstance(user_info, Error): + return user_info + + # When there are no admins in the `admins` list, all users are considered + # admins. However, `allow_pool_management` is still useful to protect from + # mistakes. + # + # To make changes while still protecting against accidental changes to + # pools, do the following: + # + # 1. set `allow_pool_management` to `True` + # 2. make the change + # 3. set `allow_pool_management` to `False` + + config = InstanceConfig.fetch() + + return check_can_manage_pools_impl(config, user_info) + + def is_user(token_data: UserInfo) -> bool: return not is_agent(token_data) diff --git a/src/api-service/__app__/pool/__init__.py b/src/api-service/__app__/pool/__init__.py index e61ebf1ee..8df4364c0 100644 --- a/src/api-service/__app__/pool/__init__.py +++ b/src/api-service/__app__/pool/__init__.py @@ -21,7 +21,7 @@ from ..onefuzzlib.azure.creds import ( from ..onefuzzlib.azure.queue import get_queue_sas from ..onefuzzlib.azure.storage import StorageType from ..onefuzzlib.azure.vmss import list_available_skus -from ..onefuzzlib.endpoint_authorization import call_if_user +from ..onefuzzlib.endpoint_authorization import call_if_user, check_can_manage_pools from ..onefuzzlib.events import get_events from ..onefuzzlib.request import not_ok, ok, parse_request from ..onefuzzlib.workers.pools import Pool @@ -78,6 +78,10 @@ def post(req: func.HttpRequest) -> func.HttpResponse: if isinstance(request, Error): return not_ok(request, context="PoolCreate") + answer = check_can_manage_pools(req) + if isinstance(answer, Error): + return not_ok(answer, context="PoolCreate") + pool = Pool.get_by_name(request.name) if isinstance(pool, Pool): return not_ok( @@ -130,6 +134,10 @@ def delete(req: func.HttpRequest) -> func.HttpResponse: if isinstance(request, Error): return not_ok(request, context="PoolDelete") + answer = check_can_manage_pools(req) + if isinstance(answer, Error): + return not_ok(answer, context="PoolDelete") + pool = Pool.get_by_name(request.name) if isinstance(pool, Error): return not_ok(pool, context="pool stop") diff --git a/src/api-service/__app__/scaleset/__init__.py b/src/api-service/__app__/scaleset/__init__.py index 098f0ef1c..949b8c8e0 100644 --- a/src/api-service/__app__/scaleset/__init__.py +++ b/src/api-service/__app__/scaleset/__init__.py @@ -16,7 +16,7 @@ from onefuzztypes.responses import BoolResult from ..onefuzzlib.azure.creds import get_base_region, get_regions from ..onefuzzlib.azure.vmss import list_available_skus -from ..onefuzzlib.endpoint_authorization import call_if_user +from ..onefuzzlib.endpoint_authorization import call_if_user, check_can_manage_pools from ..onefuzzlib.events import get_events from ..onefuzzlib.request import not_ok, ok, parse_request from ..onefuzzlib.workers.pools import Pool @@ -48,6 +48,10 @@ def post(req: func.HttpRequest) -> func.HttpResponse: if isinstance(request, Error): return not_ok(request, context="ScalesetCreate") + answer = check_can_manage_pools(req) + if isinstance(answer, Error): + return not_ok(answer, context="ScalesetCreate") + # Verify the pool exists pool = Pool.get_by_name(request.pool_name) if isinstance(pool, Error): @@ -105,6 +109,10 @@ def delete(req: func.HttpRequest) -> func.HttpResponse: if isinstance(request, Error): return not_ok(request, context="ScalesetDelete") + answer = check_can_manage_pools(req) + if isinstance(answer, Error): + return not_ok(answer, context="ScalesetDelete") + scaleset = Scaleset.get_by_id(request.scaleset_id) if isinstance(scaleset, Error): return not_ok(scaleset, context="scaleset stop") @@ -118,6 +126,10 @@ def patch(req: func.HttpRequest) -> func.HttpResponse: if isinstance(request, Error): return not_ok(request, context="ScalesetUpdate") + answer = check_can_manage_pools(req) + if isinstance(answer, Error): + return not_ok(answer, context="ScalesetUpdate") + scaleset = Scaleset.get_by_id(request.scaleset_id) if isinstance(scaleset, Error): return not_ok(scaleset, context="ScalesetUpdate") diff --git a/src/api-service/tests/test_auth_check.py b/src/api-service/tests/test_auth_check.py new file mode 100644 index 000000000..18028afd9 --- /dev/null +++ b/src/api-service/tests/test_auth_check.py @@ -0,0 +1,97 @@ +#!/usr/bin/env python +# +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +import os +import unittest +from uuid import uuid4 + +from onefuzztypes.models import UserInfo + +from __app__.onefuzzlib.config import InstanceConfig +from __app__.onefuzzlib.endpoint_authorization import ( + can_modify_config_impl, + check_can_manage_pools_impl, +) + +if "ONEFUZZ_INSTANCE_NAME" not in os.environ: + os.environ["ONEFUZZ_INSTANCE_NAME"] = "test" + + +class TestAdmin(unittest.TestCase): + def test_modify_config(self) -> None: + user1 = uuid4() + user2 = uuid4() + + # no admins set + self.assertTrue(can_modify_config_impl(InstanceConfig(), UserInfo())) + + # with oid, but no admin + self.assertTrue( + can_modify_config_impl(InstanceConfig(), UserInfo(object_id=user1)) + ) + + # is admin + self.assertTrue( + can_modify_config_impl( + InstanceConfig(admins=[user1]), UserInfo(object_id=user1) + ) + ) + + # no user oid set + self.assertFalse( + can_modify_config_impl(InstanceConfig(admins=[user1]), UserInfo()) + ) + + # not an admin + self.assertFalse( + can_modify_config_impl( + InstanceConfig(admins=[user1]), UserInfo(object_id=user2) + ) + ) + + def test_manage_pools(self) -> None: + user1 = uuid4() + user2 = uuid4() + + # by default, any can modify + self.assertIsNone( + check_can_manage_pools_impl( + InstanceConfig(allow_pool_management=True), UserInfo() + ) + ) + + # with oid, but no admin + self.assertIsNone( + check_can_manage_pools_impl( + InstanceConfig(allow_pool_management=True), UserInfo(object_id=user1) + ) + ) + + # is admin + self.assertIsNone( + check_can_manage_pools_impl( + InstanceConfig(allow_pool_management=False, admins=[user1]), + UserInfo(object_id=user1), + ) + ) + + # no user oid set + self.assertIsNotNone( + check_can_manage_pools_impl( + InstanceConfig(allow_pool_management=False, admins=[user1]), UserInfo() + ) + ) + + # not an admin + self.assertIsNotNone( + check_can_manage_pools_impl( + InstanceConfig(allow_pool_management=False, admins=[user1]), + UserInfo(object_id=user2), + ) + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/src/cli/onefuzz/api.py b/src/cli/onefuzz/api.py index d998867aa..d13d3c40a 100644 --- a/src/cli/onefuzz/api.py +++ b/src/cli/onefuzz/api.py @@ -1557,6 +1557,22 @@ class ScalesetProxy(Endpoint): return self._req_model("GET", responses.ProxyList, data=requests.ProxyGet()) +class InstanceConfigCmd(Endpoint): + """Interact with Instance Configuration""" + + endpoint = "instance_config" + + def get(self) -> models.InstanceConfig: + return self._req_model("GET", models.InstanceConfig) + + def update(self, config: models.InstanceConfig) -> models.InstanceConfig: + return self._req_model( + "POST", + models.InstanceConfig, + data=requests.InstanceConfigUpdate(config=config), + ) + + class Command: def __init__(self, onefuzz: "Onefuzz", logger: logging.Logger): self.onefuzz = onefuzz @@ -1634,6 +1650,7 @@ class Onefuzz: self.scalesets = Scaleset(self) self.nodes = Node(self) self.webhooks = Webhooks(self) + self.instance_config = InstanceConfigCmd(self) if self._backend.is_feature_enabled(PreviewFeature.job_templates.name): self.job_templates = JobTemplates(self) diff --git a/src/deployment/deploy.py b/src/deployment/deploy.py index 11e1c9599..ec2b187d3 100644 --- a/src/deployment/deploy.py +++ b/src/deployment/deploy.py @@ -72,6 +72,7 @@ from registration import ( set_app_audience, update_pool_registration, ) +from set_admins import update_admins # Found by manually assigning the User.Read permission to application # registration in the admin portal. The values are in the manifest under @@ -129,6 +130,7 @@ class Client: multi_tenant_domain: str, upgrade: bool, subscription_id: Optional[str], + admins: List[UUID] ): self.subscription_id = subscription_id self.resource_group = resource_group @@ -158,6 +160,7 @@ class Client: self.migrations = migrations self.export_appinsights = export_appinsights self.log_service_principal = log_service_principal + self.admins = admins machine = platform.machine() system = platform.system() @@ -552,12 +555,18 @@ class Client: ) def apply_migrations(self) -> None: - self.results["deploy"]["func-storage"]["value"] name = self.results["deploy"]["func-name"]["value"] key = self.results["deploy"]["func-key"]["value"] table_service = TableService(account_name=name, account_key=key) migrate(table_service, self.migrations) + def set_admins(self) -> None: + name = self.results["deploy"]["func-name"]["value"] + key = self.results["deploy"]["func-key"]["value"] + table_service = TableService(account_name=name, account_key=key) + if self.admins: + update_admins(table_service, self.application_name, self.admins) + def create_queues(self) -> None: logger.info("creating eventgrid destination queue") @@ -916,6 +925,7 @@ def main() -> None: full_deployment_states = rbac_only_states + [ ("apply_migrations", Client.apply_migrations), + ("set_admins", Client.set_admins), ("queues", Client.create_queues), ("eventgrid", Client.create_eventgrid), ("tools", Client.upload_tools), @@ -1021,6 +1031,12 @@ def main() -> None: action="store_true", help="execute only the steps required to create the rbac resources", ) + parser.add_argument( + "--set_admins", + type=UUID, + nargs="*", + help="set the list of administrators (by OID in AAD)", + ) args = parser.parse_args() @@ -1048,6 +1064,7 @@ def main() -> None: multi_tenant_domain=args.multi_tenant_domain, upgrade=args.upgrade, subscription_id=args.subscription_id, + admins=args.set_admins, ) if args.verbose: level = logging.DEBUG diff --git a/src/deployment/set_admins.py b/src/deployment/set_admins.py new file mode 100644 index 000000000..c733c70e2 --- /dev/null +++ b/src/deployment/set_admins.py @@ -0,0 +1,60 @@ +#!/usr/bin/env python +# +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +import argparse +import json +from typing import List, Optional +from uuid import UUID + +from azure.common.client_factory import get_client_from_cli_profile +from azure.cosmosdb.table.tableservice import TableService +from azure.mgmt.storage import StorageManagementClient + +TABLE_NAME = "InstanceConfig" + + +def create_if_missing(table_service: TableService) -> None: + if not table_service.exists(TABLE_NAME): + table_service.create_table(TABLE_NAME) + + +def update_admins( + table_service: TableService, resource_group: str, admins: List[UUID] +) -> None: + create_if_missing(table_service) + admins_as_str: Optional[List[str]] = None + if admins: + admins_as_str = [str(x) for x in admins] + + table_service.insert_or_merge_entity( + TABLE_NAME, + { + "PartitionKey": resource_group, + "RowKey": resource_group, + "admins": json.dumps(admins_as_str), + }, + ) + + +def main() -> None: + formatter = argparse.ArgumentDefaultsHelpFormatter + parser = argparse.ArgumentParser(formatter_class=formatter) + parser.add_argument("resource_group") + parser.add_argument("storage_account") + parser.add_argument("admins", type=UUID, nargs="*") + args = parser.parse_args() + + client = get_client_from_cli_profile(StorageManagementClient) + storage_keys = client.storage_accounts.list_keys( + args.resource_group, args.storage_account + ) + table_service = TableService( + account_name=args.storage_account, account_key=storage_keys.keys[0].value + ) + update_admins(table_service, args.resource_group, args.admins) + + +if __name__ == "__main__": + main() diff --git a/src/pytypes/extra/generate-docs.py b/src/pytypes/extra/generate-docs.py index 2347b9d14..eb89779cb 100755 --- a/src/pytypes/extra/generate-docs.py +++ b/src/pytypes/extra/generate-docs.py @@ -22,6 +22,7 @@ from onefuzztypes.events import ( Event, EventCrashReported, EventFileAdded, + EventInstanceConfigUpdated, EventJobCreated, EventJobStopped, EventNodeCreated, @@ -53,6 +54,7 @@ from onefuzztypes.models import ( BlobRef, CrashTestResult, Error, + InstanceConfig, JobConfig, RegressionReport, Report, @@ -252,6 +254,7 @@ def main() -> None: EventFileAdded(container=Container("container-name"), filename="example.txt"), EventNodeHeartbeat(machine_id=UUID(int=0), pool_name=PoolName("example")), EventTaskHeartbeat(task_id=UUID(int=0), job_id=UUID(int=0), config=task_config), + EventInstanceConfigUpdated(config=InstanceConfig(admins=[UUID(int=0)])), ] # works around `mypy` not handling that Union has `__args__` diff --git a/src/pytypes/onefuzztypes/events.py b/src/pytypes/onefuzztypes/events.py index ef2359599..dc17b20d8 100644 --- a/src/pytypes/onefuzztypes/events.py +++ b/src/pytypes/onefuzztypes/events.py @@ -23,6 +23,7 @@ from .enums import ( from .models import ( AutoScaleConfig, Error, + InstanceConfig, JobConfig, RegressionReport, Report, @@ -200,6 +201,10 @@ class EventFileAdded(BaseEvent): filename: str +class EventInstanceConfigUpdated(BaseEvent): + config: InstanceConfig + + Event = Union[ EventJobCreated, EventJobStopped, @@ -226,6 +231,7 @@ Event = Union[ EventCrashReported, EventRegressionReported, EventFileAdded, + EventInstanceConfigUpdated, ] @@ -255,6 +261,7 @@ class EventType(Enum): file_added = "file_added" task_heartbeat = "task_heartbeat" node_heartbeat = "node_heartbeat" + instance_config_updated = "instance_config_updated" EventTypeMap = { @@ -283,6 +290,7 @@ EventTypeMap = { EventType.crash_reported: EventCrashReported, EventType.regression_reported: EventRegressionReported, EventType.file_added: EventFileAdded, + EventType.instance_config_updated: EventInstanceConfigUpdated, } diff --git a/src/pytypes/onefuzztypes/models.py b/src/pytypes/onefuzztypes/models.py index 663fcc988..86a86e80a 100644 --- a/src/pytypes/onefuzztypes/models.py +++ b/src/pytypes/onefuzztypes/models.py @@ -856,4 +856,28 @@ class Task(BaseModel): user_info: Optional[UserInfo] +class InstanceConfig(BaseModel): + # initial set of admins can only be set during deployment. + # if admins are set, only admins can update instance configs. + admins: Optional[List[UUID]] = None + + # if set, only admins can manage pools or scalesets + allow_pool_management: bool = Field(default=True) + + def update(self, config: "InstanceConfig") -> None: + for field in config.__fields__: + # If no admins are set, then ignore setting admins + if field == "admins" and self.admins is None: + continue + + if hasattr(self, field): + setattr(self, field, getattr(config, field)) + + @validator("admins", allow_reuse=True) + def check_admins(cls, value: Optional[List[UUID]]) -> Optional[List[UUID]]: + if value is not None and len(value) == 0: + raise ValueError("admins must be None or contain at least one UUID") + return value + + _check_hotfix() diff --git a/src/pytypes/onefuzztypes/requests.py b/src/pytypes/onefuzztypes/requests.py index e3c36a907..8180b1b24 100644 --- a/src/pytypes/onefuzztypes/requests.py +++ b/src/pytypes/onefuzztypes/requests.py @@ -20,7 +20,7 @@ from .enums import ( TaskState, ) from .events import EventType -from .models import AutoScaleConfig, NotificationConfig +from .models import AutoScaleConfig, InstanceConfig, NotificationConfig from .primitives import Container, PoolName, Region @@ -253,4 +253,8 @@ class NodeAddSshKey(BaseModel): public_key: str +class InstanceConfigUpdate(BaseModel): + config: InstanceConfig + + _check_hotfix() diff --git a/src/pytypes/tests/test_instance_config_update.py b/src/pytypes/tests/test_instance_config_update.py new file mode 100644 index 000000000..8532c9a9b --- /dev/null +++ b/src/pytypes/tests/test_instance_config_update.py @@ -0,0 +1,30 @@ +#!/usr/bin/env python +# +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +from uuid import UUID +import unittest + +from onefuzztypes.models import InstanceConfig + + +class TestInstanceConfig(unittest.TestCase): + def test_with_admins(self) -> None: + no_admins = InstanceConfig(admins=None) + with_admins = InstanceConfig(admins=[UUID(int=0)]) + with_admins_2 = InstanceConfig(admins=[UUID(int=1)]) + + no_admins.update(with_admins) + self.assertEqual(no_admins.admins, None) + + with_admins.update(with_admins_2) + self.assertEqual(with_admins.admins, with_admins_2.admins) + + def test_with_empty_admins(self) -> None: + with self.assertRaises(ValueError): + InstanceConfig.parse_obj({"admins": []}) + + +if __name__ == "__main__": + unittest.main()