appship 0.1.0 → 0.2.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.
@@ -0,0 +1,221 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "minitest/autorun"
4
+ require "stringio"
5
+ require_relative "../lib/appship"
6
+
7
+ class ProjectConfigTest < Minitest::Test
8
+ def setup
9
+ @directory = Dir.mktmpdir("appship-project-config-")
10
+ @original_directory = Dir.pwd
11
+ @original_env_file = ENV["APPSHIP_ENV_FILE"]
12
+ @original_stdin = $stdin
13
+ @project_dir = File.join(@directory, "project")
14
+ FileUtils.mkdir_p(File.join(@project_dir, "Demo.xcodeproj"))
15
+ Dir.chdir(@project_dir)
16
+ @local_path = Appship::ProjectConfig.path
17
+ @global_path = File.join(@directory, "global.config")
18
+ ENV["APPSHIP_ENV_FILE"] = @global_path
19
+ $stdin = StringIO.new
20
+ @profile = {
21
+ "project_name" => "Demo", "app_name" => "GlobalApp",
22
+ "pgyer_api_key" => "pgyer-key", "pgyer_password" => "global-password",
23
+ "fir_api_key" => "fir-key", "fir_password" => "fir-password",
24
+ "wechat_webhook" => "https://example.test/wechat", "configuration" => "Release"
25
+ }
26
+ @global_content = JSON.pretty_generate([@profile, { "project_name" => "Other", "custom" => "keep" }])
27
+ File.write(@global_path, @global_content)
28
+ end
29
+
30
+ def teardown
31
+ Dir.chdir(@original_directory)
32
+ ENV["APPSHIP_ENV_FILE"] = @original_env_file
33
+ $stdin = @original_stdin
34
+ FileUtils.remove_entry(@directory)
35
+ end
36
+
37
+ def write_local(data)
38
+ FileUtils.mkdir_p(File.dirname(@local_path))
39
+ File.write(@local_path, YAML.dump(data))
40
+ end
41
+
42
+ def resolve(**options)
43
+ Appship::ProjectConfig.load(@global_path).resolve(interactive: false, **options)
44
+ end
45
+
46
+ def test_partial_project_config_inherits_only_the_matching_global_profile
47
+ local = { "app_name" => "LocalApp", "configuration" => "Debug" }
48
+ write_local(local)
49
+
50
+ %w[pgyer fir].each do |provider|
51
+ profile = resolve(provider: provider)
52
+ assert_equal @profile.merge(local), profile
53
+ refute profile.key?("custom")
54
+ end
55
+ assert_equal local, YAML.safe_load_file(@local_path)
56
+ assert_equal @global_content, File.read(@global_path)
57
+ end
58
+
59
+ def test_explicit_empty_values_override_global_passwords_and_webhooks
60
+ local = {
61
+ "pgyer_api_key" => "local-pgyer-key", "pgyer_password" => "",
62
+ "fir_api_key" => "local-fir-key", "fir_password" => "",
63
+ "wechat_webhook" => "", "dingtalk_webhook" => "https://example.test/dingtalk"
64
+ }
65
+ write_local(local)
66
+ profile = resolve
67
+ options = {}
68
+ capture_io { Appship::CLI.new.send(:apply_cached_profile!, options, profile) }
69
+
70
+ assert_equal "local-pgyer-key", options[:api_key]
71
+ assert_equal "", options[:password]
72
+ assert_equal 1, options[:install_type]
73
+ assert_equal "", options[:wechat_webhook]
74
+ assert_equal "https://example.test/dingtalk", options[:dingtalk_webhook]
75
+ assert_equal local, resolve(provider: "fir").slice(*local.keys)
76
+ assert_equal @global_content, File.read(@global_path)
77
+ end
78
+
79
+ def test_complete_project_config_does_not_create_global_cache
80
+ write_local(@profile)
81
+ missing = File.join(@directory, "missing", ".config")
82
+
83
+ profile = Appship::ProjectConfig.load(missing).resolve(interactive: false)
84
+
85
+ assert_equal @profile, profile
86
+ refute File.exist?(File.dirname(missing))
87
+ end
88
+
89
+ def test_other_projects_credentials_are_never_used_as_fallback
90
+ File.write(@global_path, JSON.generate([@profile.merge("project_name" => "Other")]))
91
+ write_local("app_name" => "LocalApp")
92
+
93
+ error = assert_raises(Appship::ConfigurationError) { resolve }
94
+
95
+ assert_includes error.message, "缺少 pgyer_api_key"
96
+ refute_includes File.read(@local_path), "pgyer-key"
97
+ end
98
+
99
+ def test_missing_values_are_prompted_into_local_config_and_can_resume
100
+ write_local("project_name" => "Demo")
101
+ missing = File.join(@directory, "missing.config")
102
+ input = StringIO.new("LocalApp\n")
103
+ input.define_singleton_method(:tty?) { true }
104
+ $stdin = input
105
+
106
+ capture_io do
107
+ assert_raises(Appship::ConfigurationError) do
108
+ Appship::ProjectConfig.load(missing).resolve
109
+ end
110
+ end
111
+ assert_equal "LocalApp", YAML.safe_load_file(@local_path)["app_name"]
112
+ $stdin = StringIO.new("new-key\n\n")
113
+ $stdin.define_singleton_method(:tty?) { true }
114
+ capture_io { Appship::ProjectConfig.load(missing).resolve }
115
+
116
+ assert_equal({ "project_name" => "Demo", "app_name" => "LocalApp", "pgyer_api_key" => "new-key", "pgyer_password" => "" }, YAML.safe_load_file(@local_path))
117
+ refute File.exist?(missing)
118
+ assert_equal @global_content, File.read(@global_path)
119
+ end
120
+
121
+ def test_credential_overrides_only_persist_locally_and_keep_other_platforms
122
+ write_local("project_name" => "Demo", "custom" => "keep")
123
+
124
+ profile = resolve(credential_overrides: { "pgyer_api_key" => "local-key", "pgyer_password" => "" })
125
+
126
+ assert_equal "local-key", profile["pgyer_api_key"]
127
+ assert_equal "fir-key", profile["fir_api_key"]
128
+ assert_equal({ "project_name" => "Demo", "custom" => "keep", "pgyer_api_key" => "local-key", "pgyer_password" => "" }, YAML.safe_load_file(@local_path))
129
+ assert_equal @global_content, File.read(@global_path)
130
+ assert_equal 0o600, File.stat(@local_path).mode & 0o777
131
+ end
132
+
133
+ def test_setting_a_value_equal_to_global_still_saves_an_explicit_local_override
134
+ write_local("project_name" => "Demo")
135
+ cache = Appship::ProjectConfig.load(@global_path)
136
+ profile = cache.resolve(interactive: false)
137
+ cache.update_profile!(profile, "pgyer_password" => @profile["pgyer_password"])
138
+
139
+ assert_equal @profile["pgyer_password"], YAML.safe_load_file(@local_path)["pgyer_password"]
140
+ assert_equal @global_content, File.read(@global_path)
141
+ end
142
+
143
+ def test_webhook_command_writes_project_config_without_copying_global_secrets
144
+ write_local("project_name" => "Demo")
145
+ capture_io { Appship::CLI.new.start(["webhook", "wechat", ""]) }
146
+
147
+ assert_equal({ "project_name" => "Demo", "wechat_webhook" => "" }, YAML.safe_load_file(@local_path))
148
+ assert_equal @global_content, File.read(@global_path)
149
+ end
150
+
151
+ def test_without_local_config_the_existing_global_cache_is_used
152
+ cache = Appship::ProjectConfig.load(@global_path)
153
+ assert_instance_of Appship::Cache, cache
154
+ profile = cache.resolve(interactive: false)
155
+ cache.update_profile!(profile, "pgyer_password" => "")
156
+
157
+ assert_equal "", JSON.parse(File.read(@global_path)).first["pgyer_password"]
158
+ refute File.exist?(@local_path)
159
+ end
160
+
161
+ def test_generic_commands_also_receive_merged_build_and_upload_defaults
162
+ write_local("app_name" => "LocalApp", "pgyer_password" => "", "wechat_webhook" => "")
163
+ config = Appship::Config.find
164
+ cli = Appship::CLI.new
165
+ build = cli.send(:build_options, config)
166
+ upload = cli.send(:upload_options, config)
167
+
168
+ assert_equal @local_path, config.path
169
+ assert_equal "Demo", build[:scheme]
170
+ assert_equal "Demo.xcodeproj", build[:project]
171
+ assert_equal "Release", build[:configuration]
172
+ assert_equal "pgyer-key", upload[:api_key]
173
+ assert_equal "LocalApp", upload[:app_name]
174
+ assert_equal "", upload[:password]
175
+ assert_equal 1, upload[:install_type]
176
+ assert_equal "", upload[:wechat_webhook]
177
+ end
178
+
179
+ def test_invalid_project_config_does_not_silently_use_global_config
180
+ write_local([])
181
+ assert_raises(Appship::ConfigurationError) { resolve }
182
+ File.write(@local_path, "{broken")
183
+ assert_raises(Appship::ConfigurationError) { resolve }
184
+ end
185
+
186
+ def test_duplicate_global_project_names_are_rejected
187
+ write_local("app_name" => "LocalApp")
188
+ File.write(@global_path, JSON.generate([@profile, @profile]))
189
+
190
+ error = assert_raises(Appship::ConfigurationError) { resolve }
191
+
192
+ assert_includes error.message, "project_name 重复"
193
+ end
194
+
195
+ def test_symlinked_project_config_is_rejected_without_changing_target
196
+ FileUtils.mkdir_p(File.dirname(@local_path))
197
+ File.symlink(@global_path, @local_path)
198
+
199
+ assert_raises(Appship::ConfigurationError) { Appship::ProjectConfig.load(@global_path) }
200
+ assert_equal @global_content, File.read(@global_path)
201
+ end
202
+
203
+ def test_samples_are_not_loaded_as_active_configuration_or_message
204
+ Appship::ProjectConfig.init!
205
+ File.write("#{@local_path}.sample", JSON.generate("app_name" => "MustNotUse"))
206
+ File.write("#{Appship::Message.path}.sample", "MustNotUse %{name}\n")
207
+
208
+ assert_equal "GlobalApp", resolve["app_name"]
209
+ refute_includes Appship::Message.template, "MustNotUse"
210
+ refute File.exist?(@local_path)
211
+ refute File.exist?(Appship::Message.path)
212
+ end
213
+
214
+ def test_init_rejects_symlinked_sample_without_changing_target
215
+ FileUtils.mkdir_p(File.dirname(@local_path))
216
+ File.symlink(@global_path, "#{@local_path}.sample")
217
+
218
+ assert_raises(Appship::ConfigurationError) { Appship::ProjectConfig.init! }
219
+ assert_equal @global_content, File.read(@global_path)
220
+ end
221
+ end
@@ -0,0 +1,110 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "minitest/autorun"
4
+ require "stringio"
5
+ require_relative "../lib/appship"
6
+
7
+ class UploadProgressTest < Minitest::Test
8
+ class Terminal < StringIO
9
+ def tty?
10
+ true
11
+ end
12
+ end
13
+
14
+ class Connection
15
+ attr_reader :body
16
+
17
+ def initialize(result)
18
+ @result = result
19
+ end
20
+
21
+ def request(request)
22
+ @body = request.body_stream
23
+ if @result.is_a?(Exception)
24
+ @body.read(1024)
25
+ raise @result
26
+ end
27
+ while @body.read(16 * 1024); end
28
+ @result
29
+ end
30
+ end
31
+
32
+ def test_progress_does_not_report_success_until_server_acknowledges
33
+ [StringIO.new, Terminal.new].each do |output|
34
+ progress = Appship::UploadProgress.new(100, output: output)
35
+ progress.start
36
+ progress.update(100)
37
+ refute_match(/100%|100 %|上传完成/, output.string)
38
+
39
+ progress.finish(success: true)
40
+ assert_includes output.string, "上传完成"
41
+ assert_match(/100%/, output.string)
42
+ completed = output.string.dup
43
+ progress.finish
44
+ assert_equal completed, output.string
45
+ end
46
+ end
47
+
48
+ def test_failed_upload_keeps_actual_byte_count_without_fake_completion
49
+ output = Terminal.new
50
+ progress = Appship::UploadProgress.new(100, output: output)
51
+ progress.start
52
+ progress.update(25)
53
+ progress.finish(success: false)
54
+
55
+ assert_includes output.string, "上传失败"
56
+ assert_includes output.string, "25B/100B"
57
+ refute_match(/100%|上传完成/, output.string)
58
+ end
59
+
60
+ def test_both_platforms_complete_progress_only_on_http_success_and_close_streams
61
+ Dir.mktmpdir do |directory|
62
+ file = File.join(directory, "Demo.ipa")
63
+ File.write(file, "x" * 65_536)
64
+ [Appship::PgyerUploader, Appship::FirUploader].each do |base|
65
+ [200, 403, SocketError.new("network failed")].each do |status|
66
+ result = if status.is_a?(Exception)
67
+ status
68
+ else
69
+ response = Net::HTTPResponse::CODE_TO_OBJ.fetch(status.to_s).new("1.1", status.to_s, "")
70
+ response.body = "response"
71
+ response.instance_variable_set(:@read, true)
72
+ response
73
+ end
74
+ connection = Connection.new(result)
75
+ klass = Class.new(base) do
76
+ attr_accessor :connection
77
+
78
+ private
79
+
80
+ def http(_uri)
81
+ connection
82
+ end
83
+ end
84
+ uploader = klass.new(api_key: "key", fir_api_token: "token")
85
+ uploader.connection = connection
86
+ call = if base == Appship::PgyerUploader
87
+ -> { uploader.send(:upload_to_cos!, file, { endpoint: "https://example.test/upload", key: "key", signature: "signature", security_token: "token" }) }
88
+ else
89
+ -> { uploader.send(:multipart_upload!, "https://example.test/upload", { "key" => "key" }, file) }
90
+ end
91
+ output, = capture_io do
92
+ if status == 200
93
+ assert call.call
94
+ else
95
+ assert_raises(status.is_a?(Exception) ? SocketError : Appship::UploadError) { call.call }
96
+ end
97
+ end
98
+
99
+ assert connection.body.instance_variable_get(:@file).closed?
100
+ if status == 200
101
+ assert_includes output, "上传完成"
102
+ else
103
+ assert_includes output, "上传失败"
104
+ refute_match(/100%|上传完成/, output)
105
+ end
106
+ end
107
+ end
108
+ end
109
+ end
110
+ end
@@ -0,0 +1,352 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "minitest/autorun"
4
+ require_relative "../lib/appship"
5
+
6
+ class WebhookTest < Minitest::Test
7
+ class FakeHTTP
8
+ attr_accessor :use_ssl, :open_timeout, :read_timeout, :write_timeout, :max_retries
9
+ attr_reader :requests
10
+
11
+ def initialize(*responses)
12
+ @responses = responses
13
+ @requests = []
14
+ end
15
+
16
+ def request(request)
17
+ @requests << request
18
+ response = @responses.shift
19
+ raise response if response.is_a?(Exception)
20
+ raise "Unexpected request" unless response
21
+
22
+ response
23
+ end
24
+ end
25
+
26
+ FakeUploader = Struct.new(:result) do
27
+ def upload!(_file)
28
+ raise result if result.is_a?(Exception)
29
+
30
+ result
31
+ end
32
+ end
33
+
34
+ def setup
35
+ @webhook_home = Dir.mktmpdir("appship-webhook-")
36
+ @original_home = ENV["HOME"]
37
+ @original_directory = Dir.pwd
38
+ ENV["HOME"] = @webhook_home
39
+ Dir.chdir(@webhook_home)
40
+ end
41
+
42
+ def teardown
43
+ Dir.chdir(@original_directory)
44
+ ENV["HOME"] = @original_home
45
+ FileUtils.remove_entry(@webhook_home)
46
+ end
47
+
48
+ def response(body = '{"errcode":0,"errmsg":"ok"}', status: "200")
49
+ result = Net::HTTPResponse::CODE_TO_OBJ.fetch(status).new("1.1", status, "")
50
+ result.body = body
51
+ result.instance_variable_set(:@read, true)
52
+ result
53
+ end
54
+
55
+ def with_constructor(klass, object)
56
+ original = klass.method(:new)
57
+ klass.define_singleton_method(:new) { |*| object }
58
+ yield
59
+ ensure
60
+ klass.define_singleton_method(:new, original)
61
+ end
62
+
63
+ def test_posts_release_message_as_utf8_text_to_both_robots
64
+ http = FakeHTTP.new(response, response)
65
+ message = "应用名称: Demo\n更新描述: 完成 100%\n"
66
+ options = {
67
+ wechat_webhook: "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test-key",
68
+ dingtalk_webhook: "https://oapi.dingtalk.com/robot/send?access_token=test-token"
69
+ }
70
+
71
+ output, errors = capture_io do
72
+ with_constructor(Net::HTTP, http) { Appship::Webhook.new(options).notify(message) }
73
+ end
74
+
75
+ assert_equal 2, http.requests.length
76
+ http.requests.each do |request|
77
+ assert_equal "POST", request.method
78
+ assert_equal "application/json; charset=utf-8", request["Content-Type"]
79
+ assert_equal({ "msgtype" => "text", "text" => { "content" => message } }, JSON.parse(request.body))
80
+ end
81
+ assert http.use_ssl
82
+ assert_equal 0, http.max_retries
83
+ assert_includes output, "企业微信发布通知已发送"
84
+ assert_includes output, "钉钉发布通知已发送"
85
+ assert_empty errors
86
+ refute_includes output, "test-key"
87
+ refute_includes output, "test-token"
88
+ end
89
+
90
+ def test_unset_webhooks_do_not_make_requests
91
+ http = FakeHTTP.new
92
+ output, errors = capture_io do
93
+ with_constructor(Net::HTTP, http) do
94
+ Appship::Webhook.new(wechat_webhook: "", dingtalk_webhook: nil).notify("message")
95
+ end
96
+ end
97
+
98
+ assert_empty output
99
+ assert_empty errors
100
+ assert_empty http.requests
101
+ end
102
+
103
+ def test_failure_notification_contains_context_but_hides_credentials
104
+ url = "https://example.test/send?key=webhook-token"
105
+ http = FakeHTTP.new(response)
106
+ webhook = Appship::Webhook.new(
107
+ app_name: "Demo", provider: "fir", configuration: "Release",
108
+ api_key: "api-secret", password: 123456, fir_api_token: "fir-secret",
109
+ wechat_webhook: url
110
+ )
111
+ error = Appship::UploadError.new("failed api-secret 123456 fir-secret #{url} https://upload.test/path?signature=upload-secret")
112
+ output, errors = capture_io do
113
+ with_constructor(Net::HTTP, http) { webhook.failure(error) }
114
+ end
115
+
116
+ content = JSON.parse(http.requests.first.body).dig("text", "content")
117
+ assert_includes content, "发布流程失败"
118
+ assert_includes content, "应用名称: Demo"
119
+ assert_includes content, "分发平台: fir.im"
120
+ assert_includes content, "构建配置: Release"
121
+ %w[api-secret 123456 fir-secret webhook-token upload-secret].each { |secret| refute_includes content, secret }
122
+ assert_includes output, "企业微信失败通知已发送"
123
+ assert_empty errors
124
+ end
125
+
126
+ def test_failure_notification_error_does_not_claim_the_app_was_published
127
+ http = FakeHTTP.new(SocketError.new("network failed"))
128
+ output, errors = capture_io do
129
+ with_constructor(Net::HTTP, http) do
130
+ Appship::Webhook.new(wechat_webhook: "https://example.test/send").failure(Appship::CommandError.new("archive failed"))
131
+ end
132
+ end
133
+
134
+ assert_equal 1, http.requests.length
135
+ assert_empty output
136
+ assert_includes errors, "失败通知失败"
137
+ refute_includes errors, "应用已发布"
138
+ end
139
+
140
+ def test_failed_robot_does_not_prevent_other_robot_from_receiving_message
141
+ http = FakeHTTP.new(response('{"errcode":310000,"errmsg":"keyword missing"}'), response)
142
+ output, errors = capture_io do
143
+ with_constructor(Net::HTTP, http) do
144
+ Appship::Webhook.new(
145
+ wechat_webhook: "https://example.test/wechat?key=secret",
146
+ dingtalk_webhook: "https://example.test/dingtalk?access_token=secret"
147
+ ).notify("发布消息")
148
+ end
149
+ end
150
+
151
+ assert_equal 2, http.requests.length
152
+ assert_includes errors, "310000"
153
+ assert_includes errors, "应用已发布"
154
+ refute_includes errors, "secret"
155
+ assert_includes output, "钉钉发布通知已发送"
156
+ end
157
+
158
+ def test_http_parse_and_network_failures_are_warnings
159
+ [
160
+ response("secret-body", status: "403"),
161
+ response("not-json"),
162
+ response("{}"),
163
+ Net::ReadTimeout.new
164
+ ].each do |failure|
165
+ http = FakeHTTP.new(failure)
166
+ output, errors = capture_io do
167
+ with_constructor(Net::HTTP, http) do
168
+ Appship::Webhook.new(wechat_webhook: "https://example.test/send?key=secret-token").notify("message")
169
+ end
170
+ end
171
+
172
+ assert_empty output
173
+ assert_includes errors, "通知失败"
174
+ refute_includes errors, "secret"
175
+ end
176
+ end
177
+
178
+ def test_successful_uploads_send_the_exact_rendered_message
179
+ FileUtils.mkdir_p(File.dirname(Appship::Message.path))
180
+ File.write(Appship::Message.path, YAML.dump("cli_content" => "发布成功\n应用: %{name}\n下载: %{url}\n"))
181
+ file = File.join(@webhook_home, "Demo.ipa")
182
+ File.write(file, "ipa-data")
183
+ results = [
184
+ { "data" => { "buildName" => "Demo", "buildKey" => "demo" } },
185
+ { "provider" => "fir", "data" => { "name" => "Demo" }, "url" => "https://fir.im/demo" }
186
+ ]
187
+
188
+ results.each do |result|
189
+ [:direct, :after_build].each do |flow|
190
+ options = { file: file, upload: true, wechat_webhook: "https://example.test/send" }
191
+ http = FakeHTTP.new(response)
192
+ output, errors = capture_io do
193
+ with_constructor(Net::HTTP, http) do
194
+ with_constructor(Appship::Uploader, FakeUploader.new(result)) do
195
+ builder = Appship::Builder.new
196
+ if flow == :direct
197
+ assert_equal result, builder.upload!(options)
198
+ else
199
+ builder.send(:upload_if_requested!, file, options)
200
+ end
201
+ end
202
+ end
203
+ end
204
+
205
+ assert_equal 1, http.requests.length
206
+ content = JSON.parse(http.requests.first.body).fetch("text").fetch("content")
207
+ assert_equal content + "✅ 企业微信发布通知已发送\n", output
208
+ assert_includes content, "应用: Demo"
209
+ assert_includes content, result["provider"] == "fir" ? "https://fir.im/demo" : "https://www.pgyer.com/demo"
210
+ assert_empty errors
211
+ end
212
+ end
213
+ end
214
+
215
+ def test_successful_upload_uses_each_channel_template_and_approved_format
216
+ FileUtils.mkdir_p(File.dirname(Appship::Message.path))
217
+ File.write(Appship::Message.path, <<~YAML)
218
+ cli_content: |
219
+ 终端: %{name}
220
+ wechat_webhook_success_content: |
221
+ **企微: %{name}**
222
+ wechat_webhook_content: 旧版企微文案
223
+ wechat_webhook_failure_content: "%{password}"
224
+ wechat_webhook_format: markdown
225
+ dingtalk_webhook_content: |
226
+ 钉钉: %{name}
227
+ feishu_webhook_success_content: |
228
+ 飞书: %{name}
229
+ 下载: %{url}
230
+ feishu_webhook_format: post
231
+ YAML
232
+ file = File.join(@webhook_home, "Demo.ipa")
233
+ File.write(file, "ipa-data")
234
+ options = {
235
+ file: file,
236
+ wechat_webhook: "https://example.test/wechat",
237
+ dingtalk_webhook: "https://example.test/dingtalk",
238
+ feishu_webhook: "https://example.test/feishu",
239
+ feishu_webhook_secret: "feishu-signing-secret"
240
+ }
241
+ result = { "provider" => "fir", "data" => { "name" => "Demo" }, "url" => "https://fir.im/demo" }
242
+ http = FakeHTTP.new(response, response, response('{"code":0,"msg":"success"}'))
243
+ output, errors = capture_io do
244
+ with_constructor(Net::HTTP, http) do
245
+ with_constructor(Appship::Uploader, FakeUploader.new(result)) { Appship::Builder.new.upload!(options) }
246
+ end
247
+ end
248
+
249
+ wechat, dingtalk, feishu = http.requests.map { |request| JSON.parse(request.body) }
250
+ assert_includes output, "终端: Demo\n"
251
+ refute_includes output, "企微: Demo"
252
+ refute_includes output, "feishu-signing-secret"
253
+ assert_empty errors
254
+ assert_equal({ "msgtype" => "markdown", "markdown" => { "content" => "**企微: Demo**\n" } }, wechat)
255
+ assert_equal "钉钉: Demo\n", dingtalk.dig("text", "content")
256
+ assert_equal "post", feishu["msg_type"]
257
+ paragraphs = feishu.dig("content", "post", "zh_cn", "content")
258
+ assert_equal({ "tag" => "text", "text" => "飞书: Demo" }, paragraphs.first.first)
259
+ assert_equal({ "tag" => "a", "text" => "https://fir.im/demo", "href" => "https://fir.im/demo" }, paragraphs.last.last)
260
+ expected_signature = OpenSSL::HMAC.digest("SHA256", "#{feishu.fetch("timestamp")}\nfeishu-signing-secret", "")
261
+ assert_equal expected_signature, feishu.fetch("sign").unpack1("m0")
262
+ end
263
+
264
+ def test_custom_failure_messages_use_project_directory_and_keep_redaction
265
+ project = File.join(@webhook_home, "project")
266
+ path = Appship::Message.path(project)
267
+ FileUtils.mkdir_p(File.dirname(path))
268
+ File.write(path, <<~YAML)
269
+ cli_content: "成功文案"
270
+ wechat_webhook_success_content: "企微成功"
271
+ wechat_webhook_failure_content: |
272
+ 企微失败:%{name} / %{provider} / %{configuration}
273
+ %{error_type}:%{error_message}
274
+ wechat_webhook_format: markdown
275
+ dingtalk_webhook_failure_content: "%{password}"
276
+ feishu_webhook_failure_content: "飞书失败:%{error_message}"
277
+ feishu_webhook_format: post
278
+ YAML
279
+ options = {
280
+ project_dir: project, app_name: "Demo", provider: "fir", configuration: "Release",
281
+ wechat_webhook: "https://example.test/wechat?key=wechat-secret",
282
+ dingtalk_webhook: "https://example.test/dingtalk",
283
+ feishu_webhook: "https://open.feishu.cn/open-apis/bot/v2/hook/feishu-token",
284
+ feishu_webhook_secret: "signing-secret", api_key: "api-secret"
285
+ }
286
+ error = Appship::CommandError.new("归档失败 api-secret feishu-token signing-secret https://upload.test/path?token=query-secret")
287
+ http = FakeHTTP.new(response, response, response('{"code":0}'))
288
+ _output, warnings = capture_io do
289
+ with_constructor(Net::HTTP, http) { Appship::Webhook.new(options).failure(error) }
290
+ end
291
+
292
+ wechat, dingtalk, feishu = http.requests.map { |request| JSON.parse(request.body) }
293
+ assert_equal "text", wechat["msgtype"]
294
+ assert_equal "text", feishu["msg_type"]
295
+ assert feishu.key?("sign")
296
+ assert_includes wechat.dig("text", "content"), "企微失败:Demo / fir.im / Release"
297
+ assert_includes wechat.dig("text", "content"), "Appship::CommandError"
298
+ assert_includes dingtalk.dig("text", "content"), "Appship 发布流程失败"
299
+ assert_includes feishu.dig("content", "text"), "飞书失败:归档失败"
300
+ assert_includes warnings, "dingtalk_webhook_failure_content 无效"
301
+ refute_match(/api-secret|feishu-token|signing-secret|query-secret|企微成功/, http.requests.map(&:body).join)
302
+ end
303
+
304
+ def test_invalid_message_yaml_does_not_prevent_builtin_failure_notification
305
+ path = Appship::Message.path
306
+ FileUtils.mkdir_p(File.dirname(path))
307
+ File.write(path, "cli_content: [")
308
+ http = FakeHTTP.new(response)
309
+ _output, warnings = capture_io do
310
+ with_constructor(Net::HTTP, http) do
311
+ Appship::Webhook.new(wechat_webhook: "https://example.test/send").failure(Appship::CommandError.new("上传失败"))
312
+ end
313
+ end
314
+
315
+ assert_includes JSON.parse(http.requests.first.body).dig("text", "content"), "上传失败"
316
+ assert_includes warnings, "无法读取失败消息模板"
317
+ end
318
+
319
+ def test_feishu_uses_text_without_signature_by_default_and_hides_secrets_on_failure
320
+ url = "https://open.feishu.cn/open-apis/bot/v2/hook/feishu-token"
321
+ http = FakeHTTP.new(response('{"code":0}'), response('{"code":0}'))
322
+ capture_io do
323
+ with_constructor(Net::HTTP, http) do
324
+ Appship::Webhook.new(feishu_webhook: url).notify("发布成功")
325
+ Appship::Webhook.new(feishu_webhook: url, feishu_webhook_secret: "signing-secret")
326
+ .failure(Appship::CommandError.new("error #{url} feishu-token signing-secret"))
327
+ end
328
+ end
329
+
330
+ text, failure = http.requests.map { |request| JSON.parse(request.body) }
331
+ assert_equal({ "msg_type" => "text", "content" => { "text" => "发布成功" } }, text)
332
+ assert_equal "text", failure["msg_type"]
333
+ assert failure.key?("sign")
334
+ assert_includes failure.dig("content", "text"), "发布流程失败"
335
+ refute_match(/feishu-token|signing-secret/, failure.dig("content", "text"))
336
+ end
337
+
338
+ def test_upload_failure_never_sends_success_notification
339
+ file = File.join(@webhook_home, "Demo.ipa")
340
+ File.write(file, "ipa-data")
341
+ options = { file: file, upload: true, wechat_webhook: "https://example.test/send" }
342
+
343
+ http = FakeHTTP.new
344
+ with_constructor(Net::HTTP, http) do
345
+ with_constructor(Appship::Uploader, FakeUploader.new(Appship::UploadError.new("upload failed"))) do
346
+ assert_raises(Appship::UploadError) { Appship::Builder.new.upload!(options) }
347
+ assert_raises(Appship::UploadError) { Appship::Builder.new.send(:upload_if_requested!, file, options) }
348
+ end
349
+ end
350
+ assert_empty http.requests
351
+ end
352
+ end