diff --git a/CHANGELOG.md b/CHANGELOG.md index 6540b21..7dc7163 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,17 @@ All notable changes to this project will be documented in this file. +## [Unreleased] + +### Changed +- Built access level labels from their bits instead of a fixed table of combinations, so a level the server adds later is labelled too. A project level (bit 16 and up) is named after the project as the server reports it to its members, e.g. `Brain/MINDS 2.0 Open`, and otherwise `Project Open`. A restrict network other than CBS is `Restrict Open`; `0x0200`, formerly `5Kikan Open`, is now `Restrict2 Open`. +- `chacl` takes keys built the same way: `cbs_or_pw_open`, `project0_open` and so on. `5kikan_open` is no longer accepted. Its help lists them, an invalid level lists the projects the user belongs to with their keys, and `public` and `storage`, which the server refuses, are refused before any request. +- `ls` asks the server for the user's projects once per run when logged in. +- Removed `FolderAccessLevel.ACCESS_LEVELS` and `FolderAccessLevelItem`. `FolderSimple.access_level_label()` labels a level with the project names given to it. + +### Fixed +- Reported an access level that has no meaning as `Unknown (0x…)` instead of `Storage`. Storage is the absence of every bit, so it matched any level. A level made of known bits is no longer labelled by the first table entry it contained either: `0x10104` was `CBS or PW Open`. This changes the `access_level` value in `ls` output, including JSON. + ## [1.3.20] - 2026-11-04 ### Changed diff --git a/README.md b/README.md index 563ad50..899ac74 100644 --- a/README.md +++ b/README.md @@ -199,12 +199,14 @@ mdrs rm -r neurodata:/NIU/Repository/TEST/dataset ### chacl -Change the folder access level +Change the folder access level. The level is `private`, `pw_open`, `cbs_open`, `project_open`, or several open levels joined by `_or_`. `` is the project's bit position; an invalid level lists the projects you belong to with their keys. ```shell mdrs chacl private neurodata:/NIU/Repository/Private mdrs chacl cbs_open -r neurodata:/NIU/Repository/CBS_Open mdrs chacl pw_open -r -p SHARING_PASSWORD neurodata:/NIU/Repository/PW_Open +mdrs chacl cbs_or_pw_open -p SHARING_PASSWORD neurodata:/NIU/Repository/CBS_or_PW_Open +mdrs chacl project0_open neurodata:/NIU/Repository/Project_Open ``` ### metadata diff --git a/mdrsclient/api/users.py b/mdrsclient/api/users.py index e195958..22b446c 100644 --- a/mdrsclient/api/users.py +++ b/mdrsclient/api/users.py @@ -1,3 +1,4 @@ +from dataclasses import field from typing import Final import requests @@ -20,6 +21,13 @@ class UsersCurrentResponseLaboratory: role: int +@dataclass(frozen=True) +class UsersCurrentResponseProject: + id: int + name: str | None + bit_position: int + + @dataclass(frozen=True) class UsersApiCurrentResponse: id: int @@ -35,6 +43,7 @@ class UsersApiCurrentResponse: is_reviewer: bool last_login: str # ISO8601 date_joined: str # ISO8601 + projects: list[UsersCurrentResponseProject] = field(default_factory=list) class UsersApi(BaseApi): @@ -50,6 +59,20 @@ class UsersApi(BaseApi): user = User(id=obj.id, username=obj.username, laboratory_ids=laboratory_ids, is_reviewer=obj.is_reviewer) return user + def project_names(self) -> dict[int, str]: + """Names of the user's projects by bit_position, the bit their access level sets.""" + url = self.ENTRYPOINT + "current/" + response = self.connection.get(url) + self._raise_response_error(response) + obj = TypeAdapter(UsersApiCurrentResponse).validate_python(response.json()) + names: dict[int, list[str]] = {} + for project in obj.projects: + if project.name: + # bit_position is not unique on the server, and a folder at a shared bit + # belongs to every project holding it, so all of them are named. + names.setdefault(project.bit_position, []).append(project.name) + return {bit: ", ".join(sorted(x)) for bit, x in names.items()} + def token(self, username: str, password: str) -> Token: # print(self.__class__.__name__ + "::" + sys._getframe().f_code.co_name) url = self.ENTRYPOINT + "token/" diff --git a/mdrsclient/commands/chacl.py b/mdrsclient/commands/chacl.py index ed71fd4..a1b772e 100644 --- a/mdrsclient/commands/chacl.py +++ b/mdrsclient/commands/chacl.py @@ -1,18 +1,28 @@ from argparse import Namespace -from typing import Any +from typing import Any, Final + +import requests from mdrsclient.commands.base import BaseCommand -from mdrsclient.exceptions import IllegalArgumentException +from mdrsclient.exceptions import IllegalArgumentException, MDRSException from mdrsclient.models import FolderAccessLevel class ChaclCommand(BaseCommand): + ACCESS_LEVEL_HELP: Final[str] = ( + "access level: private, pw_open, cbs_open, project_open, " + "or several open levels joined by `_or_` (e.g. cbs_or_pw_open). " + " is the project's bit position; an invalid level lists the projects you belong to" + ) + @classmethod def register(cls, parsers: Any) -> None: chacl_parser = parsers.add_parser("chacl", help="change the folder access level") - chacl_parser.add_argument("access_level", help="access level (private, cbs_open, pw_open)") + chacl_parser.add_argument("access_level", help=cls.ACCESS_LEVEL_HELP) chacl_parser.add_argument("-r", "--recursive", help="change access levels recursively", action="store_true") - chacl_parser.add_argument("-p", "--password", help="password to set when access level is `pw_open`") + chacl_parser.add_argument( + "-p", "--password", help="password to set when the access level includes `pw` (e.g. pw_open)" + ) chacl_parser.add_argument("remote_path", help="remote folder path (remote:/lab/path/)") chacl_parser.set_defaults(func=cls.func) @@ -21,9 +31,10 @@ class ChaclCommand(BaseCommand): remote_path = str(args.remote_path) access_level = FolderAccessLevel.key2id(str(args.access_level)) if access_level is None: - raise IllegalArgumentException( - "Invalid `access_level` parameter. must be `private`, `cbs_open` or `pw_open`." - ) + raise IllegalArgumentException(cls._invalid_level_message(remote_path)) + if access_level in (FolderAccessLevel.STORAGE, FolderAccessLevel.PUBLIC): + # The server refuses both; saying so here spares a round trip and a bare 400. + raise IllegalArgumentException(f"`{args.access_level}` cannot be set as an access level.") password = str(args.password) if args.password else None is_recursive = bool(args.recursive) cls.chacl(remote_path, access_level, is_recursive, password) @@ -35,3 +46,24 @@ class ChaclCommand(BaseCommand): client = MdrsClient.from_remote(remote) client.chacl(remote_path, access_level, is_recursive, password) + + @classmethod + def _invalid_level_message(cls, remote_path: str) -> str: + message = ( + "Invalid `access_level` parameter. must be `private`, `pw_open`, `cbs_open`, `project_open` " + "or open levels joined by `_or_` (e.g. `cbs_or_pw_open`)." + ) + remote = remote_path.split(":", 1)[0] if ":" in remote_path else "" + from mdrsclient.client import MdrsClient + + # The project numbers are known only to the server, and only for the user's own + # projects. Not reaching it leaves the message as it is: the error being reported + # is the level, not the connection. + try: + projects = MdrsClient.from_remote(remote).project_names() + except (MDRSException, requests.RequestException): + return message + if not projects: + return message + lines = [f" project{bit}_open: {name}" for bit, name in sorted(projects.items())] + return "\n".join([message, "Projects you belong to:", *lines]) diff --git a/mdrsclient/commands/ls.py b/mdrsclient/commands/ls.py index 23bf34f..2482966 100644 --- a/mdrsclient/commands/ls.py +++ b/mdrsclient/commands/ls.py @@ -1,5 +1,6 @@ import json from argparse import Namespace +from dataclasses import field from typing import Any, Final from pydantic.dataclasses import dataclass @@ -26,6 +27,7 @@ class LsCommandContext: is_json: bool is_quiet: bool is_recursive: bool + projects: dict[int, str] = field(default_factory=dict) class LsCommand(BaseCommand): @@ -85,6 +87,7 @@ class LsCommand(BaseCommand): is_json, is_quiet, is_recursive, + client.project_names(), ) if context.is_json: cls._ls_json(context, folder, files) @@ -110,7 +113,7 @@ class LsCommand(BaseCommand): length = {key: len(label) if not context.is_quiet else 0 for key, label in cls.LABELS.items()} for sub_folder in folder.sub_folders: sub_laboratory_name = cls._laboratory_name(context, sub_folder.laboratory_id) - length["acl"] = max(length["acl"], len(sub_folder.access_level_name)) + length["acl"] = max(length["acl"], len(sub_folder.access_level_label(context.projects))) length["laboratory"] = max(length["laboratory"], len(sub_laboratory_name)) length["size"] = max(length["size"], len(str(sub_folder.size))) length["date"] = max(length["date"], len(sub_folder.updated_at_name)) @@ -119,7 +122,7 @@ class LsCommand(BaseCommand): length["size"] = max(length["size"], len(str(file.size))) length["date"] = max(length["date"], len(file.updated_at_name)) length["name"] = max(length["name"], len(file.name)) - length["acl"] = max(length["acl"], len(folder.access_level_name)) + length["acl"] = max(length["acl"], len(folder.access_level_label(context.projects))) length["laboratory"] = max(length["laboratory"], len(context.laboratory.name)) return length @@ -159,14 +162,15 @@ class LsCommand(BaseCommand): for sub_folder in sorted(folder.sub_folders, key=lambda x: x.name): sub_laboratory_name = cls._laboratory_name(context, sub_folder.laboratory_id) sub_folder_type = "[d]" if sub_folder.lock is False else "[l]" + sub_folder_acl = sub_folder.access_level_label(context.projects) print( - f"{sub_folder_type:{length['type']}}\t{sub_folder.access_level_name:{length['acl']}}\t" + f"{sub_folder_type:{length['type']}}\t{sub_folder_acl:{length['acl']}}\t" f"{sub_laboratory_name:{length['laboratory']}}\t{sub_folder.size:{length['size']}}\t" f"{sub_folder.updated_at_name:{length['date']}}\t{sub_folder.name:{length['name']}}" ) for file in sorted(files, key=lambda x: x.name): print( - f"{'[f]':{length['type']}}\t{folder.access_level_name:{length['acl']}}\t" + f"{'[f]':{length['type']}}\t{folder.access_level_label(context.projects):{length['acl']}}\t" f"{context.laboratory.name:{length['laboratory']}}\t{file.size:{length['size']}}\t" f"{file.updated_at_name:{length['date']}}\t{file.name:{length['name']}}" ) @@ -183,7 +187,7 @@ class LsCommand(BaseCommand): "pid": folder.pid, "name": folder.name, "size": folder.size, - "access_level": folder.access_level_name, + "access_level": folder.access_level_label(context.projects), "lock": folder.lock, "laboratory": cls._laboratory_name(context, folder.laboratory_id), "description": folder.description, diff --git a/mdrsclient/models/folder.py b/mdrsclient/models/folder.py index 1f65a67..95a9e06 100644 --- a/mdrsclient/models/folder.py +++ b/mdrsclient/models/folder.py @@ -1,4 +1,5 @@ -from typing import Any, Final, NamedTuple +import re +from typing import Any, Final, Mapping from unicodedata import normalize from pydantic.dataclasses import dataclass @@ -6,39 +7,99 @@ from pydantic.dataclasses import dataclass from mdrsclient.models.utils import iso8601_to_user_friendly -class FolderAccessLevelItem(NamedTuple): - mask: int - key: str - label: str - - class FolderAccessLevel: # Bit Mask # - bit 0: Is Private # - bit 1: Is Public # - bit 2: With Password # - bit 3-7: (Reserved) - # - bit 8-15: Restricted Open - ACCESS_LEVELS: Final[list[FolderAccessLevelItem]] = [ - FolderAccessLevelItem(0x0204, "5kikan_or_pw_open", "5Kikan or PW Open"), - FolderAccessLevelItem(0x0104, "cbs_or_pw_open", "CBS or PW Open"), - FolderAccessLevelItem(0x0200, "5kikan_open", "5Kikan Open"), - FolderAccessLevelItem(0x0100, "cbs_open", "CBS Open"), - FolderAccessLevelItem(0x0004, "pw_open", "PW Open"), - FolderAccessLevelItem(0x0002, "public", "Public"), - FolderAccessLevelItem(0x0001, "private", "Private"), - FolderAccessLevelItem(0x0000, "storage", "Storage"), - ] + # - bit 8-15: Restricted Open, one bit per restrict network slot (slot 1 is bit 8) + # - bit 16-: Project Open, one bit per project bit_position (bit_position 0 is bit 16) + STORAGE: Final[int] = 0x0000 + PRIVATE: Final[int] = 0x0001 + PUBLIC: Final[int] = 0x0002 + PASSWORD: Final[int] = 0x0004 + RESERVED: Final[int] = 0x00F8 + RESTRICT_SHIFT: Final[int] = 8 + RESTRICT_SLOTS: Final[int] = 8 + PROJECT_SHIFT: Final[int] = 16 + # The server keeps no names for its restrict networks, so the one in use is named here. + # Any other slot is named after its number. + RESTRICT_NAMES: Final[dict[int, str]] = {1: "CBS"} + LEVEL_NAMES: Final[dict[int, str]] = {STORAGE: "Storage", PRIVATE: "Private", PUBLIC: "Public"} + + @staticmethod + def restrict_name(slot: int) -> str: + return FolderAccessLevel.RESTRICT_NAMES.get(slot, f"Restrict{slot}") + + @staticmethod + def project_name(bit_position: int, projects: Mapping[int, str] | None = None) -> str: + name = (projects or {}).get(bit_position) + return name if name else f"Project{bit_position}" + + @staticmethod + def id2label(id: int, projects: Mapping[int, str] | None = None) -> str | None: + """ + Label an access level from its bits, e.g. 0x0104 is "CBS or PW Open". + + `projects` names the projects by bit_position, as the server tells their members; + any other project gets a name made from its bit_position. None for a level that + has no meaning: Private or Public with any other bit, or a reserved bit. + """ + if id in FolderAccessLevel.LEVEL_NAMES: + return FolderAccessLevel.LEVEL_NAMES[id] + if id < 0 or id & (FolderAccessLevel.PRIVATE | FolderAccessLevel.PUBLIC | FolderAccessLevel.RESERVED): + return None + names = [ + FolderAccessLevel.restrict_name(slot) + for slot in range(1, FolderAccessLevel.RESTRICT_SLOTS + 1) + if id & (1 << (FolderAccessLevel.RESTRICT_SHIFT + slot - 1)) + ] + project_bits = id >> FolderAccessLevel.PROJECT_SHIFT + names += [ + FolderAccessLevel.project_name(bit, projects) + for bit in range(project_bits.bit_length()) + if project_bits & (1 << bit) + ] + if id & FolderAccessLevel.PASSWORD: + names.append("PW") + return " or ".join(names) + " Open" @staticmethod def key2id(key: str) -> int | None: - acl = next((x for x in FolderAccessLevel.ACCESS_LEVELS if x.key == key), None) - return acl.mask if acl is not None else None + """ + The access level a key names: `private`, `public`, `storage`, or the parts of an + open level joined by `_or_` and ending in `_open`, e.g. `cbs_or_pw_open`. A part + is `pw`, `cbs`, `restrict` or `project`. + """ + fixed = {label.lower(): id for id, label in FolderAccessLevel.LEVEL_NAMES.items()} + if key in fixed: + return fixed[key] + if not key.endswith("_open"): + return None + id = 0 + for part in key.removesuffix("_open").split("_or_"): + bit = FolderAccessLevel._part2bit(part) + if bit is None: + return None + id |= bit + return id @staticmethod - def id2label(id: int) -> str | None: - acl = next((x for x in FolderAccessLevel.ACCESS_LEVELS if (x.mask & id) == x.mask), None) - return acl.label if acl is not None else None + def _part2bit(part: str) -> int | None: + if part == "pw": + return FolderAccessLevel.PASSWORD + slots = {name.lower(): slot for slot, name in FolderAccessLevel.RESTRICT_NAMES.items()} + match = re.fullmatch(r"restrict([1-9][0-9]*)", part) + slot = slots.get(part, int(match.group(1)) if match else None) + if slot is not None: + if slot > FolderAccessLevel.RESTRICT_SLOTS: + return None + return 1 << (FolderAccessLevel.RESTRICT_SHIFT + slot - 1) + match = re.fullmatch(r"project(0|[1-9][0-9]*)", part) + if match: + return 1 << (FolderAccessLevel.PROJECT_SHIFT + int(match.group(1))) + return None @dataclass(frozen=True) @@ -55,10 +116,14 @@ class FolderSimple: updated_at: str restrict_opened_at: str | None + def access_level_label(self, projects: Mapping[int, str] | None = None) -> str: + """The access level as shown to a user, naming the projects given by bit_position.""" + label = FolderAccessLevel.id2label(self.access_level, projects) + return label if label is not None else f"Unknown (0x{self.access_level:x})" + @property def access_level_name(self) -> str: - label = FolderAccessLevel.id2label(self.access_level) - return label if label is not None else "" + return self.access_level_label() @property def lock_name(self) -> str: diff --git a/mdrsclient/services.py b/mdrsclient/services.py index 6f78ab8..ff0b436 100644 --- a/mdrsclient/services.py +++ b/mdrsclient/services.py @@ -3,6 +3,7 @@ import re from unicodedata import normalize from mdrsclient.api import DoiApi, FilesApi, FoldersApi, LaboratoriesApi, UsersApi +from mdrsclient.api.utils import token_check from mdrsclient.cache import CacheInterface from mdrsclient.config import ConfigFile, ConfigInterface from mdrsclient.connection import MDRSConnection @@ -66,6 +67,17 @@ class MdrsService: def logout(self) -> None: self.connection.logout() + def project_names(self) -> dict[int, str]: + """ + Names of the logged-in user's projects by bit_position, for labelling access levels. + + Empty when nobody is logged in: the server names a project only to its members. + """ + token_check(self.connection) + if self.connection.token is None: + return {} + return UsersApi(self.connection).project_names() + def whoami(self) -> User: user_api = UsersApi(self.connection) return user_api.current() diff --git a/tests/test_commands.py b/tests/test_commands.py index 9342b59..48d4cde 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -23,6 +23,7 @@ from mdrsclient.commands import ( VersionCommand, WhoamiCommand, ) +from mdrsclient.exceptions import IllegalArgumentException, MissingConfigurationException from mdrsclient.models import Folder, Laboratory @@ -177,6 +178,7 @@ class TestCommands(unittest.TestCase): mock_lab = Laboratory(id=1, name="mylab", pi_name="pi_name", full_name="full_name") mock_client.resolve_folder.return_value = (mock_folder, mock_lab) + mock_client.project_names.return_value = {} mock_file = MagicMock() mock_file.name = "file.txt" @@ -228,6 +230,7 @@ class TestCommands(unittest.TestCase): mock_lab = Laboratory(id=1, name="mylab", pi_name="pi_name", full_name="full_name") mock_client.resolve_folder.return_value = (mock_folder, mock_lab) + mock_client.project_names.return_value = {} mock_client.connection.laboratories.find_by_id.return_value = mock_lab mock_file = MagicMock() @@ -316,6 +319,48 @@ class TestCommands(unittest.TestCase): mock_client_class.from_remote.assert_called_once_with("myremote") mock_client.chacl.assert_called_once_with("myremote:/mylab/", 1, True, "secret") + def test_chacl_command_project_level(self): + with patch("mdrsclient.client.MdrsClient") as mock_client_class: + mock_client = MagicMock() + mock_client_class.from_remote.return_value = mock_client + + args = self.parse_args(ChaclCommand, ["chacl", "cbs_or_project0_open", "myremote:/mylab/"]) + args.func(args) + + mock_client.chacl.assert_called_once_with("myremote:/mylab/", 0x10100, False, None) + + def test_chacl_command_invalid_level_lists_projects(self): + with patch("mdrsclient.client.MdrsClient") as mock_client_class: + mock_client = MagicMock() + mock_client_class.from_remote.return_value = mock_client + mock_client.project_names.return_value = {0: "Brain/MINDS 2.0"} + + args = self.parse_args(ChaclCommand, ["chacl", "5kikan_open", "myremote:/mylab/"]) + with self.assertRaises(IllegalArgumentException) as cm: + args.func(args) + + self.assertIn("project_open", str(cm.exception)) + self.assertIn("project0_open: Brain/MINDS 2.0", str(cm.exception)) + mock_client.chacl.assert_not_called() + + def test_chacl_command_invalid_level_without_server(self): + with patch("mdrsclient.client.MdrsClient") as mock_client_class: + mock_client_class.from_remote.side_effect = MissingConfigurationException("no remote") + + args = self.parse_args(ChaclCommand, ["chacl", "5kikan_open", "myremote:/mylab/"]) + with self.assertRaises(IllegalArgumentException) as cm: + args.func(args) + + self.assertNotIn("Projects you belong to", str(cm.exception)) + + def test_chacl_command_refuses_public_and_storage(self): + for level in ("public", "storage"): + with self.subTest(level=level), patch("mdrsclient.client.MdrsClient") as mock_client_class: + args = self.parse_args(ChaclCommand, ["chacl", level, "myremote:/mylab/"]) + with self.assertRaises(IllegalArgumentException): + args.func(args) + mock_client_class.from_remote.assert_not_called() + def test_metadata_command(self): with patch("mdrsclient.client.MdrsClient") as mock_client_class: mock_client = MagicMock() diff --git a/tests/test_folder_access_level.py b/tests/test_folder_access_level.py new file mode 100644 index 0000000..659f376 --- /dev/null +++ b/tests/test_folder_access_level.py @@ -0,0 +1,154 @@ +import unittest +from unittest.mock import MagicMock, patch + +from mdrsclient.api import UsersApi +from mdrsclient.models import FolderSimple +from mdrsclient.models.folder import FolderAccessLevel +from mdrsclient.services import MdrsService + +TIMESTAMP = "2026-01-01T00:00:00+09:00" +BM2 = {0: "Brain/MINDS 2.0"} + + +def make_folder(access_level: int) -> FolderSimple: + return FolderSimple( + id="f1", + pid=None, + name="data", + access_level=access_level, + lock=False, + size=0, + laboratory_id=1, + description="", + created_at=TIMESTAMP, + updated_at=TIMESTAMP, + restrict_opened_at=None, + ) + + +def current_user(projects: list[dict]) -> dict: + return { + "id": 1, + "username": "user", + "first_name": "", + "last_name": "", + "email": "", + "orcid_id": "", + "laboratories": [], + "is_staff": False, + "is_active": True, + "is_superuser": False, + "is_reviewer": False, + "last_login": TIMESTAMP, + "date_joined": TIMESTAMP, + "projects": projects, + } + + +class TestId2Label(unittest.TestCase): + def test_single_levels(self) -> None: + cases = { + 0x0000: "Storage", + 0x0001: "Private", + 0x0002: "Public", + 0x0004: "PW Open", + 0x0100: "CBS Open", + 0x0104: "CBS or PW Open", + } + for id, label in cases.items(): + with self.subTest(id=hex(id)): + self.assertEqual(FolderAccessLevel.id2label(id), label) + + def test_restrict_slot_without_a_name_is_numbered(self) -> None: + self.assertEqual(FolderAccessLevel.id2label(0x0200), "Restrict2 Open") + self.assertEqual(FolderAccessLevel.id2label(0x8000), "Restrict8 Open") + + def test_project_is_named_from_the_server(self) -> None: + self.assertEqual(FolderAccessLevel.id2label(0x10000, BM2), "Brain/MINDS 2.0 Open") + self.assertEqual(FolderAccessLevel.id2label(0x10004, BM2), "Brain/MINDS 2.0 or PW Open") + + def test_project_not_named_gets_its_bit_position(self) -> None: + self.assertEqual(FolderAccessLevel.id2label(0x10000), "Project0 Open") + self.assertEqual(FolderAccessLevel.id2label(0x20000, BM2), "Project1 Open") + + def test_parts_are_restrict_then_project_then_password(self) -> None: + self.assertEqual( + FolderAccessLevel.id2label(0x30304, BM2), "CBS or Restrict2 or Brain/MINDS 2.0 or Project1 or PW Open" + ) + + def test_meaningless_levels_are_unlabelled(self) -> None: + # Private or Public mixed with another bit, a reserved bit, a negative value. + for id in (0x0003, 0x0006, 0x0102, 0x10001, 0x0008, 0x0080, -1): + with self.subTest(id=hex(id)): + self.assertIsNone(FolderAccessLevel.id2label(id)) + + +class TestKey2Id(unittest.TestCase): + def test_keys(self) -> None: + cases = { + "storage": 0x0000, + "private": 0x0001, + "public": 0x0002, + "pw_open": 0x0004, + "cbs_open": 0x0100, + "cbs_or_pw_open": 0x0104, + "restrict1_open": 0x0100, + "restrict2_open": 0x0200, + "project0_open": 0x10000, + "project1_or_pw_open": 0x20004, + } + for key, id in cases.items(): + with self.subTest(key=key): + self.assertEqual(FolderAccessLevel.key2id(key), id) + + def test_unknown_keys(self) -> None: + for key in ("5kikan_open", "restrict9_open", "restrict0_open", "project01_open", "cbs", "_open", "pw_or_open"): + with self.subTest(key=key): + self.assertIsNone(FolderAccessLevel.key2id(key)) + + +class TestAccessLevelLabel(unittest.TestCase): + def test_known_level(self) -> None: + self.assertEqual(make_folder(0x0100).access_level_name, "CBS Open") + + def test_project_names_are_used(self) -> None: + self.assertEqual(make_folder(0x10000).access_level_label(BM2), "Brain/MINDS 2.0 Open") + self.assertEqual(make_folder(0x10000).access_level_name, "Project0 Open") + + def test_meaningless_level_shows_its_value(self) -> None: + self.assertEqual(make_folder(0x0008).access_level_name, "Unknown (0x8)") + + +class TestProjectNames(unittest.TestCase): + def test_anonymous_user_asks_nothing(self) -> None: + connection = MagicMock() + connection.token = None + with patch("mdrsclient.services.UsersApi") as users_api: + self.assertEqual(MdrsService(connection).project_names(), {}) + users_api.assert_not_called() + + def test_names_by_bit_position(self) -> None: + connection = MagicMock() + connection.get.return_value.json.return_value = current_user( + [ + {"id": 1, "name": "Brain/MINDS 2.0", "bit_position": 0}, + {"id": 2, "name": "B", "bit_position": 3}, + {"id": 3, "name": "A", "bit_position": 3}, + {"id": 4, "name": None, "bit_position": 5}, + ] + ) + with patch.object(UsersApi, "_raise_response_error"): + names = UsersApi(connection).project_names() + self.assertEqual(names, {0: "Brain/MINDS 2.0", 3: "A, B"}) + + def test_server_without_projects(self) -> None: + connection = MagicMock() + user = current_user([]) + del user["projects"] + connection.get.return_value.json.return_value = user + with patch.object(UsersApi, "_raise_response_error"): + self.assertEqual(UsersApi(connection).project_names(), {}) + + +if __name__ == "__main__": + unittest.main()