gowalk-cicd 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +94 -0
- package/README.md +417 -0
- package/action/.daemux-version +1 -0
- package/action/MIGRATION.md +232 -0
- package/action/action.yml +973 -0
- package/action/prompts/generate_dependent_fields.prompt.yml +78 -0
- package/action/prompts/generate_descriptions.prompt.yml +59 -0
- package/action/scripts/app_context_scanner.py +239 -0
- package/action/scripts/asc_build_history.py +284 -0
- package/action/scripts/asc_common.py +246 -0
- package/action/scripts/asc_metadata_applier.py +368 -0
- package/action/scripts/asc_metadata_detector.py +250 -0
- package/action/scripts/asc_version_create.py +365 -0
- package/action/scripts/asc_version_fetch.py +274 -0
- package/action/scripts/asc_version_reuse.py +177 -0
- package/action/scripts/auto_detect.py +391 -0
- package/action/scripts/autoupdate_check.sh +118 -0
- package/action/scripts/cert_factory.py +190 -0
- package/action/scripts/cfg_io.py +27 -0
- package/action/scripts/cfg_resolve.py +147 -0
- package/action/scripts/commit_bot_changes.sh +82 -0
- package/action/scripts/creds_store.py +328 -0
- package/action/scripts/keychain.py +103 -0
- package/action/scripts/lookup_app_id.py +51 -0
- package/action/scripts/manage_marketing_version.py +337 -0
- package/action/scripts/metadata_constants.py +102 -0
- package/action/scripts/mmv_decide_create.py +181 -0
- package/action/scripts/mmv_floor_check.py +265 -0
- package/action/scripts/next_build_number.py +195 -0
- package/action/scripts/pbxproj_editor.py +220 -0
- package/action/scripts/prepare_signing.py +223 -0
- package/action/scripts/profile_io.py +64 -0
- package/action/scripts/profile_manager.py +307 -0
- package/action/scripts/read_config.py +315 -0
- package/action/scripts/resolve_marketing_version.py +173 -0
- package/action/scripts/set_app_store_whats_new.py +317 -0
- package/action/scripts/team_resolver.py +130 -0
- package/action/scripts/test_app_context_scanner.py +167 -0
- package/action/scripts/test_asc_build_history.py +221 -0
- package/action/scripts/test_asc_builds_prerelease.py +219 -0
- package/action/scripts/test_asc_common_timeouts.py +121 -0
- package/action/scripts/test_asc_metadata_applier.py +850 -0
- package/action/scripts/test_asc_metadata_detector.py +445 -0
- package/action/scripts/test_auto_detect.py +432 -0
- package/action/scripts/test_cfg_resolve_credentials.py +48 -0
- package/action/scripts/test_compute_next_version.py +95 -0
- package/action/scripts/test_compute_next_version_patch_backcompat.py +41 -0
- package/action/scripts/test_compute_next_version_strictness.py +67 -0
- package/action/scripts/test_fetch_versions.py +163 -0
- package/action/scripts/test_ground_truth_floor.py +104 -0
- package/action/scripts/test_manage_marketing_version.py +134 -0
- package/action/scripts/test_manage_marketing_version_autoroll.py +149 -0
- package/action/scripts/test_manage_marketing_version_floor.py +170 -0
- package/action/scripts/test_manage_marketing_version_match.py +187 -0
- package/action/scripts/test_mmv_409_classifier.py +231 -0
- package/action/scripts/test_mmv_autobump_persist_gate.py +131 -0
- package/action/scripts/test_mmv_decide_create.py +119 -0
- package/action/scripts/test_mmv_floor_check.py +158 -0
- package/action/scripts/test_mmv_floor_crosscheck.py +132 -0
- package/action/scripts/test_mmv_helpers.py +57 -0
- package/action/scripts/test_next_build_number.py +174 -0
- package/action/scripts/test_read_config_auto_detect.py +257 -0
- package/action/scripts/test_resolve_marketing_version.py +298 -0
- package/action/scripts/test_reuse_stale_editable.py +182 -0
- package/action/scripts/test_set_app_store_whats_new.py +71 -0
- package/action/scripts/test_team_fallback_wiring.py +157 -0
- package/action/scripts/test_team_resolver.py +249 -0
- package/action/scripts/tests_common.py +167 -0
- package/action/scripts/version_utils.py +373 -0
- package/android-action/.daemux-version +1 -0
- package/android-action/action.yml +92 -0
- package/android-action/scripts/android_config.py +130 -0
- package/android-action/scripts/bitrise_deploy.py +160 -0
- package/android-action/scripts/find_bundle.py +25 -0
- package/android-action/scripts/play_preflight.py +69 -0
- package/android-action/scripts/resolve_android.py +70 -0
- package/android-action/scripts/sign_bundle.py +70 -0
- package/android-action/scripts/test_android_config.py +97 -0
- package/android-action/scripts/test_bitrise_deploy.py +124 -0
- package/android-action/scripts/test_sign_bundle.py +82 -0
- package/bin/cli.mjs +63 -0
- package/package.json +57 -0
- package/src/install.mjs +208 -0
- package/templates/deploy.yml +150 -0
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Tests the integration wiring for auto-derived team_id:
|
|
4
|
+
|
|
5
|
+
1. ``prepare_signing.main()`` no longer fails with "missing env var: TEAM_ID"
|
|
6
|
+
when TEAM_ID is empty; it falls back to the team Apple assigned to the
|
|
7
|
+
provisioning profile (already available via provision_all_bundles).
|
|
8
|
+
|
|
9
|
+
2. ``cfg_resolve.derive_team_if_empty`` calls team_resolver.derive_team_id
|
|
10
|
+
only when input+config yield an empty team, and emits a labeled source
|
|
11
|
+
(``derived_from_asc_key``).
|
|
12
|
+
|
|
13
|
+
All ASC network calls are mocked.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import os
|
|
19
|
+
import sys
|
|
20
|
+
import unittest
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
from unittest import mock
|
|
23
|
+
|
|
24
|
+
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
25
|
+
|
|
26
|
+
import cfg_resolve # noqa: E402
|
|
27
|
+
import prepare_signing # noqa: E402
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class PrepareSigningTeamIdOptionalTests(unittest.TestCase):
|
|
31
|
+
"""prepare_signing.py: TEAM_ID env var must be optional now."""
|
|
32
|
+
|
|
33
|
+
def test_main_runs_without_team_id_when_profiles_return_team(self):
|
|
34
|
+
# Arrange: stub every external side-effect so main() can run end-to-end
|
|
35
|
+
# without a real Xcode project or ASC API.
|
|
36
|
+
env_patch = {
|
|
37
|
+
"ASC_KEY_ID": "KID",
|
|
38
|
+
"ASC_ISSUER_ID": "ISS",
|
|
39
|
+
"ASC_KEY_PATH": "/tmp/fake.p8",
|
|
40
|
+
"RUNNER_TEMP": "/tmp",
|
|
41
|
+
"PROJECT": "/tmp/fake.xcodeproj",
|
|
42
|
+
# TEAM_ID deliberately omitted — this is the bug we're fixing.
|
|
43
|
+
}
|
|
44
|
+
with mock.patch.dict(os.environ, env_patch, clear=True):
|
|
45
|
+
with mock.patch.object(prepare_signing, "make_jwt", return_value="T"):
|
|
46
|
+
with mock.patch.object(
|
|
47
|
+
prepare_signing,
|
|
48
|
+
"discover_signable_targets",
|
|
49
|
+
return_value=[
|
|
50
|
+
{
|
|
51
|
+
"name": "App",
|
|
52
|
+
"bundle_id": "com.example.app",
|
|
53
|
+
"config_ids": ["C1"],
|
|
54
|
+
}
|
|
55
|
+
],
|
|
56
|
+
):
|
|
57
|
+
with mock.patch.object(
|
|
58
|
+
prepare_signing,
|
|
59
|
+
"_load_or_create_cert",
|
|
60
|
+
return_value=("CERTID", b"FAKEP12", "password", False),
|
|
61
|
+
):
|
|
62
|
+
with mock.patch.object(
|
|
63
|
+
prepare_signing,
|
|
64
|
+
"provision_all_bundles",
|
|
65
|
+
return_value=(
|
|
66
|
+
[("com.example.app", "CI-com.example.app", "UUID")],
|
|
67
|
+
"DERIVED123", # effective_team from profile plist
|
|
68
|
+
),
|
|
69
|
+
):
|
|
70
|
+
with mock.patch.object(
|
|
71
|
+
prepare_signing, "patch_project_signing"
|
|
72
|
+
):
|
|
73
|
+
with mock.patch.object(
|
|
74
|
+
prepare_signing, "setup_keychain"
|
|
75
|
+
):
|
|
76
|
+
# Act: must not raise SystemExit.
|
|
77
|
+
prepare_signing.main()
|
|
78
|
+
|
|
79
|
+
def test_main_fails_with_actionable_message_when_nothing_yields_team(self):
|
|
80
|
+
env_patch = {
|
|
81
|
+
"ASC_KEY_ID": "KID",
|
|
82
|
+
"ASC_ISSUER_ID": "ISS",
|
|
83
|
+
"ASC_KEY_PATH": "/tmp/fake.p8",
|
|
84
|
+
"RUNNER_TEMP": "/tmp",
|
|
85
|
+
"PROJECT": "/tmp/fake.xcodeproj",
|
|
86
|
+
}
|
|
87
|
+
with mock.patch.dict(os.environ, env_patch, clear=True):
|
|
88
|
+
with mock.patch.object(prepare_signing, "make_jwt", return_value="T"):
|
|
89
|
+
with mock.patch.object(
|
|
90
|
+
prepare_signing,
|
|
91
|
+
"discover_signable_targets",
|
|
92
|
+
return_value=[
|
|
93
|
+
{"name": "A", "bundle_id": "b", "config_ids": ["C"]}
|
|
94
|
+
],
|
|
95
|
+
):
|
|
96
|
+
with mock.patch.object(
|
|
97
|
+
prepare_signing,
|
|
98
|
+
"_load_or_create_cert",
|
|
99
|
+
return_value=("CID", b"P12", "password", False),
|
|
100
|
+
):
|
|
101
|
+
with mock.patch.object(
|
|
102
|
+
prepare_signing,
|
|
103
|
+
"provision_all_bundles",
|
|
104
|
+
# Profile with no TeamIdentifier — impossible in
|
|
105
|
+
# practice but we still need a graceful message.
|
|
106
|
+
return_value=([("b", "CI-b", "UUID")], ""),
|
|
107
|
+
):
|
|
108
|
+
with mock.patch.object(
|
|
109
|
+
prepare_signing, "patch_project_signing"
|
|
110
|
+
):
|
|
111
|
+
with self.assertRaises(SystemExit) as ctx:
|
|
112
|
+
prepare_signing.main()
|
|
113
|
+
self.assertIn("team_id", str(ctx.exception).lower())
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
class CfgResolveDeriveTeamTests(unittest.TestCase):
|
|
117
|
+
"""cfg_resolve: derive_team_if_empty wires team_resolver into resolve_app."""
|
|
118
|
+
|
|
119
|
+
def test_derive_not_called_when_team_from_config(self):
|
|
120
|
+
"""If config already supplies team_id, no API call is made."""
|
|
121
|
+
with mock.patch("cfg_resolve.derive_team_id") as m_derive:
|
|
122
|
+
result = cfg_resolve.derive_team_if_empty(
|
|
123
|
+
team_val="ABC123",
|
|
124
|
+
team_src="config",
|
|
125
|
+
creds={"key_id": "K", "issuer_id": "I", "key_path": "/p"},
|
|
126
|
+
)
|
|
127
|
+
self.assertEqual(result, ("ABC123", "config"))
|
|
128
|
+
m_derive.assert_not_called()
|
|
129
|
+
|
|
130
|
+
def test_derive_called_when_team_empty_emits_labeled_source(self):
|
|
131
|
+
"""When team is empty, derive via ASC and label source."""
|
|
132
|
+
with mock.patch("cfg_resolve.make_jwt", return_value="T"):
|
|
133
|
+
with mock.patch(
|
|
134
|
+
"cfg_resolve.derive_team_id", return_value="DERIVED7X8"
|
|
135
|
+
) as m_derive:
|
|
136
|
+
result = cfg_resolve.derive_team_if_empty(
|
|
137
|
+
team_val="",
|
|
138
|
+
team_src="empty",
|
|
139
|
+
creds={"key_id": "K", "issuer_id": "I", "key_path": "/p"},
|
|
140
|
+
)
|
|
141
|
+
self.assertEqual(result, ("DERIVED7X8", "derived_from_asc_key"))
|
|
142
|
+
m_derive.assert_called_once_with("T")
|
|
143
|
+
|
|
144
|
+
def test_derive_keeps_empty_when_api_cant_determine(self):
|
|
145
|
+
"""If ASC lookup returns empty, keep empty — prepare_signing falls back."""
|
|
146
|
+
with mock.patch("cfg_resolve.make_jwt", return_value="T"):
|
|
147
|
+
with mock.patch("cfg_resolve.derive_team_id", return_value=""):
|
|
148
|
+
result = cfg_resolve.derive_team_if_empty(
|
|
149
|
+
team_val="",
|
|
150
|
+
team_src="empty",
|
|
151
|
+
creds={"key_id": "K", "issuer_id": "I", "key_path": "/p"},
|
|
152
|
+
)
|
|
153
|
+
self.assertEqual(result, ("", "empty"))
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
if __name__ == "__main__":
|
|
157
|
+
unittest.main()
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Tests for team_resolver.derive_team_id — the ASC-API-based Apple developer
|
|
4
|
+
team identifier derivation used when ``team_id`` is omitted from
|
|
5
|
+
``ci.config.yaml``.
|
|
6
|
+
|
|
7
|
+
Runs offline: every ASC API interaction is stubbed via unittest.mock.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import base64
|
|
13
|
+
import datetime
|
|
14
|
+
import plistlib
|
|
15
|
+
import subprocess
|
|
16
|
+
import sys
|
|
17
|
+
import unittest
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from unittest import mock
|
|
20
|
+
|
|
21
|
+
from cryptography import x509
|
|
22
|
+
from cryptography.hazmat.primitives import hashes, serialization
|
|
23
|
+
from cryptography.hazmat.primitives.asymmetric import rsa
|
|
24
|
+
from cryptography.x509.oid import NameOID
|
|
25
|
+
|
|
26
|
+
# Make sibling scripts importable regardless of the pytest invocation cwd.
|
|
27
|
+
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
28
|
+
|
|
29
|
+
import team_resolver # noqa: E402 (import-after-sys.path modification)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _make_dist_cert_der(team_id: str) -> bytes:
|
|
33
|
+
"""Generate a throwaway DER-encoded Apple-Distribution-style cert with OU=<team_id>."""
|
|
34
|
+
key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
|
|
35
|
+
subject = issuer = x509.Name(
|
|
36
|
+
[
|
|
37
|
+
x509.NameAttribute(
|
|
38
|
+
NameOID.COMMON_NAME,
|
|
39
|
+
f"Apple Distribution: Test Team ({team_id})",
|
|
40
|
+
),
|
|
41
|
+
x509.NameAttribute(NameOID.ORGANIZATIONAL_UNIT_NAME, team_id),
|
|
42
|
+
x509.NameAttribute(NameOID.ORGANIZATION_NAME, "Test Team"),
|
|
43
|
+
x509.NameAttribute(NameOID.COUNTRY_NAME, "US"),
|
|
44
|
+
]
|
|
45
|
+
)
|
|
46
|
+
now = datetime.datetime.now(datetime.timezone.utc)
|
|
47
|
+
cert = (
|
|
48
|
+
x509.CertificateBuilder()
|
|
49
|
+
.subject_name(subject)
|
|
50
|
+
.issuer_name(issuer)
|
|
51
|
+
.public_key(key.public_key())
|
|
52
|
+
.serial_number(x509.random_serial_number())
|
|
53
|
+
.not_valid_before(now)
|
|
54
|
+
.not_valid_after(now + datetime.timedelta(days=365))
|
|
55
|
+
.sign(key, hashes.SHA256())
|
|
56
|
+
)
|
|
57
|
+
return cert.public_bytes(serialization.Encoding.DER)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _make_profile_content(team_id: str) -> bytes:
|
|
61
|
+
"""Build a fake mobileprovision payload (plain plist, not CMS-signed).
|
|
62
|
+
|
|
63
|
+
team_resolver falls back to ``plistlib.loads`` directly when the payload
|
|
64
|
+
isn't a signed blob, so a raw plist is sufficient for the test path.
|
|
65
|
+
"""
|
|
66
|
+
plist_dict = {
|
|
67
|
+
"AppIDName": "Test",
|
|
68
|
+
"TeamIdentifier": [team_id],
|
|
69
|
+
"Name": "CI-com.example.test",
|
|
70
|
+
"UUID": "00000000-0000-0000-0000-000000000000",
|
|
71
|
+
}
|
|
72
|
+
return plistlib.dumps(plist_dict)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class DeriveTeamIdFromCertificateTests(unittest.TestCase):
|
|
76
|
+
"""Happy path: ASC returns at least one Distribution cert."""
|
|
77
|
+
|
|
78
|
+
def test_derives_team_from_cert_ou_when_cert_exists(self):
|
|
79
|
+
team_id = "QYVMNH6654"
|
|
80
|
+
cert_der = _make_dist_cert_der(team_id)
|
|
81
|
+
cert_b64 = base64.b64encode(cert_der).decode()
|
|
82
|
+
asc_response = {
|
|
83
|
+
"data": [
|
|
84
|
+
{
|
|
85
|
+
"id": "CERT123",
|
|
86
|
+
"attributes": {
|
|
87
|
+
"certificateType": "DISTRIBUTION",
|
|
88
|
+
"certificateContent": cert_b64,
|
|
89
|
+
},
|
|
90
|
+
}
|
|
91
|
+
]
|
|
92
|
+
}
|
|
93
|
+
with mock.patch.object(
|
|
94
|
+
team_resolver, "get_json", return_value=asc_response
|
|
95
|
+
) as m_get:
|
|
96
|
+
derived = team_resolver.derive_team_id("jwt-token")
|
|
97
|
+
self.assertEqual(derived, team_id)
|
|
98
|
+
m_get.assert_any_call(
|
|
99
|
+
"/certificates",
|
|
100
|
+
"jwt-token",
|
|
101
|
+
params={"limit": "1", "sort": "-id"},
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
def test_returns_team_even_when_cert_type_is_development(self):
|
|
105
|
+
team_id = "VD3WNXP8BH"
|
|
106
|
+
cert_der = _make_dist_cert_der(team_id)
|
|
107
|
+
cert_b64 = base64.b64encode(cert_der).decode()
|
|
108
|
+
asc_response = {
|
|
109
|
+
"data": [
|
|
110
|
+
{
|
|
111
|
+
"id": "DEVCERT",
|
|
112
|
+
"attributes": {
|
|
113
|
+
"certificateType": "DEVELOPMENT",
|
|
114
|
+
"certificateContent": cert_b64,
|
|
115
|
+
},
|
|
116
|
+
}
|
|
117
|
+
]
|
|
118
|
+
}
|
|
119
|
+
with mock.patch.object(team_resolver, "get_json", return_value=asc_response):
|
|
120
|
+
derived = team_resolver.derive_team_id("jwt-token")
|
|
121
|
+
self.assertEqual(derived, team_id)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
class DeriveTeamIdFromProfileFallbackTests(unittest.TestCase):
|
|
125
|
+
"""Fallback when no certs exist: use an existing provisioning profile's plist."""
|
|
126
|
+
|
|
127
|
+
def test_falls_back_to_profile_when_no_certs(self):
|
|
128
|
+
team_id = "ABC1234567"
|
|
129
|
+
profile_b64 = base64.b64encode(_make_profile_content(team_id)).decode()
|
|
130
|
+
cert_response = {"data": []}
|
|
131
|
+
profile_response = {
|
|
132
|
+
"data": [
|
|
133
|
+
{
|
|
134
|
+
"id": "PROF123",
|
|
135
|
+
"attributes": {
|
|
136
|
+
"name": "CI-com.example.test",
|
|
137
|
+
"profileContent": profile_b64,
|
|
138
|
+
},
|
|
139
|
+
}
|
|
140
|
+
]
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
def fake_get_json(path, token, *, params=None):
|
|
144
|
+
if path.startswith("/certificates"):
|
|
145
|
+
return cert_response
|
|
146
|
+
if path.startswith("/profiles"):
|
|
147
|
+
return profile_response
|
|
148
|
+
raise AssertionError(f"unexpected path {path}")
|
|
149
|
+
|
|
150
|
+
with mock.patch.object(team_resolver, "get_json", side_effect=fake_get_json):
|
|
151
|
+
derived = team_resolver.derive_team_id("jwt-token")
|
|
152
|
+
self.assertEqual(derived, team_id)
|
|
153
|
+
|
|
154
|
+
def test_handles_cms_signed_profile_via_security_tool(self):
|
|
155
|
+
"""Realistic profiles are CMS-signed; plistlib.loads raises.
|
|
156
|
+
|
|
157
|
+
team_resolver must shell out to ``security cms -D`` to decode them.
|
|
158
|
+
"""
|
|
159
|
+
team_id = "TEAM987654"
|
|
160
|
+
# Produce a blob that plistlib.loads can't parse directly.
|
|
161
|
+
fake_cms_blob = b"\x30\x82\x01\x00" + b"NOT-A-PLIST" * 20
|
|
162
|
+
profile_response = {
|
|
163
|
+
"data": [
|
|
164
|
+
{
|
|
165
|
+
"id": "P1",
|
|
166
|
+
"attributes": {
|
|
167
|
+
"name": "CI-com.example.test",
|
|
168
|
+
"profileContent": base64.b64encode(fake_cms_blob).decode(),
|
|
169
|
+
},
|
|
170
|
+
}
|
|
171
|
+
]
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
def fake_get_json(path, token, *, params=None):
|
|
175
|
+
if path.startswith("/certificates"):
|
|
176
|
+
return {"data": []}
|
|
177
|
+
return profile_response
|
|
178
|
+
|
|
179
|
+
decoded_plist = plistlib.dumps({"TeamIdentifier": [team_id]})
|
|
180
|
+
|
|
181
|
+
def fake_check_output(args, *a, **kw):
|
|
182
|
+
# args[0] == "security", args[1:3] == ("cms", "-D")
|
|
183
|
+
self.assertEqual(args[0], "security")
|
|
184
|
+
self.assertIn("cms", args)
|
|
185
|
+
return decoded_plist
|
|
186
|
+
|
|
187
|
+
with mock.patch.object(team_resolver, "get_json", side_effect=fake_get_json):
|
|
188
|
+
with mock.patch.object(
|
|
189
|
+
subprocess, "check_output", side_effect=fake_check_output
|
|
190
|
+
):
|
|
191
|
+
derived = team_resolver.derive_team_id("jwt-token")
|
|
192
|
+
self.assertEqual(derived, team_id)
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
class DeriveTeamIdFailureTests(unittest.TestCase):
|
|
196
|
+
"""Absolute failure path — no certs AND no profiles."""
|
|
197
|
+
|
|
198
|
+
def test_returns_empty_when_neither_cert_nor_profile_found(self):
|
|
199
|
+
def fake_get_json(path, token, *, params=None):
|
|
200
|
+
return {"data": []}
|
|
201
|
+
|
|
202
|
+
with mock.patch.object(team_resolver, "get_json", side_effect=fake_get_json):
|
|
203
|
+
derived = team_resolver.derive_team_id("jwt-token")
|
|
204
|
+
self.assertEqual(derived, "")
|
|
205
|
+
|
|
206
|
+
def test_returns_empty_on_certificate_without_ou(self):
|
|
207
|
+
"""Malformed cert with no OU attribute — must not crash, must return ''."""
|
|
208
|
+
key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
|
|
209
|
+
subject = x509.Name(
|
|
210
|
+
[x509.NameAttribute(NameOID.COMMON_NAME, "Malformed")]
|
|
211
|
+
)
|
|
212
|
+
now = datetime.datetime.now(datetime.timezone.utc)
|
|
213
|
+
cert = (
|
|
214
|
+
x509.CertificateBuilder()
|
|
215
|
+
.subject_name(subject)
|
|
216
|
+
.issuer_name(subject)
|
|
217
|
+
.public_key(key.public_key())
|
|
218
|
+
.serial_number(1)
|
|
219
|
+
.not_valid_before(now)
|
|
220
|
+
.not_valid_after(now + datetime.timedelta(days=30))
|
|
221
|
+
.sign(key, hashes.SHA256())
|
|
222
|
+
)
|
|
223
|
+
cert_b64 = base64.b64encode(
|
|
224
|
+
cert.public_bytes(serialization.Encoding.DER)
|
|
225
|
+
).decode()
|
|
226
|
+
cert_response = {
|
|
227
|
+
"data": [
|
|
228
|
+
{
|
|
229
|
+
"id": "BAD",
|
|
230
|
+
"attributes": {
|
|
231
|
+
"certificateType": "DISTRIBUTION",
|
|
232
|
+
"certificateContent": cert_b64,
|
|
233
|
+
},
|
|
234
|
+
}
|
|
235
|
+
]
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
def fake_get_json(path, token, *, params=None):
|
|
239
|
+
if path.startswith("/certificates"):
|
|
240
|
+
return cert_response
|
|
241
|
+
return {"data": []}
|
|
242
|
+
|
|
243
|
+
with mock.patch.object(team_resolver, "get_json", side_effect=fake_get_json):
|
|
244
|
+
derived = team_resolver.derive_team_id("jwt-token")
|
|
245
|
+
self.assertEqual(derived, "")
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
if __name__ == "__main__":
|
|
249
|
+
unittest.main()
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Shared test helpers for the manage_marketing_version test suite.
|
|
4
|
+
|
|
5
|
+
Imports the production module via `manage_marketing_version` so test
|
|
6
|
+
patches at the `mmv` namespace propagate through helper code paths
|
|
7
|
+
(``mmv.fetch_versions``, ``mmv.get_combined_floor``, etc. are seam
|
|
8
|
+
re-exports the helpers exercise late-bound).
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import io
|
|
14
|
+
import os
|
|
15
|
+
import sys
|
|
16
|
+
from contextlib import redirect_stderr
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
from unittest import mock
|
|
19
|
+
|
|
20
|
+
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
21
|
+
|
|
22
|
+
import manage_marketing_version as mmv # noqa: E402
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _v(version: str, state: str, vid: str = "", created: str = "") -> dict:
|
|
26
|
+
"""Build a minimal versions-list entry matching fetch_versions() shape."""
|
|
27
|
+
return {
|
|
28
|
+
"versionString": version,
|
|
29
|
+
"state": state,
|
|
30
|
+
"id": vid or f"id-{version}-{state}",
|
|
31
|
+
"createdDate": created,
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class _Env:
|
|
36
|
+
"""Scoped override of an env var so tests don't leak state."""
|
|
37
|
+
|
|
38
|
+
def __init__(self, name: str, value: str | None) -> None:
|
|
39
|
+
self._name = name
|
|
40
|
+
self._value = value
|
|
41
|
+
self._prev: str | None = None
|
|
42
|
+
self._had: bool = False
|
|
43
|
+
|
|
44
|
+
def __enter__(self) -> "_Env":
|
|
45
|
+
self._had = self._name in os.environ
|
|
46
|
+
self._prev = os.environ.get(self._name)
|
|
47
|
+
if self._value is None:
|
|
48
|
+
os.environ.pop(self._name, None)
|
|
49
|
+
else:
|
|
50
|
+
os.environ[self._name] = self._value
|
|
51
|
+
return self
|
|
52
|
+
|
|
53
|
+
def __exit__(self, *_exc) -> None:
|
|
54
|
+
if self._had:
|
|
55
|
+
os.environ[self._name] = self._prev or ""
|
|
56
|
+
else:
|
|
57
|
+
os.environ.pop(self._name, None)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
_BUILD_SETTING_TOKENS = (
|
|
61
|
+
"project.yml", "Info.plist", "xcconfig",
|
|
62
|
+
"MARKETING_VERSION", "CFBundleShortVersionString",
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _assert_mentions_build_setting_source(test, stderr: str) -> None:
|
|
67
|
+
"""The cross-check error message MUST name at least one build-setting
|
|
68
|
+
source so the user knows what to edit in their project."""
|
|
69
|
+
test.assertTrue(
|
|
70
|
+
any(tok in stderr for tok in _BUILD_SETTING_TOKENS),
|
|
71
|
+
f"error must reference a build-setting source; got {stderr!r}",
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _patch_floor(
|
|
76
|
+
ground_truth: str | None,
|
|
77
|
+
combined_floor: str | None = None,
|
|
78
|
+
*,
|
|
79
|
+
per_source: dict[str, str | None] | None = None,
|
|
80
|
+
):
|
|
81
|
+
"""Context-manager bundle for the two mmv-level floor seams. Tests use
|
|
82
|
+
this to control what _assert_above_ground_truth sees without reaching
|
|
83
|
+
into asc_version_fetch.
|
|
84
|
+
|
|
85
|
+
`per_source` overrides the per-source breakdown surfaced in the
|
|
86
|
+
error message; defaults to attributing the floor to appStoreVersions
|
|
87
|
+
only so legacy tests stay green."""
|
|
88
|
+
cf = combined_floor if combined_floor is not None else ground_truth
|
|
89
|
+
breakdown = per_source if per_source is not None else {
|
|
90
|
+
"appStoreVersions": cf,
|
|
91
|
+
"preReleaseVersions": None,
|
|
92
|
+
"buildsViaPreRelease": None,
|
|
93
|
+
}
|
|
94
|
+
return (
|
|
95
|
+
mock.patch.object(mmv, "get_ground_truth_floor", return_value=ground_truth),
|
|
96
|
+
mock.patch.object(mmv, "get_combined_floor", return_value=(cf, breakdown)),
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _run_decide_for_version(
|
|
101
|
+
target_version: str,
|
|
102
|
+
versions: list[dict],
|
|
103
|
+
*,
|
|
104
|
+
new_id: str = "new-created-id",
|
|
105
|
+
ground_truth: str | None = None,
|
|
106
|
+
combined_floor: str | None = None,
|
|
107
|
+
per_source: dict[str, str | None] | None = None,
|
|
108
|
+
):
|
|
109
|
+
"""
|
|
110
|
+
Invoke decide_for_version() with create_or_reuse stubbed to return
|
|
111
|
+
`new_id`. Returns (result, stderr_text, create_calls).
|
|
112
|
+
|
|
113
|
+
`combined_floor` controls what mmv.get_combined_floor returns; if None,
|
|
114
|
+
`ground_truth` doubles as the combined value too. `per_source` lets the
|
|
115
|
+
test inject a real breakdown so message-content assertions can verify
|
|
116
|
+
the binding-source name actually surfaces (the production path).
|
|
117
|
+
"""
|
|
118
|
+
buf = io.StringIO()
|
|
119
|
+
gt_patch, cf_patch = _patch_floor(
|
|
120
|
+
ground_truth, combined_floor, per_source=per_source,
|
|
121
|
+
)
|
|
122
|
+
with mock.patch.object(
|
|
123
|
+
mmv, "create_or_reuse_with_stale", return_value=new_id) as cor, \
|
|
124
|
+
gt_patch, cf_patch, \
|
|
125
|
+
redirect_stderr(buf):
|
|
126
|
+
result = mmv.decide_for_version(
|
|
127
|
+
target_version, versions, app_id="111", token="tok"
|
|
128
|
+
)
|
|
129
|
+
return result, buf.getvalue(), cor.call_args_list
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _assert_decide_exits(
|
|
133
|
+
test, target: str, versions: list[dict],
|
|
134
|
+
*, ground_truth: str | None, combined_floor: str | None = None,
|
|
135
|
+
per_source: dict[str, str | None] | None = None,
|
|
136
|
+
expected_code: int = 2,
|
|
137
|
+
) -> str:
|
|
138
|
+
"""Run decide_for_version expecting SystemExit. Returns the stderr text
|
|
139
|
+
so the caller can make message-content assertions."""
|
|
140
|
+
buf = io.StringIO()
|
|
141
|
+
gt_patch, cf_patch = _patch_floor(
|
|
142
|
+
ground_truth, combined_floor, per_source=per_source,
|
|
143
|
+
)
|
|
144
|
+
with gt_patch, cf_patch, redirect_stderr(buf):
|
|
145
|
+
with test.assertRaises(SystemExit) as ctx:
|
|
146
|
+
mmv.decide_for_version(target, versions, app_id="111", token="tok")
|
|
147
|
+
test.assertEqual(ctx.exception.code, expected_code)
|
|
148
|
+
return buf.getvalue()
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
class _FakeResponse:
|
|
152
|
+
"""Minimal stand-in for requests.Response."""
|
|
153
|
+
|
|
154
|
+
def __init__(self, payload: dict, status_code: int = 200) -> None:
|
|
155
|
+
self._payload = payload
|
|
156
|
+
self.status_code = status_code
|
|
157
|
+
self.text = ""
|
|
158
|
+
|
|
159
|
+
def json(self) -> dict:
|
|
160
|
+
return self._payload
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _mk_prerelease_entry(version: str, pid: str = "") -> dict:
|
|
164
|
+
return {
|
|
165
|
+
"id": pid or f"pr-{version}",
|
|
166
|
+
"attributes": {"version": version, "platform": "IOS"},
|
|
167
|
+
}
|