Repository: canonical/operator

Issue:
Ensure Secret.id includes model UUID
Currently `Secret._canonicalize_id` adds the `secret:` prefix if it's not there, but it doesn't add the model UUID. It would be helpful if it added the model UUID too, especially for cross-model relations. See more information at https://bugs.launchpad.net/juju/+bug/2075153

```python
# Current logic:
id if id.startswith('secret:') else f'secret:{id}'

# Change it to something like:
id if id.startswith('secret:') else f'secret://{model_uuid}/{id}'
```

Discussion:

Patch:
diff --git a/.github/workflows/tiobe.yaml b/.github/workflows/tiobe.yaml
index 6456792..6c79c22 100644
--- a/.github/workflows/tiobe.yaml
+++ b/.github/workflows/tiobe.yaml
@@ -18,8 +18,7 @@ jobs:
         run: pip install tox~=4.2 coverage[toml] flake8 pylint websocket-client==1.* pyyaml==6.* pytest~=7.2 pytest-operator~=0.23
       - name: Generate coverage report
         run: |
-          tox -e unit
-          coverage xml
+          tox -e coverage
           # Annoyingly, the coverage.xml file needs to be in a .coverage folder.
           rm .coverage
           mkdir .coverage
diff --git a/.gitignore b/.gitignore
index 30a07a5..c8b24c7 100644
--- a/.gitignore
+++ b/.gitignore
@@ -8,6 +8,7 @@ venv
 /docsenv
 .vscode
 .coverage
+coverage.xml
 /.tox
 .*.swp
 
diff --git a/ops/_main.py b/ops/_main.py
index a492846..07adc04 100644
--- a/ops/_main.py
+++ b/ops/_main.py
@@ -257,7 +257,7 @@ class _Dispatcher:
         # code is not triggered on config-changed.
         #
         # 'start' event is included as Juju does not fire the install event for
-        # K8s charms (see LP: #1854635).
+        # K8s charms https://bugs.launchpad.net/juju/+bug/1854635, fixed in juju 2.7.6 and 2.8
         if self.event_name in ('install', 'start', 'upgrade_charm') or self.event_name.endswith(
             '_storage_attached'
         ):
diff --git a/ops/charm.py b/ops/charm.py
index e6d35c3..dcd13d2 100644
--- a/ops/charm.py
+++ b/ops/charm.py
@@ -304,6 +304,7 @@ class ConfigChangedEvent(HookEvent):
       rescheduling, on unit upgrade or refresh, and so on.
     - As a specific instance of the above point: when networking changes
       (if the machine reboots and comes up with a different IP).
+    - When the app config changes, for example when `juju trust` is run.
 
     Any callback method bound to this event cannot assume that the
     software has already been started; it should not start stopped
diff --git a/ops/model.py b/ops/model.py
index 9102f30..336ef5a 100644
--- a/ops/model.py
+++ b/ops/model.py
@@ -30,6 +30,7 @@ import sys
 import tempfile
 import time
 import typing
+import warnings
 import weakref
 from abc import ABC, abstractmethod
 from pathlib import Path, PurePath
@@ -295,7 +296,7 @@ class Model:
             raise TypeError('Must provide an id or label, or both')
         if id is not None:
             # Canonicalize to "secret:<id>" form for consistency in backend calls.
-            id = Secret._canonicalize_id(id)
+            id = Secret._canonicalize_id(id, self.uuid)
         content = self._backend.secret_get(id=id, label=label)
         return Secret(self._backend, id=id, label=label, content=content)
 
@@ -1182,8 +1183,17 @@ class SecretInfo:
         rotation: Optional[SecretRotate],
         rotates: Optional[datetime.datetime],
         description: Optional[str] = None,
+        *,
+        model_uuid: Optional[str] = None,
     ):
-        self.id = Secret._canonicalize_id(id)
+        if model_uuid is None:
+            warnings.warn(
+                '`model_uuid` should always be provided when creating a '
+                'SecretInfo instance, and will be required in the future.',
+                DeprecationWarning,
+                stacklevel=2,
+            )
+        self.id = Secret._canonicalize_id(id, model_uuid)
         self.label = label
         self.revision = revision
         self.expires = expires
@@ -1192,7 +1202,9 @@ class SecretInfo:
         self.description = description
 
     @classmethod
-    def from_dict(cls, id: str, d: Dict[str, Any]) -> 'SecretInfo':
+    def from_dict(
+        cls, id: str, d: Dict[str, Any], model_uuid: Optional[str] = None
+    ) -> 'SecretInfo':
         """Create new SecretInfo object from ID and dict parsed from JSON."""
         expires = typing.cast(Optional[str], d.get('expiry'))
         try:
@@ -1208,6 +1220,7 @@ class SecretInfo:
             rotation=rotation,
             rotates=timeconv.parse_rfc3339(rotates) if rotates is not None else None,
             description=typing.cast(Optional[str], d.get('description')),
+            model_uuid=model_uuid,
         )
 
     def __repr__(self):
@@ -1249,7 +1262,7 @@ class Secret:
         if not (id or label):
             raise TypeError('Must provide an id or label, or both')
         if id is not None:
-            id = self._canonicalize_id(id)
+            id = self._canonicalize_id(id, backend.model_uuid)
         self._backend = backend
         self._id = id
         self._label = label
@@ -1264,11 +1277,13 @@ class Secret:
         return f"<Secret {' '.join(fields)}>"
 
     @staticmethod
-    def _canonicalize_id(id: str) -> str:
+    def _canonicalize_id(id: str, model_uuid: Optional[str]) -> str:
         """Return the canonical form of the given secret ID, with the 'secret:' prefix."""
         id = id.strip()
         if not id.startswith('secret:'):
-            id = f'secret:{id}'  # add the prefix if not there already
+            # Add the prefix and, if provided, model UUID.
+            id = f'secret:{id}' if model_uuid is None else f'secret://{model_uuid}/{id}'
+
         return id
 
     @classmethod
@@ -1308,8 +1323,8 @@ class Secret:
         """Locator ID (URI) for this secret.
 
         This has an unfortunate name for historical reasons, as it's not
-        really a unique identifier, but the secret's locator URI, which may or
-        may not include the model UUID (for cross-model secrets).
+        really a unique identifier, but the secret's locator URI, which will
+        include the model UUID (for cross-model secrets).
 
         Charms should treat this as an opaque string for looking up secrets
         and sharing them via relation data. If a charm-local "name" is needed
@@ -3604,7 +3619,9 @@ class _ModelBackend:
         result = self._run_for_secret('secret-info-get', *args, return_output=True, use_json=True)
         info_dicts = typing.cast(Dict[str, Any], result)
         id = list(info_dicts)[0]  # Juju returns dict of {secret_id: {info}}
-        return SecretInfo.from_dict(id, typing.cast(Dict[str, Any], info_dicts[id]))
+        return SecretInfo.from_dict(
+            id, typing.cast(Dict[str, Any], info_dicts[id]), model_uuid=self.model_uuid
+        )
 
     def secret_set(
         self,
diff --git a/ops/pebble.py b/ops/pebble.py
index 701b60d..7859415 100644
--- a/ops/pebble.py
+++ b/ops/pebble.py
@@ -797,7 +797,8 @@ class Plan:
     """Represents the effective Pebble configuration.
 
     A plan is the combined layer configuration. The layer configuration is
-    documented at https://github.com/canonical/pebble/#layer-specification.
+    documented at
+    https://canonical-pebble.readthedocs-hosted.com/en/latest/reference/layer-specification/
     """
 
     def __init__(self, raw: Optional[Union[str, PlanDict]] = None):
@@ -873,7 +874,7 @@ class Layer:
     """Represents a Pebble configuration layer.
 
     The format of this is documented at
-    https://github.com/canonical/pebble/#layer-specification.
+    https://canonical-pebble.readthedocs-hosted.com/en/latest/reference/layer-specification/
     """
 
     #: Summary of the purpose of this layer.
@@ -1567,7 +1568,7 @@ class Notice:
     last_repeated: datetime.datetime
     """The time this notice was last repeated.
 
-    See Pebble's `Notices documentation <https://github.com/canonical/pebble/#notices>`_
+    See Pebble's `Notices documentation <https://canonical-pebble.readthedocs-hosted.com/en/latest/reference/notices/>`_
     for an explanation of what "repeated" means.
     """
 
diff --git a/tox.ini b/tox.ini
index 72a4c62..2c7a249 100644
--- a/tox.ini
+++ b/tox.ini
@@ -113,6 +113,7 @@ deps =
 commands =
     coverage run --source={[vars]src_path} \
              -m pytest --ignore={[vars]tst_path}smoke -v --tb native {posargs}
+    coverage xml
     coverage report
 
 [testenv:pebble]


Test patch:
diff --git a/ops/testing.py b/ops/testing.py
index 40d60fc..dcabdf3 100644
--- a/ops/testing.py
+++ b/ops/testing.py
@@ -24,6 +24,7 @@ import ipaddress
 import os
 import pathlib
 import random
+import re
 import shutil
 import signal
 import tempfile
@@ -260,9 +261,10 @@ class Harness(Generic[CharmType]):
         actions: Optional[YAMLStringOrFile] = None,
         config: Optional[YAMLStringOrFile] = None,
     ):
-        if 'JUJU_VERSION' not in os.environ:
-            os.environ['JUJU_VERSION'] = '0.0.0'
-        self._juju_context = _JujuContext.from_dict(os.environ)
+        context_environ = os.environ.copy()
+        if 'JUJU_VERSION' not in context_environ:
+            context_environ['JUJU_VERSION'] = '0.0.0'
+        self._juju_context = _JujuContext.from_dict(context_environ)
         self._charm_cls = charm_cls
         self._charm: Optional[CharmType] = None
         self._charm_dir = 'no-disk-path'  # this may be updated by _create_meta
@@ -2701,7 +2703,7 @@ class _TestingModelBackend:
         return len(units) + 1  # Account for this unit.
 
     def _get_secret(self, id: str) -> Optional[_Secret]:
-        return next((s for s in self._secrets if s.id == id), None)
+        return next((s for s in self._secrets if self._secret_ids_are_equal(s.id, id)), None)
 
     def _ensure_secret(self, id: str) -> _Secret:
         secret = self._get_secret(id)
@@ -2723,6 +2725,22 @@ class _TestingModelBackend:
             )
         return secret
 
+    def _secret_ids_are_equal(self, id1: str, id2: str) -> bool:
+        secret_re = re.compile(
+            r'^(?:secret:)?(?://)?(?:(?P<uuid>[a-z0-9-]+)/)?(?P<id>[a-z0-9-]+)$', re.IGNORECASE
+        )
+        mo = secret_re.match(id1)
+        if not mo:
+            return False
+        model_uuid1 = mo.group('uuid') or self.model_uuid
+        id1 = mo.group('id')
+        mo = secret_re.match(id2)
+        if not mo:
+            return False
+        model_uuid2 = mo.group('uuid') or self.model_uuid
+        id2 = mo.group('id')
+        return model_uuid1 == model_uuid2 and id1 == id2
+
     def secret_get(
         self,
         *,
@@ -2815,6 +2833,7 @@ class _TestingModelBackend:
             rotation=rotation,
             rotates=rotates,
             description=secret.description,
+            model_uuid=self.model_uuid,
         )
 
     def secret_set(
@@ -2898,7 +2917,7 @@ class _TestingModelBackend:
             description=description,
         )
         self._secrets.append(secret)
-        return id
+        return id  # Note that this is the 'short' ID, not the canonicalised one.
 
     def secret_grant(self, id: str, relation_id: int, *, unit: Optional[str] = None) -> None:
         secret = self._ensure_secret(id)
@@ -2935,9 +2954,11 @@ class _TestingModelBackend:
                 secret.revisions = revisions
             else:
                 # Last revision removed, remove entire secret
-                self._secrets = [s for s in self._secrets if s.id != id]
+                self._secrets = [
+                    s for s in self._secrets if not self._secret_ids_are_equal(s.id, id)
+                ]
         else:
-            self._secrets = [s for s in self._secrets if s.id != id]
+            self._secrets = [s for s in self._secrets if not self._secret_ids_are_equal(s.id, id)]
 
     def open_port(self, protocol: str, port: Optional[int] = None):
         self._check_protocol_and_port(protocol, port)
diff --git a/test/test_model.py b/test/test_model.py
index c8266dc..f8af184 100644
--- a/test/test_model.py
+++ b/test/test_model.py
@@ -3482,11 +3482,13 @@ class TestSecrets:
         fake_script.write('secret-get', """echo '{"foo": "g"}'""")
 
         secret = model.get_secret(id='123')
-        assert secret.id == 'secret:123'
+        assert secret.id == f'secret://{model._backend.model_uuid}/123'
         assert secret.label is None
         assert secret.get_content() == {'foo': 'g'}
 
-        assert fake_script.calls(clear=True) == [['secret-get', 'secret:123', '--format=json']]
+        assert fake_script.calls(clear=True) == [
+            ['secret-get', f'secret://{model._backend.model_uuid}/123', '--format=json']
+        ]
 
     def test_get_secret_label(self, fake_script: FakeScript, model: ops.Model):
         fake_script.write('secret-get', """echo '{"foo": "g"}'""")
@@ -3502,12 +3504,18 @@ class TestSecrets:
         fake_script.write('secret-get', """echo '{"foo": "h"}'""")
 
         secret = model.get_secret(id='123', label='l')
-        assert secret.id == 'secret:123'
+        assert secret.id == f'secret://{model._backend.model_uuid}/123'
         assert secret.label == 'l'
         assert secret.get_content() == {'foo': 'h'}
 
         assert fake_script.calls(clear=True) == [
-            ['secret-get', 'secret:123', '--label', 'l', '--format=json']
+            [
+                'secret-get',
+                f'secret://{model._backend.model_uuid}/123',
+                '--label',
+                'l',
+                '--format=json',
+            ]
         ]
 
     def test_get_secret_no_args(self, model: ops.Model):
@@ -3537,7 +3545,7 @@ class TestSecrets:
         assert secret.unique_identifier is None
 
         secret = model.get_secret(id='123')
-        assert secret.id == 'secret:123'
+        assert secret.id == f'secret://{model._backend.model_uuid}/123'
         assert secret.unique_identifier == '123'
 
         secret = model.get_secret(id='secret:124')
@@ -3550,7 +3558,7 @@ class TestSecrets:
 
         assert fake_script.calls(clear=True) == [
             ['secret-get', '--label', 'lbl', '--format=json'],
-            ['secret-get', 'secret:123', '--format=json'],
+            ['secret-get', f'secret://{model._backend.model_uuid}/123', '--format=json'],
             ['secret-get', 'secret:124', '--format=json'],
             ['secret-get', 'secret://modeluuid/125', '--format=json'],
         ]
@@ -3566,8 +3574,9 @@ class TestSecretInfo:
             rotation=ops.SecretRotate.MONTHLY,
             rotates=datetime.datetime(2023, 1, 9, 14, 10, 0),
             description='desc',
+            model_uuid='abcd',
         )
-        assert info.id == 'secret:3'
+        assert info.id == 'secret://abcd/3'
         assert info.label == 'lbl'
         assert info.revision == 7
         assert info.expires == datetime.datetime(2022, 12, 9, 14, 10, 0)
@@ -3590,6 +3599,7 @@ class TestSecretInfo:
                 'rotates': '2023-01-09T14:10:00Z',
                 'description': 'desc',
             },
+            model_uuid='abcd',
         )
         assert info.id == 'secret:4'
         assert info.label == 'fromdict'
@@ -3600,14 +3610,15 @@ class TestSecretInfo:
         assert info.description == 'desc'
 
         info = ops.SecretInfo.from_dict(
-            'secret:4',
+            '4',
             {
                 'label': 'fromdict',
                 'revision': 8,
                 'rotation': 'badvalue',
             },
+            model_uuid='abcd',
         )
-        assert info.id == 'secret:4'
+        assert info.id == 'secret://abcd/4'
         assert info.label == 'fromdict'
         assert info.revision == 8
         assert info.expires is None
@@ -3619,11 +3630,15 @@ class TestSecretInfo:
         assert info.id == 'secret:5'
         assert info.revision == 9
 
+        info = ops.SecretInfo.from_dict('secret://abcd/6', {'revision': 9})
+        assert info.id == 'secret://abcd/6'
+        assert info.revision == 9
+
 
 class TestSecretClass:
     @pytest.fixture
     def model(self):
-        return ops.Model(ops.CharmMeta(), _ModelBackend('myapp/0'))
+        return ops.Model(ops.CharmMeta(), _ModelBackend('myapp/0', model_uuid='abcd'))
 
     def make_secret(
         self,
@@ -3636,11 +3651,11 @@ class TestSecretClass:
 
     def test_id_and_label(self, model: ops.Model):
         secret = self.make_secret(model, id=' abc ', label='lbl')
-        assert secret.id == 'secret:abc'
+        assert secret.id == f'secret://{model._backend.model_uuid}/abc'
         assert secret.label == 'lbl'
 
         secret = self.make_secret(model, id='x')
-        assert secret.id == 'secret:x'
+        assert secret.id == f'secret://{model._backend.model_uuid}/x'
         assert secret.label is None
 
         secret = self.make_secret(model, label='y')
@@ -3664,7 +3679,7 @@ class TestSecretClass:
         assert content == {'foo': 'refreshed'}
 
         assert fake_script.calls(clear=True) == [
-            ['secret-get', 'secret:y', '--refresh', '--format=json']
+            ['secret-get', f'secret://{model._backend.model_uuid}/y', '--refresh', '--format=json']
         ]
 
     def test_get_content_uncached(self, model: ops.Model, fake_script: FakeScript):
@@ -3674,7 +3689,9 @@ class TestSecretClass:
         content = secret.get_content()
         assert content == {'foo': 'notcached'}
 
-        assert fake_script.calls(clear=True) == [['secret-get', 'secret:z', '--format=json']]
+        assert fake_script.calls(clear=True) == [
+            ['secret-get', f'secret://{model._backend.model_uuid}/z', '--format=json']
+        ]
 
     def test_get_content_copies_dict(self, model: ops.Model, fake_script: FakeScript):
         fake_script.write('secret-get', """echo '{"foo": "bar"}'""")
@@ -3685,7 +3702,9 @@ class TestSecretClass:
         content['new'] = 'value'
         assert secret.get_content() == {'foo': 'bar'}
 
-        assert fake_script.calls(clear=True) == [['secret-get', 'secret:z', '--format=json']]
+        assert fake_script.calls(clear=True) == [
+            ['secret-get', f'secret://{model._backend.model_uuid}/z', '--format=json']
+        ]
 
     def test_peek_content(self, model: ops.Model, fake_script: FakeScript):
         fake_script.write('secret-get', """echo '{"foo": "peeked"}'""")
@@ -3695,7 +3714,14 @@ class TestSecretClass:
         assert content == {'foo': 'peeked'}
 
         assert fake_script.calls(clear=True) == [
-            ['secret-get', 'secret:a', '--label', 'b', '--peek', '--format=json']
+            [
+                'secret-get',
+                f'secret://{model._backend.model_uuid}/a',
+                '--label',
+                'b',
+                '--peek',
+                '--format=json',
+            ]
         ]
 
     def test_get_info(self, model: ops.Model, fake_script: FakeScript):
@@ -3704,28 +3730,28 @@ class TestSecretClass:
         # Secret with ID only
         secret = self.make_secret(model, id='x')
         info = secret.get_info()
-        assert info.id == 'secret:x'
+        assert info.id == f'secret://{model._backend.model_uuid}/x'
         assert info.label == 'y'
         assert info.revision == 7
 
         # Secret with label only
         secret = self.make_secret(model, label='y')
         info = secret.get_info()
-        assert info.id == 'secret:x'
+        assert info.id == f'secret://{model._backend.model_uuid}/x'
         assert info.label == 'y'
         assert info.revision == 7
 
         # Secret with ID and label
         secret = self.make_secret(model, id='x', label='y')
         info = secret.get_info()
-        assert info.id == 'secret:x'
+        assert info.id == f'secret://{model._backend.model_uuid}/x'
         assert info.label == 'y'
         assert info.revision == 7
 
         assert fake_script.calls(clear=True) == [
-            ['secret-info-get', 'secret:x', '--format=json'],
+            ['secret-info-get', f'secret://{model._backend.model_uuid}/x', '--format=json'],
             ['secret-info-get', '--label', 'y', '--format=json'],
-            ['secret-info-get', 'secret:x', '--format=json'],
+            ['secret-info-get', f'secret://{model._backend.model_uuid}/x', '--format=json'],
         ]
 
     def test_set_content(self, model: ops.Model, fake_script: FakeScript):
@@ -3739,15 +3765,15 @@ class TestSecretClass:
         secret = self.make_secret(model, label='y')
         assert secret.id is None
         secret.set_content({'bar': 'foo'})
-        assert secret.id == 'secret:z'
+        assert secret.id == f'secret://{model._backend.model_uuid}/z'
 
         with pytest.raises(ValueError):
             secret.set_content({'s': 't'})  # ensure it validates content (key too short)
 
         assert fake_script.calls(clear=True) == [
-            ['secret-set', 'secret:x', ANY],
+            ['secret-set', f'secret://{model._backend.model_uuid}/x', ANY],
             ['secret-info-get', '--label', 'y', '--format=json'],
-            ['secret-set', 'secret:z', ANY],
+            ['secret-set', f'secret://{model._backend.model_uuid}/z', ANY],
         ]
         assert fake_script.secrets() == {'foo': 'bar', 'bar': 'foo'}
 
@@ -3768,12 +3794,12 @@ class TestSecretClass:
         secret = self.make_secret(model, label='y')
         assert secret.id is None
         secret.set_info(label='lbl')
-        assert secret.id == 'secret:z'
+        assert secret.id == f'secret://{model._backend.model_uuid}/z'
 
         assert fake_script.calls(clear=True) == [
             [
                 'secret-set',
-                'secret:x',
+                f'secret://{model._backend.model_uuid}/x',
                 '--label',
                 'lab',
                 '--description',
@@ -3784,7 +3810,7 @@ class TestSecretClass:
                 'monthly',
             ],
             ['secret-info-get', '--label', 'y', '--format=json'],
-            ['secret-set', 'secret:z', '--label', 'lbl'],
+            ['secret-set', f'secret://{model._backend.model_uuid}/z', '--label', 'lbl'],
         ]
 
         with pytest.raises(TypeError):
@@ -3811,16 +3837,23 @@ class TestSecretClass:
         assert secret.id is None
         rel345 = ops.Relation('test', 345, True, unit, backend, cache)
         secret.grant(rel345)
-        assert secret.id == 'secret:z'
+        assert secret.id == f'secret://{model._backend.model_uuid}/z'
 
         assert fake_script.calls(clear=True) == [
             ['relation-list', '-r', '123', '--format=json'],
             ['relation-list', '-r', '234', '--format=json'],
-            ['secret-grant', 'secret:x', '--relation', '123'],
-            ['secret-grant', 'secret:x', '--relation', '234', '--unit', 'app/0'],
+            ['secret-grant', f'secret://{model._backend.model_uuid}/x', '--relation', '123'],
+            [
+                'secret-grant',
+                f'secret://{model._backend.model_uuid}/x',
+                '--relation',
+                '234',
+                '--unit',
+                'app/0',
+            ],
             ['relation-list', '-r', '345', '--format=json'],
             ['secret-info-get', '--label', 'y', '--format=json'],
-            ['secret-grant', 'secret:z', '--relation', '345'],
+            ['secret-grant', f'secret://{model._backend.model_uuid}/z', '--relation', '345'],
         ]
 
     def test_revoke(self, model: ops.Model, fake_script: FakeScript):
@@ -3841,16 +3874,23 @@ class TestSecretClass:
         assert secret.id is None
         rel345 = ops.Relation('test', 345, True, unit, model._backend, model._cache)
         secret.revoke(rel345)
-        assert secret.id == 'secret:z'
+        assert secret.id == f'secret://{model._backend.model_uuid}/z'
 
         assert fake_script.calls(clear=True) == [
             ['relation-list', '-r', '123', '--format=json'],
             ['relation-list', '-r', '234', '--format=json'],
-            ['secret-revoke', 'secret:x', '--relation', '123'],
-            ['secret-revoke', 'secret:x', '--relation', '234', '--unit', 'app/0'],
+            ['secret-revoke', f'secret://{model._backend.model_uuid}/x', '--relation', '123'],
+            [
+                'secret-revoke',
+                f'secret://{model._backend.model_uuid}/x',
+                '--relation',
+                '234',
+                '--unit',
+                'app/0',
+            ],
             ['relation-list', '-r', '345', '--format=json'],
             ['secret-info-get', '--label', 'y', '--format=json'],
-            ['secret-revoke', 'secret:z', '--relation', '345'],
+            ['secret-revoke', f'secret://{model._backend.model_uuid}/z', '--relation', '345'],
         ]
 
     def test_remove_revision(self, model: ops.Model, fake_script: FakeScript):
@@ -3864,12 +3904,12 @@ class TestSecretClass:
         secret = self.make_secret(model, label='y')
         assert secret.id is None
         secret.remove_revision(234)
-        assert secret.id == 'secret:z'
+        assert secret.id == f'secret://{model._backend.model_uuid}/z'
 
         assert fake_script.calls(clear=True) == [
-            ['secret-remove', 'secret:x', '--revision', '123'],
+            ['secret-remove', f'secret://{model._backend.model_uuid}/x', '--revision', '123'],
             ['secret-info-get', '--label', 'y', '--format=json'],
-            ['secret-remove', 'secret:z', '--revision', '234'],
+            ['secret-remove', f'secret://{model._backend.model_uuid}/z', '--revision', '234'],
         ]
 
     def test_remove_all_revisions(self, model: ops.Model, fake_script: FakeScript):
@@ -3883,12 +3923,12 @@ class TestSecretClass:
         secret = self.make_secret(model, label='y')
         assert secret.id is None
         secret.remove_all_revisions()
-        assert secret.id == 'secret:z'
+        assert secret.id == f'secret://{model._backend.model_uuid}/z'
 
         assert fake_script.calls(clear=True) == [
-            ['secret-remove', 'secret:x'],
+            ['secret-remove', f'secret://{model._backend.model_uuid}/x'],
             ['secret-info-get', '--label', 'y', '--format=json'],
-            ['secret-remove', 'secret:z'],
+            ['secret-remove', f'secret://{model._backend.model_uuid}/z'],
         ]
 
 
diff --git a/test/test_real_pebble.py b/test/test_real_pebble.py
index b3abac3..a788f72 100644
--- a/test/test_real_pebble.py
+++ b/test/test_real_pebble.py
@@ -322,12 +322,6 @@ class TestPebbleStorageAPIsUsingRealPebble(PebbleStorageAPIsTestMixin):
         yield pebble_dir
         shutil.rmtree(pebble_dir)
 
-    # Remove this entirely once the associated bug is fixed; it overrides the original test in the
-    # test mixin class.
-    @pytest.mark.skip(reason='pending resolution of https://github.com/canonical/pebble/issues/80')
-    def test_make_dir_with_permission_mask(self):
-        pass
-
 
 @pytest.mark.skipif(
     os.getenv('RUN_REAL_PEBBLE_TESTS') != '1',
diff --git a/test/test_testing.py b/test/test_testing.py
index d49a64b..e9e34f3 100644
--- a/test/test_testing.py
+++ b/test/test_testing.py
@@ -6161,6 +6161,34 @@ class TestSecrets:
         with pytest.raises(RuntimeError):
             secret.remove_all_revisions()
 
+    def test_secret_id_variants(self, request: pytest.FixtureRequest):
+        harness = ops.testing.Harness(
+            ops.CharmBase, meta='name: webapp\nrequires:\n db:\n  interface: database'
+        )
+        request.addfinalizer(harness.cleanup)
+        harness.add_relation('db', 'database')
+        harness.begin()
+
+        # Local (app and unit) secrets, secrets that belong to other apps,
+        # and user secrets should have uniform ID behaviour.
+        app_secret = harness.model.app.add_secret({'password': '1234'})
+        unit_secret = harness.model.unit.add_secret({'password': '5678'})
+        remote_secret_id = harness.add_model_secret('database', {'password': 'abcd'})
+        harness.grant_secret(remote_secret_id, 'webapp')
+        remote_secret = harness.model.get_secret(id=remote_secret_id)
+        user_secret_id = harness.add_user_secret({'password': 'efgh'})
+        harness.grant_secret(user_secret_id, 'webapp')
+        user_secret = harness.model.get_secret(id=user_secret_id)
+
+        # Ensure that all three variants of the secret ID can be used to
+        # retrieve the secret from Harness.
+        for secret in (app_secret, unit_secret, remote_secret, user_secret):
+            id_only = secret.unique_identifier
+            id_with_prefix = f'secret:{id_only}'
+            id_with_model = f'secret://{harness.model.uuid}/{id_only}'
+            for uri in (id_only, id_with_prefix, id_with_model):
+                assert harness.model.get_secret(id=uri).get_content() == secret.get_content()
+
 
 class EventRecorder(ops.CharmBase):
     def __init__(self, framework: ops.Framework):