Skip to content

test_credentials

Tests for :mod:pyrevit.coreutils.credentials, the extension credential store.

Covers the config contract and the crypto contract separately; the module docstring explains why that split matters.

Two things are under test and they are deliberately kept apart:

  • The config contract - which key a credential lives under, that setting one removes the legacy plaintext keys, that clearing one leaves nothing behind, and above all that a failure never deletes a working token. A dict-backed fake IConfiguration models the C# side exactly as test_config_roundtrip does, so these run identically under IronPython 2/3 and CPython and never touch the user's real config.

  • The crypto - that a sealed value really round-trips, and that every way a stored value can be unusable is reported as unreadable rather than as absent. These need Windows DPAPI, so they skip where it is unavailable instead of passing vacuously.

The distinction the second group protects is the one that turns a stale token into a confusing libgit2 error about a missing authentication callback, so a silent-skip here would hide a real regression.

Run from Revit via the pyRevit DevTools "Credentials Module Tests" button.

Classes

AbsentCredentialTests

Bases: _CredentialsTestCase

No stored credential is a normal state, not a failure.

Methods:

test_missing_section_reports_no_credential()
Source code in pyrevitlib/pyrevit/unittests/test_credentials.py
def test_missing_section_reports_no_credential(self):
    self.assertIsNone(credentials.get_credential("NotInstalled.extension"))
test_section_without_credential_key_reports_no_credential()
Source code in pyrevitlib/pyrevit/unittests/test_credentials.py
def test_section_without_credential_key_reports_no_credential(self):
    self.user_config.add_section("Public.extension")
    self.user_config.put_raw("Public.extension", "disabled", "true")
    self.assertIsNone(credentials.get_credential("Public.extension"))
test_has_credential_is_false_when_absent()
Source code in pyrevitlib/pyrevit/unittests/test_credentials.py
def test_has_credential_is_false_when_absent(self):
    self.assertFalse(credentials.has_credential("Nothing.extension"))
test_builtin_private_repo_flag_is_not_a_credential()

A shipped extension gets private_repo=True with no credential at all.

Reading the flag as "needs auth" is what used to send public repos down an authenticated path, so it has to stay independent of the store.

Source code in pyrevitlib/pyrevit/unittests/test_credentials.py
def test_builtin_private_repo_flag_is_not_a_credential(self):
    """A shipped extension gets private_repo=True with no credential at all.

    Reading the flag as "needs auth" is what used to send public repos down
    an authenticated path, so it has to stay independent of the store.
    """
    self.user_config.add_section("pyRevitCore.extension")
    self.user_config.put_raw("pyRevitCore.extension", "private_repo", "true")
    self.assertIsNone(credentials.get_credential("pyRevitCore.extension"))
    self.assertFalse(credentials.has_credential("pyRevitCore.extension"))
test_migration_ignores_non_extension_sections()

A non-extension section is not an extension credential.

Deliberately not in MigrationTests: that class skips wholesale without DPAPI, and this case needs none.

Source code in pyrevitlib/pyrevit/unittests/test_credentials.py
def test_migration_ignores_non_extension_sections(self):
    """A non-extension section is not an extension credential.

    Deliberately not in MigrationTests: that class skips wholesale without
    DPAPI, and this case needs none.
    """
    self.seed_plaintext("core", token="ghp_abc123")
    self.assertEqual(0, credentials.migrate_legacy_credentials())
    self.assertEqual('"ghp_abc123"', self.user_config.raw("core", "token"))
test_migration_ignores_a_library_section_with_no_secret()
Source code in pyrevitlib/pyrevit/unittests/test_credentials.py
def test_migration_ignores_a_library_section_with_no_secret(self):
    self.seed_plaintext("Some.lib", username="alex")
    self.assertEqual(0, credentials.migrate_legacy_credentials())
    self.assertIsNone(self.user_config.raw("Some.lib", credentials.CONFIG_KEY))
setUp()
Source code in pyrevitlib/pyrevit/unittests/test_credentials.py
def setUp(self):
    import pyrevit.userconfig as userconfig

    self._real_user_config = userconfig.user_config
    self.user_config = _FakeUserConfig()
    userconfig.user_config = self.user_config
tearDown()
Source code in pyrevitlib/pyrevit/unittests/test_credentials.py
def tearDown(self):
    import pyrevit.userconfig as userconfig

    userconfig.user_config = self._real_user_config
    self.user_config.close()
seed_plaintext(section, **keys)

Write keys the way a pre-sealing pyRevit would have.

Source code in pyrevitlib/pyrevit/unittests/test_credentials.py
def seed_plaintext(self, section, **keys):
    """Write keys the way a pre-sealing pyRevit would have."""
    self.user_config.add_section(section)
    for key, value in keys.items():
        self.user_config.put_raw(section, key, json.dumps(value))

SealingTests

Bases: _CredentialsTestCase

The crypto contract, against real Windows DPAPI where available.

Methods:

setUp()
Source code in pyrevitlib/pyrevit/unittests/test_credentials.py
def setUp(self):
    _CredentialsTestCase.setUp(self)
    self._require_crypto()
test_round_trip_restores_the_credential()
Source code in pyrevitlib/pyrevit/unittests/test_credentials.py
def test_round_trip_restores_the_credential(self):
    credentials.set_credential("MyTool.extension", "oauth2", "ghp_abc123")
    stored = credentials.get_credential("MyTool.extension")
    self.assertEqual("oauth2", stored.username)
    self.assertEqual("ghp_abc123", stored.secret)
    self.assertEqual("token", stored.kind)
test_round_trip_restores_a_password_credential()
Source code in pyrevitlib/pyrevit/unittests/test_credentials.py
def test_round_trip_restores_a_password_credential(self):
    credentials.set_credential(
        "MyTool.extension", "alex", "s3cret", kind="password"
    )
    stored = credentials.get_credential("MyTool.extension")
    self.assertEqual("alex", stored.username)
    self.assertEqual("s3cret", stored.secret)
    self.assertEqual("password", stored.kind)
test_stored_value_does_not_contain_the_secret()
Source code in pyrevitlib/pyrevit/unittests/test_credentials.py
def test_stored_value_does_not_contain_the_secret(self):
    credentials.set_credential("MyTool.extension", "oauth2", "ghp_supersecret")
    raw = self.user_config.raw("MyTool.extension", credentials.CONFIG_KEY)
    self.assertNotIn("ghp_supersecret", raw)
test_repr_does_not_leak_the_secret()
Source code in pyrevitlib/pyrevit/unittests/test_credentials.py
def test_repr_does_not_leak_the_secret(self):
    cred = credentials.ExtensionCredential("oauth2", "ghp_supersecret")
    self.assertNotIn("ghp_supersecret", repr(cred))
test_two_seals_of_the_same_credential_differ()
Source code in pyrevitlib/pyrevit/unittests/test_credentials.py
def test_two_seals_of_the_same_credential_differ(self):
    credentials.set_credential("MyTool.extension", "oauth2", "ghp_abc123")
    first = self.user_config.raw("MyTool.extension", credentials.CONFIG_KEY)
    credentials.set_credential("MyTool.extension", "oauth2", "ghp_abc123")
    second = self.user_config.raw("MyTool.extension", credentials.CONFIG_KEY)
    self.assertNotEqual(first, second)
test_blank_username_is_rejected()
Source code in pyrevitlib/pyrevit/unittests/test_credentials.py
def test_blank_username_is_rejected(self):
    self.assertRaises(
        credentials.PyRevitCredentialError,
        credentials.set_credential,
        "MyTool.extension",
        "  ",
        "ghp_abc123",
    )
test_blank_secret_is_rejected()
Source code in pyrevitlib/pyrevit/unittests/test_credentials.py
def test_blank_secret_is_rejected(self):
    self.assertRaises(
        credentials.PyRevitCredentialError,
        credentials.set_credential,
        "MyTool.extension",
        "oauth2",
        "",
    )
test_rejected_write_leaves_nothing_behind()
Source code in pyrevitlib/pyrevit/unittests/test_credentials.py
def test_rejected_write_leaves_nothing_behind(self):
    self.assertRaises(
        credentials.PyRevitCredentialError,
        credentials.set_credential,
        "MyTool.extension",
        "oauth2",
        "",
    )
    self.assertIsNone(credentials.get_credential("MyTool.extension"))
tearDown()
Source code in pyrevitlib/pyrevit/unittests/test_credentials.py
def tearDown(self):
    import pyrevit.userconfig as userconfig

    userconfig.user_config = self._real_user_config
    self.user_config.close()
seed_plaintext(section, **keys)

Write keys the way a pre-sealing pyRevit would have.

Source code in pyrevitlib/pyrevit/unittests/test_credentials.py
def seed_plaintext(self, section, **keys):
    """Write keys the way a pre-sealing pyRevit would have."""
    self.user_config.add_section(section)
    for key, value in keys.items():
        self.user_config.put_raw(section, key, json.dumps(value))

UnreadableCredentialTests

Bases: _CredentialsTestCase

A stored value that will not decrypt must not look like no credential.

Methods:

setUp()
Source code in pyrevitlib/pyrevit/unittests/test_credentials.py
def setUp(self):
    _CredentialsTestCase.setUp(self)
    self._require_crypto()
test_undecryptable_value_raises_unavailable()
Source code in pyrevitlib/pyrevit/unittests/test_credentials.py
def test_undecryptable_value_raises_unavailable(self):
    self._store_garbage()
    self.assertRaises(
        credentials.PyRevitCredentialUnavailable,
        credentials.get_credential,
        "MyTool.extension",
    )
test_undecryptable_value_still_counts_as_configured()

A stored value counts as configured even when it cannot be read.

That is the difference between "re-enter your token" and "you never had one", and only the first is actionable.

Source code in pyrevitlib/pyrevit/unittests/test_credentials.py
def test_undecryptable_value_still_counts_as_configured(self):
    """A stored value counts as configured even when it cannot be read.

    That is the difference between "re-enter your token" and "you never had
    one", and only the first is actionable.
    """
    self._store_garbage()
    self.assertTrue(credentials.has_credential("MyTool.extension"))
test_tampered_value_raises_unavailable()
Source code in pyrevitlib/pyrevit/unittests/test_credentials.py
def test_tampered_value_raises_unavailable(self):
    credentials.set_credential("MyTool.extension", "oauth2", "ghp_abc123")
    stored = self.user_config.raw("MyTool.extension", credentials.CONFIG_KEY)
    # Flip a character in the middle of the base64 body.
    index = len(stored) // 2
    flipped = "A" if stored[index] != "A" else "B"
    self.user_config.put_raw(
        "MyTool.extension",
        credentials.CONFIG_KEY,
        stored[:index] + flipped + stored[index + 1 :],
    )
    self.assertRaises(
        credentials.PyRevitCredentialUnavailable,
        credentials.get_credential,
        "MyTool.extension",
    )
tearDown()
Source code in pyrevitlib/pyrevit/unittests/test_credentials.py
def tearDown(self):
    import pyrevit.userconfig as userconfig

    userconfig.user_config = self._real_user_config
    self.user_config.close()
seed_plaintext(section, **keys)

Write keys the way a pre-sealing pyRevit would have.

Source code in pyrevitlib/pyrevit/unittests/test_credentials.py
def seed_plaintext(self, section, **keys):
    """Write keys the way a pre-sealing pyRevit would have."""
    self.user_config.add_section(section)
    for key, value in keys.items():
        self.user_config.put_raw(section, key, json.dumps(value))

DeleteCredentialTests

Bases: _CredentialsTestCase

Clearing a credential must leave nothing that looks like one.

Methods:

setUp()
Source code in pyrevitlib/pyrevit/unittests/test_credentials.py
def setUp(self):
    _CredentialsTestCase.setUp(self)
    self._require_crypto()
test_delete_removes_the_credential_key()
Source code in pyrevitlib/pyrevit/unittests/test_credentials.py
def test_delete_removes_the_credential_key(self):
    credentials.set_credential("MyTool.extension", "oauth2", "ghp_abc123")
    self.assertTrue(credentials.delete_credential("MyTool.extension"))
    self.assertFalse(
        self.user_config.raw("MyTool.extension", credentials.CONFIG_KEY)
    )
    self.assertIsNone(credentials.get_credential("MyTool.extension"))
test_delete_clears_the_private_repo_flag()

A cleared credential must not leave the flag claiming one.

Source code in pyrevitlib/pyrevit/unittests/test_credentials.py
def test_delete_clears_the_private_repo_flag(self):
    """A cleared credential must not leave the flag claiming one."""
    credentials.set_credential("MyTool.extension", "oauth2", "ghp_abc123")
    credentials.delete_credential("MyTool.extension")
    self.assertIs(
        False,
        self.user_config.get_section("MyTool.extension").get_option("private_repo"),
    )
test_delete_on_a_section_without_a_credential_reports_nothing_removed()
Source code in pyrevitlib/pyrevit/unittests/test_credentials.py
def test_delete_on_a_section_without_a_credential_reports_nothing_removed(self):
    self.user_config.add_section("Public.extension")
    self.assertFalse(credentials.delete_credential("Public.extension"))
test_delete_on_a_missing_section_reports_nothing_removed()
Source code in pyrevitlib/pyrevit/unittests/test_credentials.py
def test_delete_on_a_missing_section_reports_nothing_removed(self):
    self.assertFalse(credentials.delete_credential("Gone.extension"))
tearDown()
Source code in pyrevitlib/pyrevit/unittests/test_credentials.py
def tearDown(self):
    import pyrevit.userconfig as userconfig

    userconfig.user_config = self._real_user_config
    self.user_config.close()
seed_plaintext(section, **keys)

Write keys the way a pre-sealing pyRevit would have.

Source code in pyrevitlib/pyrevit/unittests/test_credentials.py
def seed_plaintext(self, section, **keys):
    """Write keys the way a pre-sealing pyRevit would have."""
    self.user_config.add_section(section)
    for key, value in keys.items():
        self.user_config.put_raw(section, key, json.dumps(value))

ReadOnlyConfigTests

Bases: _CredentialsTestCase

An admin-locked config must refuse loudly, not accept and drop.

Methods:

setUp()
Source code in pyrevitlib/pyrevit/unittests/test_credentials.py
def setUp(self):
    _CredentialsTestCase.setUp(self)
    self.user_config = _FakeUserConfig(read_only=True)
    import pyrevit.userconfig as userconfig

    userconfig.user_config = self.user_config
test_set_refuses_on_a_read_only_config()
Source code in pyrevitlib/pyrevit/unittests/test_credentials.py
def test_set_refuses_on_a_read_only_config(self):
    self.assertRaises(
        credentials.PyRevitCredentialStoreReadOnly,
        credentials.set_credential,
        "MyTool.extension",
        "oauth2",
        "ghp_abc123",
    )
test_delete_refuses_on_a_read_only_config()
Source code in pyrevitlib/pyrevit/unittests/test_credentials.py
def test_delete_refuses_on_a_read_only_config(self):
    self.assertRaises(
        credentials.PyRevitCredentialStoreReadOnly,
        credentials.delete_credential,
        "MyTool.extension",
    )
test_is_available_is_false_on_a_read_only_config()
Source code in pyrevitlib/pyrevit/unittests/test_credentials.py
def test_is_available_is_false_on_a_read_only_config(self):
    self.assertFalse(credentials.is_available())
tearDown()
Source code in pyrevitlib/pyrevit/unittests/test_credentials.py
def tearDown(self):
    import pyrevit.userconfig as userconfig

    userconfig.user_config = self._real_user_config
    self.user_config.close()
seed_plaintext(section, **keys)

Write keys the way a pre-sealing pyRevit would have.

Source code in pyrevitlib/pyrevit/unittests/test_credentials.py
def seed_plaintext(self, section, **keys):
    """Write keys the way a pre-sealing pyRevit would have."""
    self.user_config.add_section(section)
    for key, value in keys.items():
        self.user_config.put_raw(section, key, json.dumps(value))

MigrationTests

Bases: _CredentialsTestCase

One-time sealing of the plaintext credentials older pyRevit wrote.

Methods:

setUp()
Source code in pyrevitlib/pyrevit/unittests/test_credentials.py
def setUp(self):
    _CredentialsTestCase.setUp(self)
    self._require_crypto()
test_token_only_section_is_migrated()
Source code in pyrevitlib/pyrevit/unittests/test_credentials.py
def test_token_only_section_is_migrated(self):
    self.seed_plaintext("MyTool.extension", token="ghp_abc123")
    self.assertEqual(1, credentials.migrate_legacy_credentials())

    stored = credentials.get_credential("MyTool.extension")
    self.assertEqual("ghp_abc123", stored.secret)
    self.assertEqual("oauth2", stored.username)
    self.assertIsNone(self.user_config.raw("MyTool.extension", "token"))
test_password_section_keeps_its_username()

A real username/password pair must survive the migration.

Source code in pyrevitlib/pyrevit/unittests/test_credentials.py
def test_password_section_keeps_its_username(self):
    """A real username/password pair must survive the migration."""
    self.seed_plaintext("MyTool.extension", username="alex", password="s3cret")
    self.assertEqual(1, credentials.migrate_legacy_credentials())

    stored = credentials.get_credential("MyTool.extension")
    self.assertEqual("alex", stored.username)
    self.assertEqual("s3cret", stored.secret)
    self.assertEqual("password", stored.kind)
    self.assertIsNone(self.user_config.raw("MyTool.extension", "username"))
    self.assertIsNone(self.user_config.raw("MyTool.extension", "password"))
test_cli_style_token_section_is_migrated()

--persist-credentials wrote token plus an oauth2/password mirror.

Source code in pyrevitlib/pyrevit/unittests/test_credentials.py
def test_cli_style_token_section_is_migrated(self):
    """--persist-credentials wrote token plus an oauth2/password mirror."""
    self.seed_plaintext(
        "MyTool.extension",
        token="ghp_abc123",
        username="oauth2",
        password="ghp_abc123",
    )
    self.assertEqual(1, credentials.migrate_legacy_credentials())
    stored = credentials.get_credential("MyTool.extension")
    self.assertEqual("ghp_abc123", stored.secret)
    for key in ("token", "username", "password"):
        self.assertIsNone(self.user_config.raw("MyTool.extension", key))
test_builtin_section_with_no_credential_is_left_alone()

private_repo=True on a shipped extension is not a credential.

Source code in pyrevitlib/pyrevit/unittests/test_credentials.py
def test_builtin_section_with_no_credential_is_left_alone(self):
    """private_repo=True on a shipped extension is not a credential."""
    self.seed_plaintext("pyRevitCore.extension", private_repo=True, disabled=False)
    self.assertEqual(0, credentials.migrate_legacy_credentials())
    self.assertIsNone(
        self.user_config.raw("pyRevitCore.extension", credentials.CONFIG_KEY)
    )
test_username_without_a_secret_is_not_migrated()

Sealing a username with no secret is refused.

A usable-looking entry that can never authenticate is worse than no entry.

Source code in pyrevitlib/pyrevit/unittests/test_credentials.py
def test_username_without_a_secret_is_not_migrated(self):
    """Sealing a username with no secret is refused.

    A usable-looking entry that can never authenticate is worse than no entry.
    """
    self.seed_plaintext("MyTool.extension", username="alex")
    self.assertEqual(0, credentials.migrate_legacy_credentials())
    self.assertIsNone(
        self.user_config.raw("MyTool.extension", credentials.CONFIG_KEY)
    )
test_already_migrated_section_is_idempotent()
Source code in pyrevitlib/pyrevit/unittests/test_credentials.py
def test_already_migrated_section_is_idempotent(self):
    self.seed_plaintext("MyTool.extension", token="ghp_abc123")
    self.assertEqual(1, credentials.migrate_legacy_credentials())
    self.assertEqual(0, credentials.migrate_legacy_credentials())

    first = self.user_config.raw("MyTool.extension", credentials.CONFIG_KEY)
    self.assertEqual(0, credentials.migrate_legacy_credentials())
    self.assertEqual(
        first, self.user_config.raw("MyTool.extension", credentials.CONFIG_KEY)
    )
test_migration_covers_a_section_for_an_uninstalled_extension()

The migration walks the config, not the installed packages.

A token for something temporarily uninstalled would otherwise stay in the clear indefinitely, because the extension manager never sees it.

Source code in pyrevitlib/pyrevit/unittests/test_credentials.py
def test_migration_covers_a_section_for_an_uninstalled_extension(self):
    """The migration walks the config, not the installed packages.

    A token for something temporarily uninstalled would otherwise stay in the
    clear indefinitely, because the extension manager never sees it.
    """
    self.seed_plaintext("Removed.extension", token="ghp_abc123")
    self.assertEqual(1, credentials.migrate_legacy_credentials())
    self.assertEqual(
        "ghp_abc123", credentials.get_credential("Removed.extension").secret
    )
test_multiple_sections_are_all_migrated()
Source code in pyrevitlib/pyrevit/unittests/test_credentials.py
def test_multiple_sections_are_all_migrated(self):
    self.seed_plaintext("A.extension", token="ghp_a")
    self.seed_plaintext("B.lib", username="alex", password="pw_b")
    self.assertEqual(2, credentials.migrate_legacy_credentials())
    self.assertEqual("ghp_a", credentials.get_credential("A.extension").secret)
    self.assertEqual("pw_b", credentials.get_credential("B.lib").secret)
tearDown()
Source code in pyrevitlib/pyrevit/unittests/test_credentials.py
def tearDown(self):
    import pyrevit.userconfig as userconfig

    userconfig.user_config = self._real_user_config
    self.user_config.close()
seed_plaintext(section, **keys)

Write keys the way a pre-sealing pyRevit would have.

Source code in pyrevitlib/pyrevit/unittests/test_credentials.py
def seed_plaintext(self, section, **keys):
    """Write keys the way a pre-sealing pyRevit would have."""
    self.user_config.add_section(section)
    for key, value in keys.items():
        self.user_config.put_raw(section, key, json.dumps(value))

MigrationNoDataLossTests

Bases: _CredentialsTestCase

A migration that cannot seal must keep the plaintext it could not replace.

Methods:

setUp()
Source code in pyrevitlib/pyrevit/unittests/test_credentials.py
def setUp(self):
    _CredentialsTestCase.setUp(self)
    self._require_crypto()
    self._real_seal = credentials._seal
    self._seal_fails = True

    def failing_seal(username, secret, kind):
        if self._seal_fails:
            raise credentials.PyRevitCredentialError("sealing is unavailable")
        return self._real_seal(username, secret, kind)

    credentials._seal = failing_seal
tearDown()
Source code in pyrevitlib/pyrevit/unittests/test_credentials.py
def tearDown(self):
    credentials._seal = self._real_seal
    _CredentialsTestCase.tearDown(self)
test_plaintext_survives_a_failed_seal()

A failed seal must leave the plaintext credential in place.

Clearing the legacy keys before the sealed value is verified destroys the only usable copy of a working token.

Source code in pyrevitlib/pyrevit/unittests/test_credentials.py
def test_plaintext_survives_a_failed_seal(self):
    """A failed seal must leave the plaintext credential in place.

    Clearing the legacy keys before the sealed value is verified destroys the
    only usable copy of a working token.
    """
    self.seed_plaintext("MyTool.extension", token="ghp_abc123")
    credentials.migrate_legacy_credentials()

    self.assertEqual(
        '"ghp_abc123"', self.user_config.raw("MyTool.extension", "token")
    )
    self.assertIsNone(
        self.user_config.raw("MyTool.extension", credentials.CONFIG_KEY)
    )
test_migration_reports_nothing_migrated_when_sealing_fails()
Source code in pyrevitlib/pyrevit/unittests/test_credentials.py
def test_migration_reports_nothing_migrated_when_sealing_fails(self):
    self.seed_plaintext("MyTool.extension", token="ghp_abc123")
    self.assertEqual(0, credentials.migrate_legacy_credentials())
test_a_failing_section_does_not_stop_the_others()

One bad section must not strand every other extension's token.

Source code in pyrevitlib/pyrevit/unittests/test_credentials.py
def test_a_failing_section_does_not_stop_the_others(self):
    """One bad section must not strand every other extension's token."""
    self.seed_plaintext("Good.extension", token="ghp_good")

    real_set = credentials.set_credential

    def selective_set(section_name, username, secret, kind="token"):
        if section_name == "Bad.extension":
            raise credentials.PyRevitCredentialError("sealing is unavailable")
        return real_set(section_name, username, secret, kind)

    # This class patches _seal to always fail, which would fail the good
    # section too and make the test pass for the wrong reason. The failure
    # under test is per-section, so put the working seal back and inject only
    # that. tearDown restores the failing one.
    credentials._seal = self._real_seal
    credentials.set_credential = selective_set
    self.seed_plaintext("Bad.extension", token="ghp_bad")

    credentials.migrate_legacy_credentials()
    credentials.set_credential = real_set

    self.assertEqual(
        "ghp_good", credentials.get_credential("Good.extension").secret
    )
    self.assertEqual('"ghp_bad"', self.user_config.raw("Bad.extension", "token"))
seed_plaintext(section, **keys)

Write keys the way a pre-sealing pyRevit would have.

Source code in pyrevitlib/pyrevit/unittests/test_credentials.py
def seed_plaintext(self, section, **keys):
    """Write keys the way a pre-sealing pyRevit would have."""
    self.user_config.add_section(section)
    for key, value in keys.items():
        self.user_config.put_raw(section, key, json.dumps(value))

DroppedFlushTests

Bases: _CredentialsTestCase

A write that is accepted into memory but never reaches the file.

save_changes swallows the failure, so the only way to notice is to read the file. The legacy keys must survive that case, which is the entire point of verifying before removing them.

Methods:

setUp()
Source code in pyrevitlib/pyrevit/unittests/test_credentials.py
def setUp(self):
    _CredentialsTestCase.setUp(self)
    self._require_crypto()
test_dropped_flush_keeps_the_legacy_token()

A flush that never lands must not cost the user their only token.

migrate_legacy_credentials catches per-section failures so one bad extension cannot strand the rest, so it reports zero migrated rather than propagating; the guarantee that matters is that the plaintext survives.

Source code in pyrevitlib/pyrevit/unittests/test_credentials.py
def test_dropped_flush_keeps_the_legacy_token(self):
    """A flush that never lands must not cost the user their only token.

    migrate_legacy_credentials catches per-section failures so one bad
    extension cannot strand the rest, so it reports zero migrated rather
    than propagating; the guarantee that matters is that the plaintext
    survives.
    """
    self.seed_plaintext("MyTool.extension", token="ghp_abc123")
    self.user_config.fail_saves = True

    self.assertEqual(0, credentials.migrate_legacy_credentials())
    self.assertEqual(
        '"ghp_abc123"', self.user_config.raw("MyTool.extension", "token")
    )
    self.assertIsNone(
        self.user_config.raw("MyTool.extension", credentials.CONFIG_KEY)
    )
test_dropped_flush_raises_rather_than_reporting_success()

set_credential must not claim success for a value that is not on disk.

Source code in pyrevitlib/pyrevit/unittests/test_credentials.py
def test_dropped_flush_raises_rather_than_reporting_success(self):
    """set_credential must not claim success for a value that is not on disk."""
    self.user_config.fail_saves = True
    self.assertRaises(
        credentials.PyRevitCredentialUnavailable,
        credentials.set_credential,
        "MyTool.extension",
        "oauth2",
        "ghp_abc123",
    )
test_dropped_flush_never_removes_a_working_credential()

A dropped flush must not cost the user a working credential.

This is the whole point of verifying before clearing anything.

Source code in pyrevitlib/pyrevit/unittests/test_credentials.py
def test_dropped_flush_never_removes_a_working_credential(self):
    """A dropped flush must not cost the user a working credential.

    This is the whole point of verifying before clearing anything.
    """
    self.seed_plaintext("MyTool.extension", token="ghp_abc123")
    credentials.migrate_legacy_credentials()
    working = self.user_config.raw("MyTool.extension", credentials.CONFIG_KEY)
    self.assertIsNotNone(working)

    self.user_config.fail_saves = True
    self.assertRaises(
        credentials.PyRevitCredentialUnavailable,
        credentials.set_credential,
        "MyTool.extension",
        "oauth2",
        "ghp_rotated",
    )
    self.assertEqual(
        working,
        self.user_config.raw("MyTool.extension", credentials.CONFIG_KEY),
    )
test_a_landing_flush_stores_the_credential()

The control case: with the file actually written, the store succeeds.

Without this, the three tests above would also pass against a verification that rejected every write.

Source code in pyrevitlib/pyrevit/unittests/test_credentials.py
def test_a_landing_flush_stores_the_credential(self):
    """The control case: with the file actually written, the store succeeds.

    Without this, the three tests above would also pass against a
    verification that rejected every write.
    """
    credentials.set_credential("MyTool.extension", "oauth2", "ghp_abc123")
    self.assertIsNotNone(
        self.user_config.raw("MyTool.extension", credentials.CONFIG_KEY)
    )
    self.assertEqual(
        "ghp_abc123", credentials.get_credential("MyTool.extension").secret
    )
test_dropped_cleanup_save_reports_the_plaintext_left_behind()

A sealed value that lands while the cleanup does not is not success.

The plaintext is the thing this change exists to remove, so reporting the write as done while the token is still readable in the file is the one outcome that must not pass silently.

Source code in pyrevitlib/pyrevit/unittests/test_credentials.py
def test_dropped_cleanup_save_reports_the_plaintext_left_behind(self):
    """A sealed value that lands while the cleanup does not is not success.

    The plaintext is the thing this change exists to remove, so reporting the
    write as done while the token is still readable in the file is the one
    outcome that must not pass silently.
    """
    self.seed_plaintext("MyTool.extension", token="ghp_abc123")
    self.user_config.fail_from_save = 2

    self.assertRaises(
        credentials.PyRevitCredentialUnavailable,
        credentials.set_credential,
        "MyTool.extension",
        "oauth2",
        "ghp_abc123",
    )
test_landing_cleanup_save_removes_the_plaintext()

The control case for the check above: nothing is left behind.

Source code in pyrevitlib/pyrevit/unittests/test_credentials.py
def test_landing_cleanup_save_removes_the_plaintext(self):
    """The control case for the check above: nothing is left behind."""
    self.seed_plaintext("MyTool.extension", token="ghp_abc123")
    credentials.set_credential("MyTool.extension", "oauth2", "ghp_abc123")

    self.assertIsNone(self._on_disk("MyTool.extension", "token"))
    self.assertEqual(
        "ghp_abc123", credentials.get_credential("MyTool.extension").secret
    )
test_delete_reports_a_credential_it_could_not_persist_as_removed()

delete_credential must not claim success for a dropped flush.

The Extensions dialog says the settings were saved on the strength of this return value, and a credential still in the file comes back on the next start.

Source code in pyrevitlib/pyrevit/unittests/test_credentials.py
def test_delete_reports_a_credential_it_could_not_persist_as_removed(self):
    """delete_credential must not claim success for a dropped flush.

    The Extensions dialog says the settings were saved on the strength of this
    return value, and a credential still in the file comes back on the next
    start.
    """
    credentials.set_credential("MyTool.extension", "oauth2", "ghp_abc123")
    self.user_config.fail_saves = True

    self.assertRaises(
        credentials.PyRevitCredentialUnavailable,
        credentials.delete_credential,
        "MyTool.extension",
    )
test_delete_that_lands_reports_removed()
Source code in pyrevitlib/pyrevit/unittests/test_credentials.py
def test_delete_that_lands_reports_removed(self):
    credentials.set_credential("MyTool.extension", "oauth2", "ghp_abc123")

    self.assertTrue(credentials.delete_credential("MyTool.extension"))
    self.assertFalse(credentials.has_credential("MyTool.extension"))
    self.assertIsNone(credentials.get_credential("MyTool.extension"))
test_unverifiable_config_does_not_leave_a_credential_in_memory()

A verification that cannot run must not leave the write in place.

get_credential reads the in-memory store, so a value left there after an unverifiable write is one a restart will not have.

Source code in pyrevitlib/pyrevit/unittests/test_credentials.py
def test_unverifiable_config_does_not_leave_a_credential_in_memory(self):
    """A verification that cannot run must not leave the write in place.

    get_credential reads the in-memory store, so a value left there after an
    unverifiable write is one a restart will not have.
    """
    credentials.set_credential("MyTool.extension", "oauth2", "ghp_abc123")
    self.user_config._config_file = ""

    self.assertRaises(
        credentials.PyRevitCredentialError,
        credentials.set_credential,
        "MyTool.extension",
        "oauth2",
        "ghp_rotated",
    )
    self.assertIsNone(credentials.get_credential("MyTool.extension"))
tearDown()
Source code in pyrevitlib/pyrevit/unittests/test_credentials.py
def tearDown(self):
    import pyrevit.userconfig as userconfig

    userconfig.user_config = self._real_user_config
    self.user_config.close()
seed_plaintext(section, **keys)

Write keys the way a pre-sealing pyRevit would have.

Source code in pyrevitlib/pyrevit/unittests/test_credentials.py
def seed_plaintext(self, section, **keys):
    """Write keys the way a pre-sealing pyRevit would have."""
    self.user_config.add_section(section)
    for key, value in keys.items():
        self.user_config.put_raw(section, key, json.dumps(value))

CredentialKindTests

Bases: _CredentialsTestCase

A password must never be recorded as a token, and vice versa.

Methods:

setUp()
Source code in pyrevitlib/pyrevit/unittests/test_credentials.py
def setUp(self):
    _CredentialsTestCase.setUp(self)
    self._require_crypto()
test_password_kind_survives_the_round_trip()
Source code in pyrevitlib/pyrevit/unittests/test_credentials.py
def test_password_kind_survives_the_round_trip(self):
    credentials.set_credential("MyTool.extension", "alex", "pw", kind="password")
    self.assertEqual(
        "password", credentials.get_credential("MyTool.extension").kind
    )
test_token_kind_survives_the_round_trip()
Source code in pyrevitlib/pyrevit/unittests/test_credentials.py
def test_token_kind_survives_the_round_trip(self):
    credentials.set_credential("MyTool.extension", "oauth2", "ghp_abc123")
    self.assertEqual("token", credentials.get_credential("MyTool.extension").kind)
test_unknown_kind_is_rejected_rather_than_coerced()

Defaulting to a token would store a password mislabelled, silently.

Source code in pyrevitlib/pyrevit/unittests/test_credentials.py
def test_unknown_kind_is_rejected_rather_than_coerced(self):
    """Defaulting to a token would store a password mislabelled, silently."""
    self.assertRaises(
        credentials.PyRevitCredentialError,
        credentials.set_credential,
        "MyTool.extension",
        "alex",
        "pw",
        "passwd",
    )
test_cli_style_migration_records_a_token_not_a_password()

A mirrored token is still a token, not a password.

Every legacy writer mirrored the token into password "for backwards compat", so preferring password labels every migrated token as one.

Source code in pyrevitlib/pyrevit/unittests/test_credentials.py
def test_cli_style_migration_records_a_token_not_a_password(self):
    """A mirrored token is still a token, not a password.

    Every legacy writer mirrored the token into password "for backwards
    compat", so preferring password labels every migrated token as one.
    """
    self.seed_plaintext(
        "MyTool.extension",
        token="ghp_abc123",
        username="oauth2",
        password="ghp_abc123",
    )
    self.assertEqual(1, credentials.migrate_legacy_credentials())
    self.assertEqual("token", credentials.get_credential("MyTool.extension").kind)
test_username_password_migration_records_a_password()
Source code in pyrevitlib/pyrevit/unittests/test_credentials.py
def test_username_password_migration_records_a_password(self):
    self.seed_plaintext("MyTool.extension", username="alex", password="pw")
    self.assertEqual(1, credentials.migrate_legacy_credentials())
    self.assertEqual(
        "password", credentials.get_credential("MyTool.extension").kind
    )
tearDown()
Source code in pyrevitlib/pyrevit/unittests/test_credentials.py
def tearDown(self):
    import pyrevit.userconfig as userconfig

    userconfig.user_config = self._real_user_config
    self.user_config.close()
seed_plaintext(section, **keys)

Write keys the way a pre-sealing pyRevit would have.

Source code in pyrevitlib/pyrevit/unittests/test_credentials.py
def seed_plaintext(self, section, **keys):
    """Write keys the way a pre-sealing pyRevit would have."""
    self.user_config.add_section(section)
    for key, value in keys.items():
        self.user_config.put_raw(section, key, json.dumps(value))

StoredFormatContractTests

Bases: _CredentialsTestCase

Pins the stored format, which is a C#/Python contract nothing else guards.

Both sides' docstrings say the format has to change together. The point of these is that the Python side is compared against the C# values rather than against a second copy of the same string: a Python literal equalling a Python literal still passes after the C# side changed, which is exactly the orphaning scenario this exists to catch.

Methods:

setUp()
Source code in pyrevitlib/pyrevit/unittests/test_credentials.py
def setUp(self):
    _CredentialsTestCase.setUp(self)
    self._require_crypto()
test_config_key_matches_the_csharp_constant()
Source code in pyrevitlib/pyrevit/unittests/test_credentials.py
def test_config_key_matches_the_csharp_constant(self):
    self.assertEqual(self._protector().ConfigKeyName, credentials.CONFIG_KEY)
test_legacy_keys_match_the_csharp_constants()
Source code in pyrevitlib/pyrevit/unittests/test_credentials.py
def test_legacy_keys_match_the_csharp_constants(self):
    protector = self._protector()
    self.assertEqual(
        (
            protector.LegacyTokenKeyName,
            protector.LegacyPasswordKeyName,
            protector.LegacyUsernameKeyName,
        ),
        credentials.LEGACY_CREDENTIAL_KEYS,
    )
test_credential_key_set_matches_the_csharp_list()

The Python key list must match the C# one.

The C# side keeps a single list for the admin-config merge and the CLI, so a key added there and not here would be cleared in only one of them.

Source code in pyrevitlib/pyrevit/unittests/test_credentials.py
def test_credential_key_set_matches_the_csharp_list(self):
    """The Python key list must match the C# one.

    The C# side keeps a single list for the admin-config merge and the CLI,
    so a key added there and not here would be cleared in only one of them.
    """
    self.assertEqual(
        list(self._protector().AllConfigKeyNames),
        [credentials.CONFIG_KEY] + list(credentials.LEGACY_CREDENTIAL_KEYS),
    )
test_accepted_kinds_match_the_csharp_enum()

The accepted kind names must be the C# enum's, as a set.

Order is not part of the contract - nothing looks a kind up by position - and it is not even stable across hosts: dir() sorts alphabetically under IronPython 2 but follows declaration order under CPython 3, so an ordered comparison passes on one engine and fails on the other.

Source code in pyrevitlib/pyrevit/unittests/test_credentials.py
def test_accepted_kinds_match_the_csharp_enum(self):
    """The accepted kind names must be the C# enum's, as a set.

    Order is not part of the contract - nothing looks a kind up by position -
    and it is not even stable across hosts: ``dir()`` sorts alphabetically
    under IronPython 2 but follows declaration order under CPython 3, so an
    ordered comparison passes on one engine and fails on the other.
    """
    kind_type = credentials._load_crypto()[2]
    self.assertEqual(
        set(("token", "password")),
        set(
            name.lower()
            for name in dir(kind_type)
            if not name.startswith("_") and name.lower() in ("token", "password")
        ),
    )
test_separator_cannot_corrupt_a_secret()

A secret full of separator characters must not shift the fields.

The '.' separator is only unambiguous because base64 cannot contain it.

Source code in pyrevitlib/pyrevit/unittests/test_credentials.py
def test_separator_cannot_corrupt_a_secret(self):
    """A secret full of separator characters must not shift the fields.

    The '.' separator is only unambiguous because base64 cannot contain it.
    """
    awkward_secret = "a.b+c/d=e"
    credentials.set_credential("T.extension", "a.b", awkward_secret)
    self.assertEqual(
        awkward_secret, credentials.get_credential("T.extension").secret
    )
test_username_containing_the_separator_does_not_shift_fields()
Source code in pyrevitlib/pyrevit/unittests/test_credentials.py
def test_username_containing_the_separator_does_not_shift_fields(self):
    credentials.set_credential("T.extension", "user.name", "pw")
    self.assertEqual(
        "user.name", credentials.get_credential("T.extension").username
    )
test_non_ascii_secret_survives()
Source code in pyrevitlib/pyrevit/unittests/test_credentials.py
def test_non_ascii_secret_survives(self):
    secret = "café-töken-日本語"
    credentials.set_credential("T.extension", "oauth2", secret)
    self.assertEqual(secret, credentials.get_credential("T.extension").secret)
tearDown()
Source code in pyrevitlib/pyrevit/unittests/test_credentials.py
def tearDown(self):
    import pyrevit.userconfig as userconfig

    userconfig.user_config = self._real_user_config
    self.user_config.close()
seed_plaintext(section, **keys)

Write keys the way a pre-sealing pyRevit would have.

Source code in pyrevitlib/pyrevit/unittests/test_credentials.py
def seed_plaintext(self, section, **keys):
    """Write keys the way a pre-sealing pyRevit would have."""
    self.user_config.add_section(section)
    for key, value in keys.items():
        self.user_config.put_raw(section, key, json.dumps(value))