@mmerterden/multi-agent-pipeline 20.7.0 → 20.8.1

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.
Files changed (284) hide show
  1. package/CHANGELOG.md +21 -0
  2. package/LICENSE +0 -10
  3. package/docs/facts.json +1 -1
  4. package/manifest.json +285 -285
  5. package/package.json +3 -3
  6. package/pipeline/lib/redact.mjs +3 -2
  7. package/pipeline/scripts/_notices.mjs +1 -1
  8. package/pipeline/scripts/gen-skills-index.mjs +13 -1
  9. package/pipeline/scripts/pre-commit-check.sh +4 -0
  10. package/pipeline/skills/.skill-manifest.json +69 -69
  11. package/pipeline/skills/shared/README.md +70 -70
  12. package/pipeline/skills/shared/external/alarmkit/SKILL.md +373 -381
  13. package/pipeline/skills/shared/external/alarmkit/evals/evals.json +23 -18
  14. package/pipeline/skills/shared/external/alarmkit/references/alarmkit-patterns.md +328 -378
  15. package/pipeline/skills/shared/external/app-clips/SKILL.md +260 -160
  16. package/pipeline/skills/shared/external/app-clips/evals/evals.json +27 -27
  17. package/pipeline/skills/shared/external/app-clips/references/data-handoff-notifications-location.md +150 -83
  18. package/pipeline/skills/shared/external/app-clips/references/routing-and-experiences.md +135 -83
  19. package/pipeline/skills/shared/external/app-clips/references/size-capabilities-and-promotion.md +143 -85
  20. package/pipeline/skills/shared/external/app-intents/SKILL.md +302 -304
  21. package/pipeline/skills/shared/external/app-intents/evals/evals.json +21 -21
  22. package/pipeline/skills/shared/external/app-intents/references/appintents-advanced.md +594 -894
  23. package/pipeline/skills/shared/external/app-store-optimization/SKILL.md +345 -277
  24. package/pipeline/skills/shared/external/app-store-optimization/evals/evals.json +27 -23
  25. package/pipeline/skills/shared/external/app-store-optimization/references/keyword-research-methodology.md +107 -121
  26. package/pipeline/skills/shared/external/app-store-optimization/references/product-page-variants.md +145 -165
  27. package/pipeline/skills/shared/external/app-store-review/SKILL.md +306 -326
  28. package/pipeline/skills/shared/external/app-store-review/evals/evals.json +21 -21
  29. package/pipeline/skills/shared/external/app-store-review/references/privacy-manifest.md +105 -67
  30. package/pipeline/skills/shared/external/app-store-review/references/review-checklists.md +114 -101
  31. package/pipeline/skills/shared/external/apple-on-device-ai/SKILL.md +335 -360
  32. package/pipeline/skills/shared/external/apple-on-device-ai/evals/evals.json +24 -27
  33. package/pipeline/skills/shared/external/apple-on-device-ai/references/coreml-conversion.md +215 -322
  34. package/pipeline/skills/shared/external/apple-on-device-ai/references/coreml-optimization.md +161 -256
  35. package/pipeline/skills/shared/external/apple-on-device-ai/references/foundation-models.md +277 -387
  36. package/pipeline/skills/shared/external/apple-on-device-ai/references/mlx-swift.md +196 -210
  37. package/pipeline/skills/shared/external/authentication/SKILL.md +277 -381
  38. package/pipeline/skills/shared/external/authentication/evals/evals.json +25 -25
  39. package/pipeline/skills/shared/external/authentication/references/keychain-biometric.md +135 -178
  40. package/pipeline/skills/shared/external/authentication/references/passkeys.md +111 -147
  41. package/pipeline/skills/shared/external/avkit/SKILL.md +267 -364
  42. package/pipeline/skills/shared/external/avkit/evals/evals.json +26 -26
  43. package/pipeline/skills/shared/external/avkit/references/avkit-patterns.md +375 -493
  44. package/pipeline/skills/shared/external/background-processing/SKILL.md +274 -384
  45. package/pipeline/skills/shared/external/background-processing/evals/evals.json +22 -22
  46. package/pipeline/skills/shared/external/background-processing/references/background-task-patterns.md +173 -321
  47. package/pipeline/skills/shared/external/callkit-voip/SKILL.md +290 -371
  48. package/pipeline/skills/shared/external/callkit-voip/evals/evals.json +24 -24
  49. package/pipeline/skills/shared/external/callkit-voip/references/callkit-patterns.md +175 -343
  50. package/pipeline/skills/shared/external/cloudkit-sync/SKILL.md +292 -381
  51. package/pipeline/skills/shared/external/cloudkit-sync/evals/evals.json +33 -30
  52. package/pipeline/skills/shared/external/cloudkit-sync/references/cloudkit-patterns.md +227 -355
  53. package/pipeline/skills/shared/external/contacts-framework/SKILL.md +197 -346
  54. package/pipeline/skills/shared/external/contacts-framework/evals/evals.json +19 -21
  55. package/pipeline/skills/shared/external/contacts-framework/references/contacts-patterns.md +169 -308
  56. package/pipeline/skills/shared/external/core-bluetooth/SKILL.md +228 -376
  57. package/pipeline/skills/shared/external/core-bluetooth/evals/evals.json +25 -22
  58. package/pipeline/skills/shared/external/core-bluetooth/references/ble-patterns.md +257 -337
  59. package/pipeline/skills/shared/external/core-data/SKILL.md +302 -368
  60. package/pipeline/skills/shared/external/core-data/evals/evals.json +30 -27
  61. package/pipeline/skills/shared/external/core-motion/SKILL.md +235 -324
  62. package/pipeline/skills/shared/external/core-motion/evals/evals.json +31 -27
  63. package/pipeline/skills/shared/external/core-motion/references/motion-patterns.md +210 -310
  64. package/pipeline/skills/shared/external/core-nfc/SKILL.md +292 -366
  65. package/pipeline/skills/shared/external/core-nfc/evals/evals.json +22 -24
  66. package/pipeline/skills/shared/external/core-nfc/references/nfc-patterns.md +233 -329
  67. package/pipeline/skills/shared/external/coreml/SKILL.md +323 -367
  68. package/pipeline/skills/shared/external/coreml/evals/evals.json +24 -21
  69. package/pipeline/skills/shared/external/coreml/references/coreml-swift-integration.md +562 -565
  70. package/pipeline/skills/shared/external/cryptokit/SKILL.md +253 -394
  71. package/pipeline/skills/shared/external/cryptokit/evals/evals.json +20 -18
  72. package/pipeline/skills/shared/external/cryptokit/references/cryptokit-patterns.md +299 -488
  73. package/pipeline/skills/shared/external/debugging-instruments/SKILL.md +270 -323
  74. package/pipeline/skills/shared/external/debugging-instruments/evals/evals.json +27 -30
  75. package/pipeline/skills/shared/external/debugging-instruments/references/instruments-guide.md +167 -315
  76. package/pipeline/skills/shared/external/debugging-instruments/references/lldb-patterns.md +140 -193
  77. package/pipeline/skills/shared/external/device-integrity/SKILL.md +236 -353
  78. package/pipeline/skills/shared/external/device-integrity/evals/evals.json +25 -21
  79. package/pipeline/skills/shared/external/device-integrity/references/device-integrity-patterns.md +159 -197
  80. package/pipeline/skills/shared/external/energykit/SKILL.md +225 -392
  81. package/pipeline/skills/shared/external/energykit/evals/evals.json +29 -28
  82. package/pipeline/skills/shared/external/energykit/references/energykit-patterns.md +174 -470
  83. package/pipeline/skills/shared/external/eventkit-calendar/SKILL.md +261 -383
  84. package/pipeline/skills/shared/external/eventkit-calendar/evals/evals.json +25 -22
  85. package/pipeline/skills/shared/external/eventkit-calendar/references/eventkit-patterns.md +165 -268
  86. package/pipeline/skills/shared/external/healthkit/SKILL.md +252 -303
  87. package/pipeline/skills/shared/external/healthkit/evals/evals.json +24 -23
  88. package/pipeline/skills/shared/external/healthkit/references/healthkit-patterns.md +369 -523
  89. package/pipeline/skills/shared/external/homekit-matter/SKILL.md +233 -348
  90. package/pipeline/skills/shared/external/homekit-matter/evals/evals.json +27 -22
  91. package/pipeline/skills/shared/external/homekit-matter/references/matter-commissioning.md +199 -305
  92. package/pipeline/skills/shared/external/ios-accessibility/SKILL.md +368 -340
  93. package/pipeline/skills/shared/external/ios-accessibility/evals/evals.json +28 -27
  94. package/pipeline/skills/shared/external/ios-accessibility/references/a11y-patterns.md +314 -260
  95. package/pipeline/skills/shared/external/ios-accessibility/references/media-accessibility.md +97 -67
  96. package/pipeline/skills/shared/external/ios-accessibility/references/nutrition-labels.md +165 -101
  97. package/pipeline/skills/shared/external/ios-localization/SKILL.md +258 -371
  98. package/pipeline/skills/shared/external/ios-localization/evals/evals.json +23 -23
  99. package/pipeline/skills/shared/external/ios-localization/references/formatstyle-locale.md +283 -491
  100. package/pipeline/skills/shared/external/ios-localization/references/string-catalogs.md +313 -440
  101. package/pipeline/skills/shared/external/ios-networking/SKILL.md +265 -341
  102. package/pipeline/skills/shared/external/ios-networking/evals/evals.json +24 -24
  103. package/pipeline/skills/shared/external/ios-networking/references/background-websocket.md +425 -652
  104. package/pipeline/skills/shared/external/ios-networking/references/file-storage-patterns.md +143 -285
  105. package/pipeline/skills/shared/external/ios-networking/references/lightweight-clients.md +93 -53
  106. package/pipeline/skills/shared/external/ios-networking/references/network-framework.md +231 -456
  107. package/pipeline/skills/shared/external/ios-networking/references/urlsession-patterns.md +517 -784
  108. package/pipeline/skills/shared/external/ios-simulator/SKILL.md +265 -393
  109. package/pipeline/skills/shared/external/ios-simulator/evals/evals.json +21 -21
  110. package/pipeline/skills/shared/external/ios-simulator/references/simctl-commands.md +177 -270
  111. package/pipeline/skills/shared/external/live-activities/SKILL.md +318 -360
  112. package/pipeline/skills/shared/external/live-activities/evals/evals.json +21 -21
  113. package/pipeline/skills/shared/external/live-activities/references/activitykit-patterns.md +478 -710
  114. package/pipeline/skills/shared/external/localization-reuse-map/SKILL.md +3 -3
  115. package/pipeline/skills/shared/external/localization-reuse-map/example-mapping.json +1 -1
  116. package/pipeline/skills/shared/external/localization-reuse-map/reference/format-and-output.md +8 -7
  117. package/pipeline/skills/shared/external/localization-reuse-map/reference/publish-and-snapshot.md +2 -2
  118. package/pipeline/skills/shared/external/localization-reuse-map/reference/sources-and-recipes.md +5 -2
  119. package/pipeline/skills/shared/external/localization-reuse-map/scripts/_shared.py +100 -0
  120. package/pipeline/skills/shared/external/localization-reuse-map/scripts/build-artifact.py +45 -26
  121. package/pipeline/skills/shared/external/localization-reuse-map/scripts/build-spreadsheet.py +14 -16
  122. package/pipeline/skills/shared/external/localization-reuse-map/scripts/fetch-annotations.py +12 -5
  123. package/pipeline/skills/shared/external/localization-reuse-map/scripts/fetch-legacy-labels.py +2 -1
  124. package/pipeline/skills/shared/external/localization-reuse-map/scripts/publish-confluence.py +6 -5
  125. package/pipeline/skills/shared/external/localization-reuse-map/scripts/render-overlay.py +44 -18
  126. package/pipeline/skills/shared/external/localization-reuse-map/scripts/resolve-legacy-values.py +5 -2
  127. package/pipeline/skills/shared/external/localization-reuse-map/scripts/resolve-new-values.py +10 -11
  128. package/pipeline/skills/shared/external/localization-reuse-map/scripts/scan-screen-keys.py +4 -33
  129. package/pipeline/skills/shared/external/localization-reuse-map/scripts/verify-map.py +12 -59
  130. package/pipeline/skills/shared/external/mapkit-location/SKILL.md +297 -267
  131. package/pipeline/skills/shared/external/mapkit-location/evals/evals.json +28 -24
  132. package/pipeline/skills/shared/external/mapkit-location/references/mapkit-corelocation-patterns.md +378 -532
  133. package/pipeline/skills/shared/external/mapkit-location/references/mapkit-patterns.md +397 -499
  134. package/pipeline/skills/shared/external/metrickit-diagnostics/SKILL.md +165 -348
  135. package/pipeline/skills/shared/external/metrickit-diagnostics/evals/evals.json +26 -23
  136. package/pipeline/skills/shared/external/metrickit-diagnostics/references/metrickit-patterns.md +123 -130
  137. package/pipeline/skills/shared/external/musickit-audio/SKILL.md +189 -315
  138. package/pipeline/skills/shared/external/musickit-audio/evals/evals.json +22 -21
  139. package/pipeline/skills/shared/external/musickit-audio/references/musickit-patterns.md +181 -270
  140. package/pipeline/skills/shared/external/natural-language/SKILL.md +188 -340
  141. package/pipeline/skills/shared/external/natural-language/evals/evals.json +21 -21
  142. package/pipeline/skills/shared/external/natural-language/references/translation-patterns.md +171 -225
  143. package/pipeline/skills/shared/external/passkit-wallet/SKILL.md +258 -392
  144. package/pipeline/skills/shared/external/passkit-wallet/evals/evals.json +30 -29
  145. package/pipeline/skills/shared/external/passkit-wallet/references/wallet-passes.md +164 -231
  146. package/pipeline/skills/shared/external/pdfkit/SKILL.md +312 -344
  147. package/pipeline/skills/shared/external/pdfkit/evals/evals.json +19 -19
  148. package/pipeline/skills/shared/external/pdfkit/references/pdfkit-patterns.md +413 -624
  149. package/pipeline/skills/shared/external/pencilkit-drawing/SKILL.md +242 -358
  150. package/pipeline/skills/shared/external/pencilkit-drawing/evals/evals.json +25 -21
  151. package/pipeline/skills/shared/external/pencilkit-drawing/references/pencilkit-patterns.md +161 -226
  152. package/pipeline/skills/shared/external/permissionkit/SKILL.md +282 -400
  153. package/pipeline/skills/shared/external/permissionkit/evals/evals.json +27 -30
  154. package/pipeline/skills/shared/external/permissionkit/references/permissionkit-patterns.md +237 -350
  155. package/pipeline/skills/shared/external/photos-camera-media/SKILL.md +276 -325
  156. package/pipeline/skills/shared/external/photos-camera-media/references/av-playback.md +299 -545
  157. package/pipeline/skills/shared/external/photos-camera-media/references/camera-capture.md +344 -588
  158. package/pipeline/skills/shared/external/photos-camera-media/references/image-loading-caching.md +316 -660
  159. package/pipeline/skills/shared/external/photos-camera-media/references/photokit-patterns.md +270 -416
  160. package/pipeline/skills/shared/external/push-notifications/SKILL.md +312 -340
  161. package/pipeline/skills/shared/external/push-notifications/evals/evals.json +27 -26
  162. package/pipeline/skills/shared/external/push-notifications/references/notification-patterns.md +328 -485
  163. package/pipeline/skills/shared/external/push-notifications/references/rich-notifications.md +327 -560
  164. package/pipeline/skills/shared/external/realitykit-ar/SKILL.md +218 -410
  165. package/pipeline/skills/shared/external/realitykit-ar/evals/evals.json +24 -27
  166. package/pipeline/skills/shared/external/realitykit-ar/references/realitykit-patterns.md +221 -348
  167. package/pipeline/skills/shared/external/shareplay-activities/SKILL.md +222 -393
  168. package/pipeline/skills/shared/external/shareplay-activities/evals/evals.json +23 -24
  169. package/pipeline/skills/shared/external/shareplay-activities/references/shareplay-patterns.md +280 -420
  170. package/pipeline/skills/shared/external/skill-creator/template.md +7 -1
  171. package/pipeline/skills/shared/external/speech-recognition/SKILL.md +217 -421
  172. package/pipeline/skills/shared/external/speech-recognition/evals/evals.json +23 -26
  173. package/pipeline/skills/shared/external/speech-recognition/references/speechanalyzer-patterns.md +133 -125
  174. package/pipeline/skills/shared/external/storekit/SKILL.md +228 -204
  175. package/pipeline/skills/shared/external/storekit/evals/evals.json +27 -24
  176. package/pipeline/skills/shared/external/storekit/references/app-review-guidelines.md +98 -109
  177. package/pipeline/skills/shared/external/storekit/references/core-patterns.md +302 -241
  178. package/pipeline/skills/shared/external/storekit/references/storekit-advanced.md +356 -649
  179. package/pipeline/skills/shared/external/swift-api-design-guidelines/SKILL.md +274 -399
  180. package/pipeline/skills/shared/external/swift-api-design-guidelines/evals/evals.json +22 -24
  181. package/pipeline/skills/shared/external/swift-api-design-guidelines/references/argument-labels-and-parameters.md +107 -108
  182. package/pipeline/skills/shared/external/swift-api-design-guidelines/references/conventions-and-special-rules.md +93 -165
  183. package/pipeline/skills/shared/external/swift-api-design-guidelines/references/naming-and-clarity.md +99 -137
  184. package/pipeline/skills/shared/external/swift-api-design-guidelines/references/side-effects-and-mutating-pairs.md +77 -120
  185. package/pipeline/skills/shared/external/swift-architecture/SKILL.md +334 -350
  186. package/pipeline/skills/shared/external/swift-architecture/evals/evals.json +22 -22
  187. package/pipeline/skills/shared/external/swift-charts/SKILL.md +208 -394
  188. package/pipeline/skills/shared/external/swift-charts/evals/evals.json +27 -30
  189. package/pipeline/skills/shared/external/swift-charts/references/charts-patterns.md +351 -762
  190. package/pipeline/skills/shared/external/swift-codable/SKILL.md +339 -343
  191. package/pipeline/skills/shared/external/swift-codable/evals/evals.json +20 -20
  192. package/pipeline/skills/shared/external/swift-concurrency/SKILL.md +304 -351
  193. package/pipeline/skills/shared/external/swift-concurrency/evals/evals.json +27 -24
  194. package/pipeline/skills/shared/external/swift-concurrency/references/approachable-concurrency.md +65 -80
  195. package/pipeline/skills/shared/external/swift-concurrency/references/async-algorithms.md +48 -84
  196. package/pipeline/skills/shared/external/swift-concurrency/references/bridging-interop.md +134 -79
  197. package/pipeline/skills/shared/external/swift-concurrency/references/concurrency-patterns.md +145 -167
  198. package/pipeline/skills/shared/external/swift-concurrency/references/diagnostics.md +62 -50
  199. package/pipeline/skills/shared/external/swift-concurrency/references/swiftui-concurrency.md +92 -121
  200. package/pipeline/skills/shared/external/swift-concurrency/references/synchronization-primitives.md +177 -241
  201. package/pipeline/skills/shared/external/swift-formatstyle/SKILL.md +258 -234
  202. package/pipeline/skills/shared/external/swift-language/SKILL.md +342 -382
  203. package/pipeline/skills/shared/external/swift-language/evals/evals.json +24 -27
  204. package/pipeline/skills/shared/external/swift-language/references/swift-attributes-interop.md +79 -56
  205. package/pipeline/skills/shared/external/swift-language/references/swift-patterns-extended.md +297 -340
  206. package/pipeline/skills/shared/external/swift-security/SKILL.md +183 -162
  207. package/pipeline/skills/shared/external/swift-security/evals/evals.json +25 -25
  208. package/pipeline/skills/shared/external/swift-security/references/biometric-authentication.md +314 -469
  209. package/pipeline/skills/shared/external/swift-security/references/certificate-trust.md +411 -476
  210. package/pipeline/skills/shared/external/swift-security/references/common-anti-patterns.md +260 -530
  211. package/pipeline/skills/shared/external/swift-security/references/compliance-owasp-mapping.md +270 -477
  212. package/pipeline/skills/shared/external/swift-security/references/credential-storage-patterns.md +573 -571
  213. package/pipeline/skills/shared/external/swift-security/references/cryptokit-public-key.md +370 -441
  214. package/pipeline/skills/shared/external/swift-security/references/cryptokit-symmetric.md +332 -433
  215. package/pipeline/skills/shared/external/swift-security/references/keychain-access-control.md +346 -468
  216. package/pipeline/skills/shared/external/swift-security/references/keychain-fundamentals.md +375 -491
  217. package/pipeline/skills/shared/external/swift-security/references/keychain-item-classes.md +431 -432
  218. package/pipeline/skills/shared/external/swift-security/references/keychain-sharing.md +328 -425
  219. package/pipeline/skills/shared/external/swift-security/references/migration-legacy-stores.md +341 -579
  220. package/pipeline/skills/shared/external/swift-security/references/secure-enclave.md +397 -457
  221. package/pipeline/skills/shared/external/swift-security/references/testing-security-code.md +354 -614
  222. package/pipeline/skills/shared/external/swift-testing/SKILL.md +191 -175
  223. package/pipeline/skills/shared/external/swift-testing/evals/evals.json +26 -24
  224. package/pipeline/skills/shared/external/swift-testing/references/testing-advanced.md +81 -84
  225. package/pipeline/skills/shared/external/swift-testing/references/testing-patterns.md +317 -433
  226. package/pipeline/skills/shared/external/swiftdata/SKILL.md +394 -256
  227. package/pipeline/skills/shared/external/swiftdata/evals/evals.json +24 -24
  228. package/pipeline/skills/shared/external/swiftdata/references/core-data-coexistence.md +206 -402
  229. package/pipeline/skills/shared/external/swiftdata/references/indexing.md +59 -52
  230. package/pipeline/skills/shared/external/swiftdata/references/predicate-pitfalls.md +57 -33
  231. package/pipeline/skills/shared/external/swiftdata/references/swiftdata-advanced.md +354 -747
  232. package/pipeline/skills/shared/external/swiftdata/references/swiftdata-queries.md +300 -508
  233. package/pipeline/skills/shared/external/swiftlint/SKILL.md +175 -226
  234. package/pipeline/skills/shared/external/swiftlint/references/adoption-and-configuration.md +141 -208
  235. package/pipeline/skills/shared/external/swiftlint/references/custom-rules-and-analyze.md +100 -109
  236. package/pipeline/skills/shared/external/swiftlint/references/plugins-run-scripts-and-integrations.md +159 -179
  237. package/pipeline/skills/shared/external/swiftlint/references/rule-reference.md +383 -18
  238. package/pipeline/skills/shared/external/swiftlint/references/rules-suppressions-and-baselines.md +143 -229
  239. package/pipeline/skills/shared/external/swiftui-animation/SKILL.md +283 -366
  240. package/pipeline/skills/shared/external/swiftui-animation/references/animation-advanced.md +396 -608
  241. package/pipeline/skills/shared/external/swiftui-animation/references/core-animation-bridge.md +336 -385
  242. package/pipeline/skills/shared/external/swiftui-gestures/SKILL.md +239 -349
  243. package/pipeline/skills/shared/external/swiftui-gestures/references/gesture-patterns.md +228 -310
  244. package/pipeline/skills/shared/external/swiftui-layout-components/SKILL.md +260 -249
  245. package/pipeline/skills/shared/external/swiftui-layout-components/references/form.md +92 -74
  246. package/pipeline/skills/shared/external/swiftui-layout-components/references/grids.md +112 -177
  247. package/pipeline/skills/shared/external/swiftui-layout-components/references/list.md +61 -64
  248. package/pipeline/skills/shared/external/swiftui-layout-components/references/scrollview.md +94 -134
  249. package/pipeline/skills/shared/external/swiftui-liquid-glass/SKILL.md +193 -225
  250. package/pipeline/skills/shared/external/swiftui-liquid-glass/references/liquid-glass.md +173 -327
  251. package/pipeline/skills/shared/external/swiftui-navigation/SKILL.md +201 -168
  252. package/pipeline/skills/shared/external/swiftui-navigation/references/deeplinks.md +127 -150
  253. package/pipeline/skills/shared/external/swiftui-navigation/references/navigationstack.md +134 -133
  254. package/pipeline/skills/shared/external/swiftui-navigation/references/sheets.md +152 -117
  255. package/pipeline/skills/shared/external/swiftui-navigation/references/tabview.md +111 -138
  256. package/pipeline/skills/shared/external/swiftui-patterns/SKILL.md +316 -252
  257. package/pipeline/skills/shared/external/swiftui-patterns/references/architecture-patterns.md +341 -332
  258. package/pipeline/skills/shared/external/swiftui-patterns/references/deprecated-migration.md +547 -854
  259. package/pipeline/skills/shared/external/swiftui-patterns/references/design-polish.md +485 -537
  260. package/pipeline/skills/shared/external/swiftui-patterns/references/platform-and-sharing.md +417 -499
  261. package/pipeline/skills/shared/external/swiftui-performance/SKILL.md +213 -376
  262. package/pipeline/skills/shared/external/swiftui-performance/references/demystify-swiftui-performance-wwdc23.md +86 -175
  263. package/pipeline/skills/shared/external/swiftui-performance/references/optimizing-swiftui-performance-instruments.md +89 -195
  264. package/pipeline/skills/shared/external/swiftui-performance/references/understanding-hangs-in-your-app.md +95 -182
  265. package/pipeline/skills/shared/external/swiftui-performance/references/understanding-improving-swiftui-performance.md +71 -149
  266. package/pipeline/skills/shared/external/swiftui-performance/references/wwdc-session-sources.md +21 -27
  267. package/pipeline/skills/shared/external/swiftui-uikit-interop/SKILL.md +303 -295
  268. package/pipeline/skills/shared/external/swiftui-uikit-interop/references/hosting-migration.md +204 -387
  269. package/pipeline/skills/shared/external/swiftui-uikit-interop/references/representable-recipes.md +469 -683
  270. package/pipeline/skills/shared/external/swiftui-webkit/SKILL.md +140 -186
  271. package/pipeline/skills/shared/external/swiftui-webkit/references/loading-and-observation.md +75 -86
  272. package/pipeline/skills/shared/external/swiftui-webkit/references/local-content-and-custom-schemes.md +63 -60
  273. package/pipeline/skills/shared/external/swiftui-webkit/references/migration-and-fallbacks.md +69 -137
  274. package/pipeline/skills/shared/external/swiftui-webkit/references/navigation-and-javascript.md +95 -67
  275. package/pipeline/skills/shared/external/tipkit/SKILL.md +220 -335
  276. package/pipeline/skills/shared/external/tipkit/references/tipkit-patterns.md +356 -494
  277. package/pipeline/skills/shared/external/vision-framework/SKILL.md +260 -375
  278. package/pipeline/skills/shared/external/vision-framework/references/vision-requests.md +393 -515
  279. package/pipeline/skills/shared/external/vision-framework/references/visionkit-scanner.md +363 -539
  280. package/pipeline/skills/shared/external/weatherkit/SKILL.md +160 -315
  281. package/pipeline/skills/shared/external/weatherkit/references/weatherkit-patterns.md +288 -407
  282. package/pipeline/skills/shared/external/widgetkit/SKILL.md +224 -288
  283. package/pipeline/skills/shared/external/widgetkit/references/widgetkit-advanced.md +416 -719
  284. package/pipeline/skills/shared/external/NOTICE-swift-ios-skills.md +0 -39
@@ -1,655 +1,462 @@
1
- # Testing Keychain, CryptoKit, and Biometric Code
1
+ # Testing Security Code
2
2
 
3
- > Scope: Unit, integration, and CI patterns for validating keychain, CryptoKit, and biometric security code across simulator, CI runners, and physical devices.
3
+ The pattern that makes keychain code testable is a protocol between the app and the Security framework. Unit tests run against an in-memory implementation; tests that need the real keychain, the Secure Enclave or biometric hardware run on devices.
4
4
 
5
- **Protocol-based abstraction is the single most important pattern for testable security code.** Wrapping Security framework calls behind a Swift protocol lets you inject an in-memory mock for unit tests while reserving real keychain integration tests for physical devices. The core challenge is that keychain behavior differs dramatically across three environments - Xcode simulator, CI runner, and physical device - and tests that ignore these differences produce flaky failures, crashes, or false confidence.
5
+ The keychain does not behave the same on Simulator, on a headless CI runner and on a phone. Tests written without that in mind fail intermittently, crash on runners, or pass while proving nothing.
6
6
 
7
- This reference covers mock design, CryptoKit round-trip tests, Secure Enclave guards, biometric mocking, CI/CD keychain creation, simulator limitations, Swift Testing framework patterns, mutation testing, and OWASP MASTG validation. All code targets Swift 5.9+/6.0, iOS 17-18+, with iOS 26 post-quantum notes where applicable.
7
+ Targets: Swift 5.9 and Swift 6, iOS 17 and 18, with notes for the iOS 26 post-quantum APIs. Background: TN3137 (macOS keychain APIs and implementations), WWDC19 session 413 "Testing in Xcode", the WWDC24 Swift Testing sessions (10179 and 10195), Apple Platform Security, and the OWASP MASTG.
8
8
 
9
- Key sources: Apple TN3137 "On Mac keychain APIs and implementations," WWDC19-413 "Testing in Xcode," WWDC24-10179/10195 "Meet/Go further with Swift Testing," Apple Platform Security Guide, OWASP MASTG.
10
-
11
- ---
9
+ Related: [keychain-fundamentals.md](keychain-fundamentals.md), [cryptokit-public-key.md](cryptokit-public-key.md), [migration-legacy-stores.md](migration-legacy-stores.md), [common-anti-patterns.md](common-anti-patterns.md).
12
10
 
13
11
  ## Contents
14
12
 
15
- - [Protocol-Based Keychain Abstraction](#protocol-based-keychain-abstraction)
16
- - [KeychainServiceProtocol with Real and Mock Implementations](#keychainserviceprotocol-with-real-and-mock-implementations)
17
- - [Seven Mistakes AI Generators Make in Keychain Tests](#seven-mistakes-ai-generators-make-in-keychain-tests)
18
- - [Simulator vs. Device Testing Matrix](#simulator-vs-device-testing-matrix)
19
- - [Conditional Compilation and Runtime Guards](#conditional-compilation-and-runtime-guards)
20
- - [Essential Testing Patterns](#essential-testing-patterns)
21
- - [setUp/tearDown Cleanup for Real Keychain Tests](#setupteardown-cleanup-for-real-keychain-tests)
22
- - [No Cleanup - Flaky Across Runs](#no-cleanup-flaky-across-runs)
23
- - [Testing Error Paths with Injected Failures](#testing-error-paths-with-injected-failures)
24
- - [CryptoKit Round-Trip Tests (Simulator-Safe)](#cryptokit-round-trip-tests-simulator-safe)
25
- - [Secure Enclave Test Strategy - Protocol Fallback](#secure-enclave-test-strategy-protocol-fallback)
26
- - [SigningKeyProvider Protocol with SE/Software Implementations](#signingkeyprovider-protocol-with-sesoftware-implementations)
27
- - [Testing Secure Enclave Code](#testing-secure-enclave-code)
28
- - [Biometric Flow Testing - LAContext Mocking](#biometric-flow-testing-lacontext-mocking)
29
- - [Protocol-Based Approach (Preferred)](#protocol-based-approach-preferred)
30
- - [Biometric Scenarios to Cover](#biometric-scenarios-to-cover)
31
- - [CI/CD Pipeline Configuration](#cicd-pipeline-configuration)
32
- - [GitHub Actions](#github-actions)
33
- - [Common CI Error Reference](#common-ci-error-reference)
34
- - [Test Host App Requirement](#test-host-app-requirement)
35
- - [Xcode Test Plans - Separating Simulator from Device](#xcode-test-plans-separating-simulator-from-device)
36
- - [Swift Testing Framework Patterns](#swift-testing-framework-patterns)
37
- - [Advanced Patterns](#advanced-patterns)
38
- - [Migration Testing: UserDefaults to Keychain](#migration-testing-userdefaults-to-keychain)
39
- - [Performance Testing](#performance-testing)
40
- - [Mutation Testing](#mutation-testing)
41
- - [OWASP MASTG Keychain Validation](#owasp-mastg-keychain-validation)
42
- - [Conclusion](#conclusion)
43
- - [Summary Checklist](#summary-checklist)
44
-
45
- ## Protocol-Based Keychain Abstraction
46
-
47
- The foundation of testable keychain code is a protocol abstracting the four Security framework operations. Every view model, service, or manager that touches the keychain depends on this protocol, never on the Security framework directly.
48
-
49
- ### KeychainServiceProtocol with Real and Mock Implementations
13
+ - [A protocol in front of the keychain](#a-protocol-in-front-of-the-keychain)
14
+ - [Seven mistakes in generated keychain tests](#seven-mistakes-in-generated-keychain-tests)
15
+ - [Simulator versus device](#simulator-versus-device)
16
+ - [Core test patterns](#core-test-patterns)
17
+ - [Secure Enclave tests](#secure-enclave-tests)
18
+ - [Biometric flows](#biometric-flows)
19
+ - [CI configuration](#ci-configuration)
20
+ - [Test plans](#test-plans)
21
+ - [Swift Testing](#swift-testing)
22
+ - [Further techniques](#further-techniques)
23
+ - [Takeaways](#takeaways)
24
+ - [Checklist](#checklist)
25
+
26
+ ## A protocol in front of the keychain
50
27
 
51
28
  ```swift
52
29
  import Foundation
53
30
  import Security
54
31
 
55
- enum KeychainError: Error, Equatable {
56
- case duplicateItem
57
- case itemNotFound
58
- case authFailed
59
- case interactionNotAllowed
32
+ enum VaultStoreError: Error, Equatable {
33
+ case duplicateItem, itemNotFound, authFailed, userCanceled, interactionNotAllowed
60
34
  case unexpectedData
61
- case unhandledError(status: OSStatus)
35
+ case unhandled(status: OSStatus)
62
36
 
63
37
  init(status: OSStatus) {
64
38
  switch status {
65
- case errSecDuplicateItem: self = .duplicateItem
66
- case errSecItemNotFound: self = .itemNotFound
67
- case errSecAuthFailed: self = .authFailed
68
- case errSecInteractionNotAllowed: self = .interactionNotAllowed
69
- default: self = .unhandledError(status: status)
39
+ case errSecDuplicateItem: self = .duplicateItem
40
+ case errSecItemNotFound: self = .itemNotFound
41
+ case errSecAuthFailed: self = .authFailed
42
+ case errSecUserCanceled: self = .userCanceled
43
+ case errSecInteractionNotAllowed: self = .interactionNotAllowed
44
+ default: self = .unhandled(status: status)
70
45
  }
71
46
  }
72
47
  }
73
48
 
74
- protocol KeychainServiceProtocol: Sendable {
75
- func save(_ data: Data, forKey key: String) throws
76
- func read(forKey key: String) throws -> Data?
77
- func update(_ data: Data, forKey key: String) throws
78
- func delete(forKey key: String) throws
79
- func deleteAll() throws
49
+ protocol SecretStore: Sendable {
50
+ func put(_ secret: Data, for name: String) throws
51
+ func value(for name: String) throws -> Data?
52
+ func replace(_ secret: Data, for name: String) throws
53
+ func remove(for name: String) throws
54
+ func removeAll() throws
80
55
  }
81
56
  ```
82
57
 
83
- The real `KeychainService` implementation wraps `SecItem*` calls with the add-or-update pattern and proper `OSStatus` mapping (see `keychain-fundamentals.md` for the full implementation). Key points: `save` attempts update first to avoid `errSecDuplicateItem`; `delete` treats `errSecItemNotFound` as success; the class conforms to `@unchecked Sendable` with immutable stored properties.
58
+ The production implementation, `KeychainSecretStore`, follows the rules in [keychain-fundamentals.md](keychain-fundamentals.md): `put` calls `SecItemAdd` and, on `errSecDuplicateItem`, `SecItemUpdate` with the new data; `remove` treats `errSecItemNotFound` as success; it is a final class whose stored properties are all `let`, marked `@unchecked Sendable`.
84
59
 
85
- The mock replaces Security framework with a dictionary. Runs everywhere - simulator, CI, even Linux - with zero entitlement requirements. Supports injectable errors and call counting:
60
+ The test double keeps everything in a dictionary, counts calls and throws on request. It runs on Simulator, on CI and even on Linux, and needs no entitlements.
86
61
 
87
62
  ```swift
88
- final class MockKeychainService: KeychainServiceProtocol, @unchecked Sendable {
89
- var storage: [String: Data] = [:]
90
- var saveCallCount = 0
91
- var readCallCount = 0
92
- var deleteCallCount = 0
93
- var errorToThrow: KeychainError?
94
-
95
- func save(_ data: Data, forKey key: String) throws {
96
- if let error = errorToThrow { throw error }
97
- saveCallCount += 1
98
- storage[key] = data
99
- }
100
-
101
- func read(forKey key: String) throws -> Data? {
102
- if let error = errorToThrow { throw error }
103
- readCallCount += 1
104
- return storage[key]
105
- }
106
-
107
- func update(_ data: Data, forKey key: String) throws {
108
- if let error = errorToThrow { throw error }
109
- guard storage[key] != nil else { throw KeychainError.itemNotFound }
110
- storage[key] = data
111
- }
112
-
113
- func delete(forKey key: String) throws {
114
- if let error = errorToThrow { throw error }
115
- storage.removeValue(forKey: key)
116
- deleteCallCount += 1
63
+ final class InMemorySecretStore: SecretStore, @unchecked Sendable {
64
+ private let lock = NSLock()
65
+ private var rows: [String: Data] = [:]
66
+ private(set) var saveCount = 0
67
+ private(set) var readCount = 0
68
+ var failure: VaultStoreError?
69
+
70
+ func put(_ secret: Data, for name: String) throws {
71
+ try locked { saveCount += 1; rows[name] = secret }
72
+ }
73
+ func value(for name: String) throws -> Data? {
74
+ try locked { readCount += 1; return rows[name] }
75
+ }
76
+ func replace(_ secret: Data, for name: String) throws {
77
+ try locked {
78
+ guard rows[name] != nil else { throw VaultStoreError.itemNotFound }
79
+ rows[name] = secret
80
+ }
117
81
  }
82
+ func remove(for name: String) throws { try locked { rows[name] = nil } }
83
+ func removeAll() throws { try locked { rows.removeAll() } }
118
84
 
119
- func deleteAll() throws {
120
- if let error = errorToThrow { throw error }
121
- storage.removeAll()
85
+ private func locked<T>(_ body: () throws -> T) throws -> T {
86
+ lock.lock(); defer { lock.unlock() }
87
+ if let failure { throw failure }
88
+ return try body()
122
89
  }
123
90
  }
124
91
  ```
125
92
 
126
- Business logic depends only on the protocol - never on `SecItem*` directly:
93
+ Code under test depends only on the protocol:
127
94
 
128
95
  ```swift
129
- final class AuthenticationManager {
130
- private let keychain: KeychainServiceProtocol
96
+ struct SessionKeeper {
97
+ let store: any SecretStore
131
98
 
132
- init(keychain: KeychainServiceProtocol) {
133
- self.keychain = keychain
99
+ func remember(token: String) throws {
100
+ try store.put(Data(token.utf8), for: "session.token")
134
101
  }
135
-
136
- func storeToken(_ token: String) throws {
137
- guard let data = token.data(using: .utf8) else {
138
- throw KeychainError.unexpectedData
139
- }
140
- try keychain.save(data, forKey: "auth_token")
141
- }
142
-
143
- func retrieveToken() throws -> String? {
144
- guard let data = try keychain.read(forKey: "auth_token") else { return nil }
145
- return String(data: data, encoding: .utf8)
102
+ func currentToken() throws -> String? {
103
+ try store.value(for: "session.token").map { String(decoding: $0, as: UTF8.self) }
146
104
  }
147
105
  }
148
106
  ```
149
107
 
150
- ---
151
-
152
- ## Seven Mistakes AI Generators Make in Keychain Tests
153
-
154
- Both research providers independently identified overlapping anti-patterns. This merged list covers the full set:
155
-
156
- **1. Tests that use the real keychain without cleanup.** Tests calling `SecItemAdd` directly leave state across runs. Second run fails with `errSecDuplicateItem` (-25299). AI generators rarely include `setUp`/`tearDown` cleanup.
157
-
158
- **2. Assuming Secure Enclave exists on simulator.** `SecureEnclave.isAvailable` returns `false` on every simulator. Tests calling `SecureEnclave.P256.Signing.PrivateKey()` directly throw `CryptoKitError` on simulator and crash CI.
108
+ ## Seven mistakes in generated keychain tests
159
109
 
160
- **3. Not testing error paths.** Real keychain code must handle `errSecDuplicateItem` (-25299), `errSecItemNotFound` (-25300), `errSecAuthFailed` (-25293), and `errSecInteractionNotAllowed` (-25308). AI generators almost never test these failure modes.
110
+ 1. **No cleanup around real-keychain tests.** The first run passes; the second hits `errSecDuplicateItem` (-25299).
111
+ 2. **Expecting a Secure Enclave on Simulator.** The Simulator has no Secure Enclave. Branch at compile time with `#if targetEnvironment(simulator)` and treat `SecureEnclave.isAvailable` as the runtime check on real hardware only; do not build Simulator behaviour on what the flag reports there. Creating a Secure Enclave key without that guard throws a `CryptoKitError` or fails the test run.
112
+ 3. **Only testing the happy path.** Cover -25299, -25300 (`errSecItemNotFound`), -25293 (`errSecAuthFailed`), -128 (`errSecUserCanceled`) and -25308 (`errSecInteractionNotAllowed`).
113
+ 4. **Expecting biometric hardware.** `canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error:)` returns false on Simulator unless enrolment is simulated.
114
+ 5. **No host app.** From Xcode 9 on, a logic test bundle on the iOS Simulator has no keychain access of its own. Give it a host application; otherwise expect `SecItemAdd` to fail with -25300 or -34018 (`errSecMissingEntitlement`).
115
+ 6. **Unscoped items.** Tests must use their own `kSecAttrService` so they never touch app data or each other's rows.
116
+ 7. **Mixing up keychain types.** The `security` command-line tool works on file-based keychains. iOS apps, and macOS apps using the data protection keychain, use a different store, so `security create-keychain` on a CI runner creates the wrong kind of keychain for testing `SecItemAdd`.
161
117
 
162
- **4. Assuming biometric hardware.** Tests instantiating a real `LAContext` and asserting `canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics)` returns `true` fail on simulator where no biometric hardware exists.
118
+ ## Simulator versus device
163
119
 
164
- **5. Missing test host app.** Since Xcode 9, test bundles on iOS simulator require a host app to access the keychain. Without one, `SecItemAdd` returns `-25300` or `-34018`. AI generators never mention this requirement.
120
+ | Capability | Simulator | Device |
121
+ | --- | --- | --- |
122
+ | Keychain add, read, update, delete | Works | Works |
123
+ | CryptoKit software algorithms | Works | Works, hardware accelerated |
124
+ | `kSecAttrAccessible` | Accepted, not enforced by hardware | Enforced |
125
+ | Secure Enclave | Not present; key creation fails | Present on A7 and later |
126
+ | Biometric-protected items | Returned without any prompt | Prompt shown |
127
+ | `canEvaluatePolicy` for biometrics | False, unless enrolment is simulated from the Simulator's Features menu | True when enrolled |
128
+ | ML-KEM and ML-DSA (iOS 26) | Works in software | Works |
165
129
 
166
- **6. No service/account scoping.** Tests omitting `kSecAttrService` match items from other tests or even other apps. Every keychain operation in tests must use a unique, test-specific service identifier.
130
+ The biometric row is the dangerous one: a Simulator test for a Face ID protected item passes without any authentication, which proves nothing about the real behaviour.
167
131
 
168
- **7. Confusing data protection keychain with file-based keychain.** Per Apple TN3137, macOS has two keychain implementations. The `security` CLI works with the file-based keychain; iOS apps use the data protection keychain. CI scripts using `security create-keychain` create the wrong type for `SecItemAdd` targets.
169
-
170
- ---
171
-
172
- ## Simulator vs. Device Testing Matrix
173
-
174
- Understanding exactly what works where prevents entire categories of test failures:
175
-
176
- | Feature | Simulator | Physical Device |
177
- | ------------------------------------------------------------- | ------------------------------------- | ---------------------------- |
178
- | Keychain CRUD (`SecItemAdd`, etc.) | ✅ Works | ✅ Works |
179
- | CryptoKit software crypto (AES-GCM, ChaChaPoly, P256, SHA256) | ✅ Software | ✅ Hardware-accelerated |
180
- | `kSecAttrAccessible` values | ✅ Accepted but not hardware-enforced | ✅ Hardware-enforced |
181
- | `SecureEnclave.isAvailable` | Returns **false** | Returns **true** (A7+) |
182
- | `SecureEnclave.P256.Signing.PrivateKey()` | ❌ Throws | ✅ Works |
183
- | Biometric prompt on protected items | ❌ Skipped - value returned silently | ✅ Shows prompt |
184
- | `LAContext.canEvaluatePolicy(.biometrics)` | Returns **false** | Returns **true** if enrolled |
185
- | Face ID simulation via Xcode menu | ✅ Manual only | N/A (real hardware) |
186
- | Post-quantum (ML-KEM, ML-DSA) iOS 26+ | ✅ Software (iOS 26 runtime) | ✅ Works |
187
-
188
- **Critical subtlety:** On simulator, keychain items protected with `kSecAttrAccessControl` and biometric flags return their value without showing a biometric prompt. Simulator tests that store biometric-protected items and read them succeed silently, giving false confidence the biometric gate works.
189
-
190
- ### Conditional Compilation and Runtime Guards
132
+ Guards:
191
133
 
192
134
  ```swift
193
- // Compile-time: exclude SE code on simulator
194
- #if targetEnvironment(simulator)
195
- let signingKey = SoftwareSigningKey()
196
- #else
197
- let signingKey = SecureEnclave.isAvailable
198
- ? try SecureEnclaveSigningKey()
199
- : SoftwareSigningKey()
200
- #endif
201
-
202
- // Runtime skip in XCTest
203
- func testDeviceOnlyFeature() throws {
135
+ import CryptoKit
136
+ import XCTest
137
+
138
+ func makeSigner() throws -> any SigningKeyProvider {
204
139
  #if targetEnvironment(simulator)
205
- throw XCTSkip("Requires physical device")
140
+ return SoftwareSigningKey()
141
+ #else
142
+ return SecureEnclave.isAvailable ? try SecureEnclaveSigningKey() : SoftwareSigningKey()
206
143
  #endif
207
- // Device-only test code here
208
144
  }
209
145
 
210
- // Runtime detection via ProcessInfo
211
- struct EnvironmentDetector {
212
- static var isSimulator: Bool {
213
- ProcessInfo.processInfo.environment["SIMULATOR_DEVICE_NAME"] != nil
214
- }
215
- static var isRunningTests: Bool {
216
- ProcessInfo.processInfo.environment["XCTestConfigurationFilePath"] != nil
217
- }
146
+ func requireHardware() throws {
147
+ #if targetEnvironment(simulator)
148
+ throw XCTSkip("Requires a physical device")
149
+ #endif
218
150
  }
219
- ```
220
151
 
221
- ---
152
+ let runningInSimulator = ProcessInfo.processInfo.environment["SIMULATOR_DEVICE_NAME"] != nil
153
+ let runningUnderTests = ProcessInfo.processInfo.environment["XCTestConfigurationFilePath"] != nil
154
+ ```
222
155
 
223
- ## Essential Testing Patterns
156
+ ## Core test patterns
224
157
 
225
- ### setUp/tearDown Cleanup for Real Keychain Tests
158
+ Real-keychain integration test, with its own service and cleanup on both sides:
226
159
 
227
160
  ```swift
228
- final class KeychainIntegrationTests: XCTestCase {
229
- private let testService = "com.tests.keychain-integration"
230
- private var keychain: KeychainService!
231
-
232
- override func setUp() {
233
- super.setUp()
234
- keychain = KeychainService(service: testService)
235
- try? keychain.deleteAll() // Clean slate
236
- }
161
+ final class KeychainSecretStoreTests: XCTestCase {
162
+ private var store: KeychainSecretStore!
237
163
 
238
- override func tearDown() {
239
- try? keychain.deleteAll() // Leave no trace
240
- super.tearDown()
164
+ override func setUpWithError() throws {
165
+ store = KeychainSecretStore(service: "tests.vault.\(name)")
166
+ try store.removeAll()
241
167
  }
242
168
 
243
- func testSaveAndRetrieveToken() throws {
244
- let token = "test-jwt-token-12345"
245
- try keychain.save(token.data(using: .utf8)!, forKey: "access_token")
246
- let retrieved = try keychain.read(forKey: "access_token")
247
- XCTAssertEqual(String(data: retrieved!, encoding: .utf8), token)
169
+ override func tearDownWithError() throws {
170
+ try store.removeAll()
248
171
  }
249
- }
250
- ```
251
172
 
252
- ### No Cleanup - Flaky Across Runs
253
-
254
- ```swift
255
- // ❌ INCORRECT: No cleanup, no isolation
256
- final class BadKeychainTests: XCTestCase {
257
- func testSaveToken() {
258
- let query: [String: Any] = [
259
- kSecClass as String: kSecClassGenericPassword,
260
- kSecAttrAccount as String: "token",
261
- kSecValueData as String: "secret".data(using: .utf8)!
262
- ]
263
- let status = SecItemAdd(query as CFDictionary, nil)
264
- XCTAssertEqual(status, errSecSuccess)
265
- // First run: passes ✅
266
- // Second run: FAILS with errSecDuplicateItem (-25299) ❌
173
+ func testSaveThenRead_ReturnsSameBytes() throws {
174
+ let secret = Data("pin-4821".utf8)
175
+ try store.put(secret, for: "pin")
176
+ XCTAssertEqual(try store.value(for: "pin"), secret)
267
177
  }
268
178
  }
269
179
  ```
270
180
 
271
- ### Testing Error Paths with Injected Failures
272
-
273
- ```swift
274
- final class KeychainErrorPathTests: XCTestCase {
275
- var mockKeychain: MockKeychainService!
276
- var authManager: AuthenticationManager!
277
-
278
- override func setUp() {
279
- mockKeychain = MockKeychainService()
280
- authManager = AuthenticationManager(keychain: mockKeychain)
281
- }
181
+ What not to write: a test that calls `SecItemAdd` directly with no service attribute and no cleanup. It passes exactly once and then fails with -25299 forever.
282
182
 
283
- func testStoreToken_whenDuplicateItem_throwsExpectedError() {
284
- mockKeychain.errorToThrow = .duplicateItem
285
- XCTAssertThrowsError(try authManager.storeToken("token")) { error in
286
- XCTAssertEqual(error as? KeychainError, .duplicateItem)
287
- }
288
- }
183
+ Error paths through the mock:
289
184
 
290
- func testRetrieveToken_whenAuthFailed_throwsError() {
291
- mockKeychain.errorToThrow = .authFailed
292
- XCTAssertThrowsError(try authManager.retrieveToken()) { error in
293
- XCTAssertEqual(error as? KeychainError, .authFailed)
294
- }
295
- }
185
+ ```swift
186
+ func testSaveWhileLocked_SurfacesInteractionNotAllowed() {
187
+ let mock = InMemorySecretStore()
188
+ mock.failure = .interactionNotAllowed
189
+ let keeper = SessionKeeper(store: mock)
296
190
 
297
- func testRetrieveToken_whenInteractionNotAllowed_throwsError() {
298
- // Simulates the most common CI failure scenario
299
- mockKeychain.errorToThrow = .interactionNotAllowed
300
- XCTAssertThrowsError(try authManager.retrieveToken()) { error in
301
- XCTAssertEqual(error as? KeychainError, .interactionNotAllowed)
302
- }
191
+ XCTAssertThrowsError(try keeper.remember(token: "abc")) { error in
192
+ XCTAssertEqual(error as? VaultStoreError, .interactionNotAllowed)
303
193
  }
304
194
  }
305
195
  ```
306
196
 
307
- ### CryptoKit Round-Trip Tests (Simulator-Safe)
197
+ Repeat for `.duplicateItem`, `.authFailed` and `.userCanceled`. `interactionNotAllowed` deserves particular care: it is the failure CI runs hit most.
308
198
 
309
- All CryptoKit software operations work on simulator. These tests run everywhere:
199
+ CryptoKit tests are Simulator-safe:
310
200
 
311
201
  ```swift
312
- import XCTest
313
- import CryptoKit
314
-
315
- final class CryptoKitTests: XCTestCase {
316
-
317
- func testAESGCMRoundTrip() throws {
318
- let key = SymmetricKey(size: .bits256)
319
- let plaintext = "Sensitive credentials".data(using: .utf8)!
320
- let sealedBox = try AES.GCM.seal(plaintext, using: key)
321
- let ciphertext = sealedBox.combined!
322
- XCTAssertNotEqual(ciphertext, plaintext)
323
-
324
- let reopened = try AES.GCM.SealedBox(combined: ciphertext)
325
- let decrypted = try AES.GCM.open(reopened, using: key)
326
- XCTAssertEqual(decrypted, plaintext)
327
- }
328
-
329
- func testAESGCMWrongKeyFails() throws {
330
- let correctKey = SymmetricKey(size: .bits256)
331
- let wrongKey = SymmetricKey(size: .bits256)
332
- let sealed = try AES.GCM.seal("secret".data(using: .utf8)!, using: correctKey)
333
- XCTAssertThrowsError(try AES.GCM.open(sealed, using: wrongKey))
334
- }
335
-
336
- func testP256SignVerify() throws {
337
- let privateKey = P256.Signing.PrivateKey()
338
- let data = "Message to authenticate".data(using: .utf8)!
339
- let signature = try privateKey.signature(for: data)
340
- XCTAssertTrue(privateKey.publicKey.isValidSignature(signature, for: data))
341
-
342
- let tampered = "Tampered message".data(using: .utf8)!
343
- XCTAssertFalse(privateKey.publicKey.isValidSignature(signature, for: tampered))
344
- }
345
-
346
- func testCurve25519KeyAgreement() throws {
347
- let alice = Curve25519.KeyAgreement.PrivateKey()
348
- let bob = Curve25519.KeyAgreement.PrivateKey()
349
- let aliceShared = try alice.sharedSecretFromKeyAgreement(with: bob.publicKey)
350
- let bobShared = try bob.sharedSecretFromKeyAgreement(with: alice.publicKey)
202
+ func testGCMRoundTripAndWrongKey() throws {
203
+ let key = SymmetricKey(size: .bits256)
204
+ let wire = try XCTUnwrap(try AES.GCM.seal(Data("ledger".utf8), using: key).combined)
205
+ let parsed = try AES.GCM.SealedBox(combined: wire)
206
+ XCTAssertEqual(try AES.GCM.open(parsed, using: key), Data("ledger".utf8))
207
+ let stranger = SymmetricKey(size: .bits256)
208
+ XCTAssertThrowsError(try AES.GCM.open(parsed, using: stranger))
209
+ }
351
210
 
352
- let aliceKey = aliceShared.hkdfDerivedSymmetricKey(
353
- using: SHA256.self, salt: Data(), sharedInfo: Data(), outputByteCount: 32)
354
- let bobKey = bobShared.hkdfDerivedSymmetricKey(
355
- using: SHA256.self, salt: Data(), sharedInfo: Data(), outputByteCount: 32)
211
+ func testP256SignatureRejectsTampering() throws {
212
+ let signer = P256.Signing.PrivateKey()
213
+ let signature = try signer.signature(for: Data("amount=10".utf8))
214
+ XCTAssertTrue(signer.publicKey.isValidSignature(signature, for: Data("amount=10".utf8)))
215
+ XCTAssertFalse(signer.publicKey.isValidSignature(signature, for: Data("amount=99".utf8)))
216
+ }
356
217
 
357
- // Both parties can decrypt each other's messages
358
- let sealed = try AES.GCM.seal("test".data(using: .utf8)!, using: aliceKey)
359
- XCTAssertEqual(try AES.GCM.open(sealed, using: bobKey), "test".data(using: .utf8)!)
360
- }
218
+ func testX25519PartiesDeriveMatchingKeys() throws {
219
+ let alice = Curve25519.KeyAgreement.PrivateKey()
220
+ let bob = Curve25519.KeyAgreement.PrivateKey()
221
+ let salt = Data("pairing-v1".utf8)
222
+ func derive(_ mine: Curve25519.KeyAgreement.PrivateKey,
223
+ _ theirs: Curve25519.KeyAgreement.PublicKey) throws -> SymmetricKey {
224
+ try mine.sharedSecretFromKeyAgreement(with: theirs)
225
+ .hkdfDerivedSymmetricKey(using: SHA384.self, salt: salt,
226
+ sharedInfo: Data("chat".utf8), outputByteCount: 32)
227
+ }
228
+ let aliceKey = try derive(alice, bob.publicKey)
229
+ let bobKey = try derive(bob, alice.publicKey)
230
+ let sealed = try ChaChaPoly.seal(Data("hello".utf8), using: aliceKey)
231
+ XCTAssertEqual(try ChaChaPoly.open(sealed, using: bobKey), Data("hello".utf8))
361
232
  }
362
233
  ```
363
234
 
364
- **iOS 26 note:** Post-quantum cryptography (ML-KEM, ML-DSA) is available via CryptoKit starting iOS 26. Gate these tests with `@available(iOS 26, *)` and use the same round-trip pattern. Software-based PQC works on simulator (see `cryptokit-public-key.md`).
365
-
366
- ---
235
+ Post-quantum tests follow the same round-trip shape and are gated on availability; the software implementations run on the iOS 26 Simulator runtime.
367
236
 
368
- ## Secure Enclave Test Strategy - Protocol Fallback
237
+ ```swift
238
+ @available(iOS 26, *)
239
+ func verifyMLKEMRoundTrip() throws {
240
+ let receiver = try MLKEM768.PrivateKey()
241
+ let result = try receiver.publicKey.encapsulate()
242
+ let recovered = try receiver.decapsulate(result.encapsulated)
243
+ let bytes = { (key: SymmetricKey) in key.withUnsafeBytes { Data($0) } }
244
+ XCTAssertEqual(bytes(recovered), bytes(result.sharedSecret))
245
+ }
246
+ ```
369
247
 
370
- `SecureEnclave.P256.Signing.PrivateKey` and `P256.Signing.PrivateKey` are distinct types, so a single function should not promise to return one concrete type for both SE and software paths. Use a protocol-based abstraction:
248
+ ## Secure Enclave tests
371
249
 
372
- ### SigningKeyProvider Protocol with SE/Software Implementations
250
+ The enclave-backed signing key type and the software `P256.Signing.PrivateKey` share no common type, so a factory cannot return "one of them" directly. Put both behind a protocol.
373
251
 
374
252
  ```swift
375
- import CryptoKit
376
-
377
- protocol SigningKeyProvider {
378
- func sign(_ data: Data) throws -> Data
379
- func publicKeyData() -> Data
253
+ protocol SigningKeyProvider: Sendable {
254
+ func signature(over payload: Data) throws -> Data
255
+ var publicKeyDER: Data { get }
380
256
  }
381
257
 
382
- final class SecureEnclaveSigningKey: SigningKeyProvider {
383
- private let key: SecureEnclave.P256.Signing.PrivateKey
258
+ struct SecureEnclaveSigningKey: SigningKeyProvider {
259
+ private let enclaveKey: SecureEnclave.P256.Signing.PrivateKey
384
260
 
385
261
  init() throws {
386
- guard SecureEnclave.isAvailable else {
387
- throw KeychainError.unhandledError(status: errSecUnimplemented)
262
+ guard SecureEnclave.isAvailable else { throw VaultStoreError.unhandled(status: errSecUnimplemented) }
263
+ guard let access = SecAccessControlCreateWithFlags(nil,
264
+ kSecAttrAccessibleWhenUnlockedThisDeviceOnly, [.privateKeyUsage], nil) else {
265
+ throw VaultStoreError.unhandled(status: errSecParam)
388
266
  }
389
- self.key = try SecureEnclave.P256.Signing.PrivateKey()
267
+ enclaveKey = try .init(accessControl: access)
390
268
  }
391
-
392
- func sign(_ data: Data) throws -> Data {
393
- try key.signature(for: data).derRepresentation
269
+ func signature(over payload: Data) throws -> Data {
270
+ try enclaveKey.signature(for: payload).derRepresentation
394
271
  }
395
-
396
- func publicKeyData() -> Data { key.publicKey.derRepresentation }
272
+ var publicKeyDER: Data { enclaveKey.publicKey.derRepresentation }
397
273
  }
398
274
 
399
- final class SoftwareSigningKey: SigningKeyProvider {
400
- private let key = P256.Signing.PrivateKey()
401
-
402
- func sign(_ data: Data) throws -> Data {
403
- try key.signature(for: data).derRepresentation
275
+ struct SoftwareSigningKey: SigningKeyProvider {
276
+ private let softwareKey = P256.Signing.PrivateKey()
277
+ func signature(over payload: Data) throws -> Data {
278
+ try softwareKey.signature(for: payload).derRepresentation
404
279
  }
405
-
406
- func publicKeyData() -> Data { key.publicKey.derRepresentation }
280
+ var publicKeyDER: Data { softwareKey.publicKey.derRepresentation }
407
281
  }
408
282
 
409
- struct SigningKeyFactory {
410
- static func make() -> SigningKeyProvider {
411
- if SecureEnclave.isAvailable,
412
- let seKey = try? SecureEnclaveSigningKey() {
413
- return seKey
414
- }
415
- return SoftwareSigningKey()
283
+ enum SignerFactory {
284
+ static func make() -> any SigningKeyProvider {
285
+ (try? makeSigner()) ?? SoftwareSigningKey()
416
286
  }
417
287
  }
418
288
  ```
419
289
 
420
- ### Testing Secure Enclave Code
290
+ `.privateKeyUsage` is always part of a Secure Enclave signing key's access control flags; add `.biometryCurrentSet` or `.userPresence` next to it when the key should require authentication.
291
+
292
+ A test that creates a Secure Enclave key unconditionally crashes or fails on Simulator and CI. Skip it, or test through the protocol:
421
293
 
422
294
  ```swift
423
- // ❌ INCORRECT: Crashes on simulator and CI
424
- func testSecureEnclaveSigning_BROKEN() throws {
425
- let key = try SecureEnclave.P256.Signing.PrivateKey() // throws on simulator
426
- let sig = try key.signature(for: "data".data(using: .utf8)!)
427
- XCTAssertTrue(key.publicKey.isValidSignature(sig, for: "data".data(using: .utf8)!))
295
+ func testEnclaveSignature_VerifiesWithPublicKey() throws {
296
+ try requireHardware()
297
+ try XCTSkipUnless(SecureEnclave.isAvailable, "No Secure Enclave on this machine")
298
+ let signer = try SecureEnclaveSigningKey()
299
+ try assertVerifies(signer)
428
300
  }
429
301
 
430
- // ✅ CORRECT: Skip gracefully when SE unavailable
431
- func testSecureEnclaveSigning_withGuard() throws {
432
- try XCTSkipUnless(SecureEnclave.isAvailable,
433
- "Secure Enclave not available - skipping on simulator")
434
- let key = try SecureEnclave.P256.Signing.PrivateKey()
435
- let data = "authenticated payload".data(using: .utf8)!
436
- let sig = try key.signature(for: data)
437
- XCTAssertTrue(key.publicKey.isValidSignature(sig, for: data))
302
+ func testAnySigner_ProducesVerifiableSignatures() throws {
303
+ try assertVerifies(SignerFactory.make())
438
304
  }
439
305
 
440
- // ✅ CORRECT: Protocol-based test runs everywhere
441
- func testSigningWithFallback() throws {
442
- let signer = SigningKeyFactory.make()
443
- let data = "payload".data(using: .utf8)!
444
- let sigBytes = try signer.sign(data)
445
- XCTAssertFalse(sigBytes.isEmpty)
446
-
447
- let publicKey = try P256.Signing.PublicKey(derRepresentation: signer.publicKeyData())
448
- let signature = try P256.Signing.ECDSASignature(derRepresentation: sigBytes)
449
- XCTAssertTrue(publicKey.isValidSignature(signature, for: data))
306
+ private func assertVerifies(_ provider: any SigningKeyProvider) throws {
307
+ let challenge = Data("challenge-17".utf8)
308
+ let verifier = try P256.Signing.PublicKey(derRepresentation: provider.publicKeyDER)
309
+ let der = try provider.signature(over: challenge)
310
+ XCTAssertTrue(verifier.isValidSignature(try .init(derRepresentation: der), for: challenge))
450
311
  }
451
312
  ```
452
313
 
453
- ---
454
-
455
- ## Biometric Flow Testing - LAContext Mocking
314
+ ## Biometric flows
456
315
 
457
- Wrap `LAContext` behind a protocol for full control over biometric outcomes in tests. Alternatively, subclass `LAContext` directly (simpler but tighter coupling).
458
-
459
- ### Protocol-Based Approach (Preferred)
316
+ Put `LAContext` behind a protocol so tests control the outcome. The quickest version copies `LAContext`'s own `canEvaluatePolicy(_:error:)` and `evaluatePolicy(_:localizedReason:reply:)` requirements into a `BiometricAuthContext` protocol and declares `extension LAContext: BiometricAuthContext {}`. A small adapter, shown here, keeps the protocol in your own vocabulary instead. Subclassing `LAContext` also works and is shorter, at the cost of coupling tests to the real class.
460
317
 
461
318
  ```swift
462
319
  import LocalAuthentication
463
320
 
464
321
  protocol BiometricAuthContext {
465
- func canEvaluatePolicy(_ policy: LAPolicy, error: NSErrorPointer) -> Bool
466
- func evaluatePolicy(_ policy: LAPolicy, localizedReason: String,
467
- reply: @escaping (Bool, Error?) -> Void)
322
+ func biometricsReady() -> Bool
323
+ func authenticate(reason: String, reply: @escaping @Sendable (Bool, Error?) -> Void)
468
324
  }
469
325
 
470
- extension LAContext: BiometricAuthContext {}
471
-
472
- final class BiometricAuthManager {
473
- private let context: BiometricAuthContext
474
-
475
- init(context: BiometricAuthContext = LAContext()) {
476
- self.context = context
477
- }
478
-
479
- var isBiometricsAvailable: Bool {
326
+ struct SystemBiometricContext: BiometricAuthContext {
327
+ private let context = LAContext()
328
+ func biometricsReady() -> Bool {
480
329
  context.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: nil)
481
330
  }
331
+ func authenticate(reason: String, reply: @escaping @Sendable (Bool, Error?) -> Void) {
332
+ context.evaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, localizedReason: reason, reply: reply)
333
+ }
334
+ }
335
+ ```
336
+
337
+ ```swift
338
+ final class BiometricPrompt {
339
+ private let context: BiometricAuthContext
340
+ init(context: BiometricAuthContext) { self.context = context }
482
341
 
483
- func authenticate(reason: String,
484
- completion: @escaping (Result<Void, Error>) -> Void) {
485
- guard isBiometricsAvailable else {
486
- completion(.failure(LAError(.biometryNotAvailable)))
342
+ func confirm(reason: String, then finish: @escaping @Sendable (Result<Void, Error>) -> Void) {
343
+ guard context.biometricsReady() else {
344
+ finish(.failure(LAError(.biometryNotAvailable)))
487
345
  return
488
346
  }
489
- context.evaluatePolicy(.deviceOwnerAuthenticationWithBiometrics,
490
- localizedReason: reason) { success, error in
491
- completion(success ? .success(()) : .failure(error ?? LAError(.authenticationFailed)))
347
+ context.authenticate(reason: reason) { ok, error in
348
+ finish(ok ? .success(()) : .failure(error ?? LAError(.authenticationFailed)))
492
349
  }
493
350
  }
494
351
  }
495
352
 
496
- final class MockBiometricContext: BiometricAuthContext {
497
- var canEvaluateResult = true
498
- var evaluateResult = true
499
- var evaluateError: Error?
500
- var evaluateCalled = false
353
+ final class StubBiometricContext: BiometricAuthContext {
354
+ var available = true
355
+ var succeeds = true
356
+ var error: Error?
357
+ private(set) var evaluateCalled = false
501
358
 
502
- func canEvaluatePolicy(_ policy: LAPolicy, error: NSErrorPointer) -> Bool {
503
- canEvaluateResult
504
- }
505
-
506
- func evaluatePolicy(_ policy: LAPolicy, localizedReason: String,
507
- reply: @escaping (Bool, Error?) -> Void) {
359
+ func biometricsReady() -> Bool { available }
360
+ func authenticate(reason: String, reply: @escaping @Sendable (Bool, Error?) -> Void) {
508
361
  evaluateCalled = true
509
- reply(evaluateResult, evaluateError)
362
+ reply(succeeds, self.error)
510
363
  }
511
364
  }
512
365
  ```
513
366
 
514
- ### Biometric Scenarios to Cover
367
+ This tests UI gating only. A secret behind the prompt still has to live in a keychain item bound with `SecAccessControl`, as [biometric-authentication.md](biometric-authentication.md) explains.
515
368
 
516
- | Scenario | canEvaluate | evaluatePolicy | Error | Expected App Behavior |
517
- | ------------ | ----------- | -------------- | ---------------------- | ------------------------- |
518
- | Success | true | true | nil | Proceed |
519
- | User cancel | true | false | `.userCancel` | Retry or abort gracefully |
520
- | Lockout | true | false | `.biometryLockout` | Fallback to passcode |
521
- | Not enrolled | false | n/a | `.biometryNotEnrolled` | Show enrollment guidance |
369
+ Scenarios to cover:
370
+
371
+ | Case | Error | Expected handling |
372
+ | --- | --- | --- |
373
+ | Success | none | Proceed |
374
+ | User cancels | `.userCancel` | Offer retry or abort |
375
+ | Too many failures | `.biometryLockout` | Fall back to the device passcode |
376
+ | Nothing enrolled | `.biometryNotEnrolled` | Explain how to enrol |
522
377
 
523
378
  ```swift
524
- func testBiometricAuthSuccess() {
525
- let mock = MockBiometricContext()
526
- mock.canEvaluateResult = true
527
- mock.evaluateResult = true
528
- let manager = BiometricAuthManager(context: mock)
529
-
530
- let exp = expectation(description: "auth")
531
- manager.authenticate(reason: "Test") { result in
532
- if case .failure = result { XCTFail("Expected success") }
533
- exp.fulfill()
534
- }
535
- waitForExpectations(timeout: 1)
536
- XCTAssertTrue(mock.evaluateCalled)
379
+ func testUnavailableBiometrics_NeverEvaluates() async {
380
+ let stub = StubBiometricContext()
381
+ stub.available = false
382
+ let done = expectation(description: "completion")
383
+ BiometricPrompt(context: stub).confirm(reason: "Approve transfer") { outcome in
384
+ if case .success = outcome { XCTFail("Expected a failure") }
385
+ done.fulfill()
386
+ }
387
+ await fulfillment(of: [done], timeout: 1)
388
+ XCTAssertFalse(stub.evaluateCalled)
537
389
  }
390
+ ```
538
391
 
539
- func testBiometricAuthUnavailable() {
540
- let mock = MockBiometricContext()
541
- mock.canEvaluateResult = false
542
- let manager = BiometricAuthManager(context: mock)
392
+ The completion checks the result with `if case` rather than
393
+ `XCTAssertThrowsError(try outcome.get())`: a `try` anywhere in a closure body
394
+ makes Swift infer the closure as throwing, and `finish` is non-throwing. The
395
+ test is `async` and awaits `fulfillment(of:)` because `waitForExpectations` is
396
+ main-actor isolated.
543
397
 
544
- let exp = expectation(description: "unavailable")
545
- manager.authenticate(reason: "Test") { result in
546
- if case .success = result { XCTFail("Expected failure") }
547
- exp.fulfill()
548
- }
549
- waitForExpectations(timeout: 1)
550
- XCTAssertFalse(mock.evaluateCalled) // Should not attempt auth
551
- }
552
- ```
398
+ ## CI configuration
553
399
 
554
- ---
555
-
556
- ## CI/CD Pipeline Configuration
557
-
558
- Running keychain tests in CI is the most error-prone part. The `-25308` (`errSecInteractionNotAllowed`) error is the most common CI failure - keychain locked or requires GUI interaction in a headless environment.
559
-
560
- ### GitHub Actions
561
-
562
- ```yaml
563
- name: iOS CI
564
- on: [push, pull_request]
565
- jobs:
566
- test:
567
- runs-on: macos-latest
568
- steps:
569
- - uses: actions/checkout@v5
570
- - name: Create temporary keychain
571
- env:
572
- KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }}
573
- run: |
574
- KEYCHAIN_PATH=$RUNNER_TEMP/app-signing.keychain-db
575
- security create-keychain -p "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH
576
- security set-keychain-settings -lut 21600 $KEYCHAIN_PATH
577
- security unlock-keychain -p "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH
578
- security list-keychain -d user -s $KEYCHAIN_PATH
579
- # Import cert + CRITICAL partition list step
580
- echo -n "$BUILD_CERTIFICATE_BASE64" | base64 --decode -o $RUNNER_TEMP/cert.p12
581
- security import $RUNNER_TEMP/cert.p12 -P "$P12_PASSWORD" \
582
- -A -t cert -f pkcs12 -k $KEYCHAIN_PATH
583
- security set-key-partition-list -S apple-tool:,apple: \
584
- -k "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH
585
- - name: Run simulator-safe tests
586
- run: |
587
- xcodebuild test -scheme MyApp \
588
- -destination 'platform=iOS Simulator,name=iPhone 16' \
589
- -testPlan CITests
590
- - name: Cleanup
591
- if: always()
592
- run: security delete-keychain $RUNNER_TEMP/app-signing.keychain-db
400
+ `errSecInteractionNotAllowed` (-25308) is the error CI runs hit most: the runner's keychain is locked, or the operation wants a GUI that a headless session does not have.
401
+
402
+ Signing setup on a hosted macOS runner:
403
+
404
+ ```sh
405
+ KC="${RUNNER_TEMP:-$TMPDIR}/ci-signing.keychain-db"
406
+ security create-keychain -p "$KC_PASSWORD" "$KC"
407
+ security set-keychain-settings -l -u -t 21600 "$KC"
408
+ security unlock-keychain -p "$KC_PASSWORD" "$KC"
409
+ security list-keychains -s "$KC" -d user
410
+ security import signing.p12 -k "$KC" -f pkcs12 -t cert -A -P "$P12_PASSWORD"
411
+ security set-key-partition-list -s -k "$KC_PASSWORD" -S apple-tool:,apple: "$KC"
412
+
413
+ xcodebuild test -scheme Vault -testPlan CITests \
414
+ -destination "platform=iOS Simulator,name=$SIM_NAME"
415
+
416
+ # always-run cleanup step
417
+ security delete-keychain "$KC"
593
418
  ```
594
419
 
595
- **`security set-key-partition-list` must be called after importing certificates** - this is the step most people miss. Without it, `codesign` hangs indefinitely waiting for a GUI prompt. The `-A` flag on import grants access to all applications, necessary in CI.
420
+ `set-key-partition-list` after the import is the step people miss. Without it `codesign` waits for a GUI confirmation that never comes and the job hangs. `-A` on the import grants every application access to the key, which is acceptable only for a throwaway CI keychain.
596
421
 
597
- **Xcode Cloud:** Uses ephemeral environments - no manual `security create-keychain`. Apple manages signing automatically. Ensure Keychain Sharing capability is enabled. The `-25308` error is common when SPM tries to save credentials.
422
+ Xcode Cloud builds on ephemeral machines with managed signing, so there is no keychain to configure by hand. Enable Keychain Sharing on the test host, and expect -25308 when Swift Package Manager tries to save repository credentials.
598
423
 
599
- **Fastlane:** `setup_ci` creates a temporary `fastlane_tmp_keychain` and sets it as default. On self-hosted runners, this can interfere with the host machine's keychain.
424
+ fastlane's `setup_ci` creates a temporary keychain named `fastlane_tmp_keychain` and makes it the default, which can disturb the login keychain on a self-hosted runner.
600
425
 
601
426
  ```ruby
602
- lane :ci_test do
427
+ lane :ci_tests do
603
428
  setup_ci(timeout: 3600)
604
429
  sync_code_signing(type: "development", readonly: is_ci)
605
- run_tests(scheme: "MyApp", testplan: "CITests", device: "iPhone 16")
430
+ run_tests(scheme: "Vault", testplan: "CITests", device: "iPhone 16")
606
431
  end
607
432
  ```
608
433
 
609
- ### Common CI Error Reference
610
-
611
- | Error | OSStatus | Cause | Fix |
612
- | ----------------------------- | -------- | ------------------------------------- | ----------------------------------------------- |
613
- | `errSecInteractionNotAllowed` | -25308 | Keychain locked / needs GUI | Unlock keychain + `set-key-partition-list` |
614
- | `errSecMissingEntitlement` | -34018 | No keychain-access-groups entitlement | Add entitlements to test host app |
615
- | `errSecItemNotFound` | -25300 | No test host or missing entitlement | Use test host app with keychain capability |
616
- | `errSecInternalComponent` | -67585 | Partition list not set after import | Call `set-key-partition-list` after cert import |
617
- | Default keychain not found | -25307 | No default keychain on CI runner | Create and set default keychain |
618
-
619
- ### Test Host App Requirement
620
-
621
- Since Xcode 9, test bundles on iOS simulator require a host app to access the keychain. Without one, `SecItemAdd` returns `-25300` or `-34018`. Create a minimal iOS app target, enable the Keychain Sharing capability, and set the test target's **Test Host** and **Bundle Loader** build settings to point at it.
622
-
623
- ---
434
+ | Status | Meaning on CI | Fix |
435
+ | --- | --- | --- |
436
+ | -25308 | Keychain locked or needs UI | Unlock it and set the partition list |
437
+ | -34018 | Missing entitlement | Give the test host the keychain entitlement |
438
+ | -25300 | Item not found, often no host app | Run tests inside a host app with the Keychain capability |
439
+ | -67585 | `errSecInternalComponent` | The import happened but no partition list was applied |
440
+ | -25307 | No default keychain | Create one and set it as default |
624
441
 
625
- ## Xcode Test Plans - Separating Simulator from Device
442
+ For the test host, add an almost empty iOS app target and turn on Keychain Sharing for it. Then set `TEST_HOST` and `BUNDLE_LOADER` on the test target so the bundle loads inside that app.
626
443
 
627
- Create two test plans for CI/device split:
444
+ ## Test plans
628
445
 
629
- - **CITests.xctestplan**: Only tests using `MockKeychainService` and simulator-safe CryptoKit. Skips integration, biometric, and SE tests.
630
- - **DeviceTests.xctestplan**: Real keychain integration, Secure Enclave, and biometric hardware tests. Requires physical device.
446
+ - `CITests.xctestplan`: mock keychain and Simulator-safe CryptoKit tests only.
447
+ - `DeviceTests.xctestplan`: real keychain, Secure Enclave, biometric hardware; runs on a physical device.
631
448
 
632
- ```bash
633
- # CI: simulator-safe tests on every push
634
- xcodebuild test -scheme MyApp \
635
- -destination 'platform=iOS Simulator,name=iPhone 16' \
636
- -testPlan CITests
637
-
638
- # Nightly: device farm runs everything
639
- xcodebuild test -scheme MyApp \
640
- -destination 'platform=iOS,id=DEVICE_UDID' \
641
- -testPlan DeviceTests
449
+ ```sh
450
+ xcodebuild test -scheme Vault -testPlan CITests -destination "platform=iOS Simulator,name=$SIM_NAME"
451
+ xcodebuild test -scheme Vault -testPlan DeviceTests -destination 'platform=iOS,id=<UDID>'
642
452
  ```
643
453
 
644
- ---
645
-
646
- ## Swift Testing Framework Patterns
454
+ Run the first on every push and the second nightly on a device farm.
647
455
 
648
- Swift Testing (WWDC24) introduces tags, traits, and parameterized tests that map well to security test organization:
456
+ ## Swift Testing
649
457
 
650
458
  ```swift
651
459
  import Testing
652
- @testable import MyApp
653
460
 
654
461
  extension Tag {
655
462
  @Tag static var keychain: Self
@@ -658,156 +465,89 @@ extension Tag {
658
465
  }
659
466
 
660
467
  @Suite(.serialized, .tags(.keychain))
661
- struct KeychainTests {
662
-
663
- @Test("Save and retrieve round-trip", .tags(.ciSafe))
664
- func saveAndRetrieve() throws {
665
- let mock = MockKeychainService()
666
- let manager = AuthenticationManager(keychain: mock)
667
- try manager.storeToken("test-token")
668
- let result = try #require(try manager.retrieveToken())
669
- #expect(result == "test-token")
468
+ struct VaultTests {
469
+ @Test(.tags(.ciSafe))
470
+ func rememberedTokenIsReadable() throws {
471
+ let keeper = SessionKeeper(store: InMemorySecretStore())
472
+ try keeper.remember(token: "t-1")
473
+ let token = try #require(try keeper.currentToken())
474
+ #expect(token == "t-1")
670
475
  }
671
476
 
672
- @Test("Device-only: real keychain integration",
673
- .enabled(if: ProcessInfo.processInfo.environment["CI"] == nil),
674
- .tags(.deviceOnly))
675
- func realKeychainIntegration() throws {
676
- let keychain = KeychainService(service: "com.test.swift-testing")
677
- try keychain.deleteAll()
678
- defer { try? keychain.deleteAll() }
679
- try keychain.save("token".data(using: .utf8)!, forKey: "key")
680
- let data = try #require(try keychain.read(forKey: "key"))
681
- #expect(String(data: data, encoding: .utf8) == "token")
477
+ static let onBuildServer = ProcessInfo.processInfo.environment["CI"] != nil
478
+
479
+ @Test(.tags(.deviceOnly), .enabled(if: !onBuildServer))
480
+ func realKeychainRoundTrip() throws {
481
+ let store = KeychainSecretStore(service: "tests.vault.swift-testing")
482
+ try store.removeAll()
483
+ defer { try? store.removeAll() }
484
+ try store.put(Data("x".utf8), for: "k")
485
+ #expect(try store.value(for: "k") == Data("x".utf8))
682
486
  }
683
487
 
684
- @Test("Parameterized error paths",
685
- arguments: [
686
- KeychainError.duplicateItem,
687
- KeychainError.itemNotFound,
688
- KeychainError.authFailed,
689
- KeychainError.interactionNotAllowed
690
- ])
691
- func errorPathHandling(expectedError: KeychainError) {
692
- let mock = MockKeychainService()
693
- mock.errorToThrow = expectedError
694
- #expect(throws: KeychainError.self) {
695
- try mock.read(forKey: "any-key")
696
- }
488
+ @Test(arguments: [VaultStoreError.duplicateItem, .authFailed, .userCanceled, .interactionNotAllowed])
489
+ func injectedFailuresPropagate(_ failure: VaultStoreError) {
490
+ let mock = InMemorySecretStore()
491
+ mock.failure = failure
492
+ #expect(throws: VaultStoreError.self) { try SessionKeeper(store: mock).remember(token: "t") }
697
493
  }
698
494
  }
699
495
  ```
700
496
 
701
- The `.serialized` trait ensures keychain tests modifying shared state run sequentially. Tags integrate with test plans for filtering - `.ciSafe` tests run in CI, `.deviceOnly` tests run on device farms.
497
+ `.serialized` keeps tests that share keychain state from running in parallel. Test plans can include or exclude by tag: `ciSafe` on CI, `deviceOnly` on the device farm.
702
498
 
703
- ---
499
+ ## Further techniques
704
500
 
705
- ## Advanced Patterns
706
-
707
- ### Migration Testing: UserDefaults to Keychain
708
-
709
- Migration code is security-critical - silent failure leaves credentials in UserDefaults (see `migration-legacy-stores.md`). The class under test accepts injected dependencies for both stores:
501
+ **Migration tests.** Inject both stores: an isolated `UserDefaults(suiteName:)` cleared with `removePersistentDomain(forName:)` and the mock keychain. Assert the value arrived, was read back, and is gone from the source.
710
502
 
711
503
  ```swift
712
- final class StorageMigrationManager {
713
- private let defaults: UserDefaults
714
- private let keychain: KeychainServiceProtocol
715
-
716
- init(defaults: UserDefaults = .standard,
717
- keychain: KeychainServiceProtocol) {
718
- self.defaults = defaults
719
- self.keychain = keychain
720
- }
504
+ func testMigrationMovesAndVerifies() throws {
505
+ let suiteName = "tests.migration"
506
+ let defaults = try XCTUnwrap(UserDefaults(suiteName: suiteName))
507
+ defaults.removePersistentDomain(forName: suiteName)
508
+ defaults.set("legacy-token", forKey: "token")
509
+ let store = InMemorySecretStore()
721
510
 
722
- func migrateIfNeeded() throws {
723
- let version = defaults.integer(forKey: "migration_version")
724
- if version < 1 {
725
- if let token = defaults.string(forKey: "auth_token"),
726
- let data = token.data(using: .utf8) {
727
- try keychain.save(data, forKey: "auth_token")
728
- defaults.removeObject(forKey: "auth_token")
729
- }
730
- }
731
- defaults.set(1, forKey: "migration_version")
732
- }
733
- }
734
- ```
735
-
736
- Test with isolated `UserDefaults(suiteName:)` and mock keychain:
511
+ try LegacyTokenMigrator(defaults: defaults, store: store).run()
737
512
 
738
- ```swift
739
- func testMigrationMovesTokenToKeychain() throws {
740
- let defaults = UserDefaults(suiteName: "migration-test")!
741
- defaults.removePersistentDomain(forName: "migration-test")
742
- defaults.set("my-secret", forKey: "auth_token")
743
- defaults.set(0, forKey: "migration_version")
744
-
745
- let mock = MockKeychainService()
746
- let migrator = StorageMigrationManager(defaults: defaults, keychain: mock)
747
- try migrator.migrateIfNeeded()
748
-
749
- // Token moved to keychain, removed from UserDefaults
750
- XCTAssertEqual(String(data: mock.storage["auth_token"]!, encoding: .utf8), "my-secret")
751
- XCTAssertNil(defaults.string(forKey: "auth_token"))
513
+ XCTAssertEqual(try store.value(for: "token"), Data("legacy-token".utf8))
514
+ XCTAssertNil(defaults.string(forKey: "token"))
752
515
  }
753
516
  ```
754
517
 
755
- ### Performance Testing
518
+ `LegacyTokenMigrator` saves, reads back and compares, and removes the `UserDefaults` value only after the comparison succeeds, as in [migration-legacy-stores.md](migration-legacy-stores.md).
756
519
 
757
- ```swift
758
- func testKeychainWritePerformance() {
759
- let keychain = KeychainService(service: "com.test.perf")
760
- let options = XCTMeasureOptions()
761
- options.iterationCount = 20
762
-
763
- measure(metrics: [XCTClockMetric(), XCTCPUMetric()], options: options) {
764
- let data = UUID().uuidString.data(using: .utf8)!
765
- try? keychain.save(data, forKey: "perf-key")
766
- try? keychain.delete(forKey: "perf-key")
767
- }
768
- }
769
- ```
770
-
771
- ### Mutation Testing
772
-
773
- Mutation testing introduces deliberate bugs (flipping `==` to `!=`, removing `SecItemDelete` calls, swapping `&&` to `||`) and checks whether your tests catch them. A project can have 81% code coverage but only 16% mutation score - tests execute security code without validating it does the right thing.
774
-
775
- **Muter** (`brew install muter-mutation-testing/muter/muter`) is the primary Swift mutation testing tool. Its `RelationalOperatorReplacement` operator catches authentication bypasses; `RemoveSideEffects` catches missing `SecItemDelete` calls in logout flows. For security code, target mutation score above **80%**.
776
-
777
- ### OWASP MASTG Keychain Validation
778
-
779
- MASTG-TEST-0052 requires that sensitive data use the Keychain, not `NSUserDefaults` or `.plist` files. OWASP also documents that keychain data persists after app uninstallation - the app sandbox is wiped but keychain items remain. Standard mitigation is a fresh-install detector (see `common-anti-patterns.md`):
520
+ **Performance.**
780
521
 
781
522
  ```swift
782
- static func handleFreshInstall(keychain: KeychainServiceProtocol) {
783
- let hasLaunched = UserDefaults.standard.bool(forKey: "has_launched")
784
- if !hasLaunched {
785
- try? keychain.deleteAll()
786
- UserDefaults.standard.set(true, forKey: "has_launched")
523
+ func testSaveDeleteCost() {
524
+ let runs = XCTMeasureOptions()
525
+ runs.iterationCount = 20
526
+ measure(metrics: [XCTCPUMetric(), XCTClockMetric()], options: runs) {
527
+ try? store.put(Data(count: 64), for: "perf")
528
+ try? store.remove(for: "perf")
787
529
  }
788
530
  }
789
531
  ```
790
532
 
791
- ---
792
-
793
- ## Conclusion
533
+ **Mutation testing.** Coverage says code ran, not that a test would notice it changing. Mutations such as flipping `==` and `!=`, deleting a `SecItemDelete` call or swapping `&&` and `||` expose the gap; a project can show 81% line coverage and a 16% mutation score. Muter, installed with Homebrew from the `muter-mutation-testing/muter` tap, provides the operators: `RelationalOperatorReplacement` finds inverted checks that would become authentication bypasses, and `RemoveSideEffects` finds logout paths whose deletes nobody verifies. Aim for a mutation score above 80%.
794
534
 
795
- Protocol-abstraction is non-negotiable for testable keychain code. Every `SecItem` call should be behind `KeychainServiceProtocol` so that 95%+ of your test suite runs against `MockKeychainService` with zero entitlement requirements and zero CI flakiness. Reserve real-keychain integration tests for a dedicated test plan on physical devices.
535
+ **MASTG-TEST-0052.** Sensitive data must be in the keychain, not in `NSUserDefaults` or property lists, and because the keychain survives uninstall the app needs a fresh-install detector: a `has_launched` marker that triggers `removeAll()` when missing, run after protected data is available.
796
536
 
797
- Three insights most guides miss: (1) the simulator silently returns biometric-protected items without prompting - tests appear to validate biometric gates but test nothing; (2) TN3137's distinction between file-based and data protection keychains means `security create-keychain` in CI creates the wrong keychain type; (3) mutation testing reveals that even high-coverage suites fail to catch inverted conditionals and removed side effects - the exact mutations that create real vulnerabilities.
537
+ ## Takeaways
798
538
 
799
- ---
539
+ Aim for more than 95% of the suite running against the mock with no entitlements at all, and keep real-keychain tests in the device test plan. Three facts are easy to miss: on Simulator a Face ID or Touch ID protected item comes back with no prompt at all, `security create-keychain` makes a keychain of the wrong type for iOS-style tests, and mutation testing finds inverted conditions and removed side effects that coverage does not.
800
540
 
801
- ## Summary Checklist
541
+ ## Checklist
802
542
 
803
- 1. **Protocol abstraction** - All keychain access goes through `KeychainServiceProtocol`; no direct `SecItem*` calls in business logic
804
- 2. **Mock with injectable errors** - `MockKeychainService` supports `errorToThrow` for testing `errSecDuplicateItem`, `errSecAuthFailed`, `errSecInteractionNotAllowed`, and `errSecItemNotFound` paths
805
- 3. **setUp/tearDown cleanup** - Every integration test using real keychain has both pre-test and post-test cleanup with a test-specific `kSecAttrService`
806
- 4. **Secure Enclave guard** - All SE tests use `try XCTSkipUnless(SecureEnclave.isAvailable, ...)` or protocol-based fallback; never call `SecureEnclave.P256.*` unconditionally
807
- 5. **Biometric mock** - `LAContext` wrapped behind protocol or subclass mock; tests cover success, user cancel, lockout, and not-enrolled scenarios
808
- 6. **Simulator/device split** - Two Xcode test plans: `CITests` (mock-based, simulator-safe) and `DeviceTests` (real keychain, SE, biometrics on physical device)
809
- 7. **CI keychain setup** - GitHub Actions calls `security set-key-partition-list` after cert import; test target has host app with Keychain Sharing capability enabled
810
- 8. **CryptoKit round-trips** - Encrypt→decrypt and sign→verify tests for AES-GCM, ChaChaPoly, P256, Curve25519; wrong-key failure tests included
811
- 9. **Error path coverage** - Every `OSStatus` code the app can encounter has a corresponding test with injected mock failure
812
- 10. **Migration testing** - UserDefaults→Keychain migration tested with isolated `UserDefaults(suiteName:)` and mock keychain; verifies source cleared after migration
813
- 11. **Mutation testing baseline** - Muter mutation score ≥80% for security-critical code paths; `RelationalOperatorReplacement` and `RemoveSideEffects` operators enabled
543
+ - [ ] All keychain access goes through the `SecretStore` protocol.
544
+ - [ ] The mock can inject duplicate, auth-failed, user-canceled, interaction-not-allowed and not-found failures.
545
+ - [ ] Real-keychain tests use their own service and clean up in `setUp` and `tearDown`.
546
+ - [ ] Secure Enclave tests are compiled out or skipped on Simulator, check `SecureEnclave.isAvailable` on hardware, or go through the protocol fallback.
547
+ - [ ] `LAContext` is behind a protocol; success, cancel, lockout and not-enrolled are covered.
548
+ - [ ] Two test plans: `CITests` and `DeviceTests`.
549
+ - [ ] CI sets the key partition list after importing certificates; the test host has Keychain Sharing.
550
+ - [ ] CryptoKit round trips for AES-GCM, ChaChaPoly, P256 and Curve25519, each with a wrong-key or tampered-input case.
551
+ - [ ] Every reachable `OSStatus` has an injected-failure test.
552
+ - [ ] Migration tests use an isolated suite and the mock, and check the source is cleared after verification.
553
+ - [ ] Muter reports at least 80% with `RelationalOperatorReplacement` and `RemoveSideEffects`.