Coverage for tests/publisher/test_publisher_views.py: 100%

251 statements  

« prev     ^ index     » next       coverage.py v7.16.1, created at 2026-09-18 22:11 +0000

1import unittest 

2from urllib.parse import parse_qs, urlparse 

3from webapp.app import app 

4from tests.mock_data.mock_store_logic import sample_charm 

5from unittest.mock import call, patch 

6 

7from canonicalwebteam.exceptions import ( 

8 PublisherMacaroonRefreshRequired, 

9 StoreApiResponseErrorList, 

10) 

11 

12 

13class TestPublisherViews(unittest.TestCase): 

14 @patch( 

15 "webapp.decorators.login_required", 

16 ) 

17 def setUp(self, mock_login_required): 

18 mock_login_required.return_value = lambda: True 

19 self.app = app 

20 self.app.config["TESTING"] = True 

21 self.app.config["WTF_CSRF_ENABLED"] = [] 

22 self.client = self.app.test_client() 

23 self.set_session() 

24 

25 def set_session(self): 

26 with self.client.session_transaction() as session: 

27 session["account"] = { 

28 "display-name": "test-display-name", 

29 "email": "test-email", 

30 "username": "test-username", 

31 "id": "test-id", 

32 } 

33 session["account-auth"] = "test-auth" 

34 

35 def test_get_account_details_no_login(self): 

36 with self.client.session_transaction() as session: 

37 del session["account-auth"] 

38 del session["account"] 

39 res = self.client.get("/account/details") 

40 self.assertEqual(res.status_code, 302) 

41 

42 @patch( 

43 "webapp.authentication.is_authenticated", 

44 return_value=True, 

45 ) 

46 def test_get_account_details(self, mock_is_authenticated): 

47 res = self.client.get("/account/details") 

48 self.assertEqual(res.status_code, 200) 

49 self.assertIn(b"test-display-name", res.data) 

50 self.assertIn(b"test-email", res.data) 

51 self.assertIn(b"test-username", res.data) 

52 

53 def test_solutions_csrf_token(self): 

54 res = self.client.get("/api/solutions/csrf-token") 

55 

56 self.assertEqual(res.status_code, 200) 

57 self.assertIn("csrf_token", res.get_json()) 

58 self.assertEqual(res.headers["Cache-Control"], "no-store") 

59 self.assertIn("Cookie", res.headers["Vary"]) 

60 

61 @patch("webapp.store_api.publisher_gateway.get_package_metadata") 

62 def test_get_publisher(self, mock_get_package_metadata): 

63 mock_get_package_metadata.return_value = { 

64 **sample_charm, 

65 "unlisted": False, 

66 } 

67 for endpoint in [ 

68 "listing", 

69 "releases", 

70 "publicise", 

71 "collaboration", 

72 "settings", 

73 ]: 

74 res = self.client.get(f"/test-entity/{endpoint}") 

75 self.assertEqual(res.status_code, 200) 

76 

77 @patch("webapp.store_api.publisher_gateway.get_package_metadata") 

78 def test_get_package(self, mock_get_package_metadata): 

79 mock_get_package_metadata.return_value = { 

80 "name": "test-package", 

81 "unlisted": False, 

82 } 

83 res = self.client.get("/api/packages/test-entity") 

84 self.assertEqual(res.status_code, 200) 

85 self.assertEqual( 

86 res.json, 

87 {"success": True, "data": mock_get_package_metadata.return_value}, 

88 ) 

89 

90 @patch("webapp.publisher.views.redis_cache.delete") 

91 @patch("webapp.store_api.publisher_gateway.update_package_metadata") 

92 def test_update_package( 

93 self, mock_update_package_metadata, mock_cache_delete 

94 ): 

95 mock_update_package_metadata.return_value = {"name": "test-package"} 

96 res = self.client.patch( 

97 "/api/packages/test-entity", json={"key": "value"} 

98 ) 

99 self.assertEqual(res.status_code, 200) 

100 public_fields = [ 

101 "result.media", 

102 "default-release", 

103 "result.categories", 

104 "result.publisher.display-name", 

105 "result.title", 

106 "result.unlisted", 

107 "channel-map", 

108 "result.deployable-on", 

109 "result.bugs-url", 

110 "result.website", 

111 "result.summary", 

112 "default-release.revision.metadata-yaml", 

113 "default-release.revision.readme-md", 

114 "result.links", 

115 ] 

116 public_cache_parts = { 

117 "channel": None, 

118 "fields": ",".join(sorted(public_fields)), 

119 } 

120 

121 mock_cache_delete.assert_has_calls( 

122 [ 

123 call("package_metadata:test-id:test-entity"), 

124 call(("package_details:test-entity", public_cache_parts)), 

125 call(("package:test-entity", public_cache_parts)), 

126 call(("test-entity:details-overview", public_cache_parts)), 

127 ] 

128 ) 

129 self.assertEqual(mock_cache_delete.call_count, 4) 

130 

131 @patch("webapp.store_api.publisher_gateway.update_package_metadata") 

132 def test_update_package_failure(self, mock_update_package_metadata): 

133 mock_update_package_metadata.side_effect = StoreApiResponseErrorList( 

134 "test-error", 500, [] 

135 ) 

136 res = self.client.patch( 

137 "/api/packages/test-entity", json={"key": "value"} 

138 ) 

139 self.assertEqual(res.status_code, 500) 

140 self.assertEqual(res.json["success"], False) 

141 

142 @patch("webapp.store_api.publisher_gateway.update_package_metadata") 

143 def test_update_package_unauthorized(self, mock_update_package_metadata): 

144 mock_update_package_metadata.side_effect = StoreApiResponseErrorList( 

145 "test-error", 500, [{"message": "unauthorized"}] 

146 ) 

147 res = self.client.patch( 

148 "/api/packages/test-entity", json={"key": "value"} 

149 ) 

150 self.assertEqual(res.status_code, 500) 

151 self.assertEqual(res.json["success"], False) 

152 self.assertEqual(res.json["message"], "Package not found") 

153 

154 @patch("webapp.store_api.publisher_gateway.get_account_packages") 

155 def test_list_page( 

156 self, 

157 mock_get_account_packages, 

158 ): 

159 mock_get_account_packages.return_value = [ 

160 { 

161 "contact": "email", 

162 "id": "ChgcZB3RhaDOnhkAv9cgRg52LhjBbDt8", 

163 "media": [ 

164 { 

165 "type": "icon", 

166 "url": "https://example.com/icon.svg", 

167 } 

168 ], 

169 "name": "postgresql", 

170 "private": False, 

171 "unlisted": False, 

172 "publisher": { 

173 "id": "test-id", 

174 "display-name": "Canonical Data Platform", 

175 }, 

176 "status": "published", 

177 "type": "charm", 

178 }, 

179 ] 

180 

181 with self.client.session_transaction() as session: 

182 session["account"] = { 

183 "id": "test-id", 

184 "username": "test-username", 

185 "email": "test@example.com", 

186 } 

187 

188 res = self.client.get("/charms") 

189 self.assertEqual(res.status_code, 200) 

190 self.assertIn(b"postgresql", res.data) 

191 self.assertIn(b"Published Charms", res.data) 

192 self.assertIn(b"Listed", res.data) 

193 self.assertIn(b"Solutions", res.data) 

194 res = self.client.get("/bundles") 

195 self.assertEqual(res.status_code, 200) 

196 self.assertNotIn(b"postgresql", res.data) 

197 

198 @patch("webapp.publisher.views.get_publisher_solutions", return_value=[]) 

199 def test_solutions_page_empty_state(self, mock_get_publisher_solutions): 

200 res = self.client.get("/solutions") 

201 

202 self.assertEqual(res.status_code, 200) 

203 mock_get_publisher_solutions.assert_called_once_with("test-username") 

204 self.assertIn(b"Solutions", res.data) 

205 self.assertIn(b"No published Solutions available", res.data) 

206 

207 @patch("webapp.publisher.views.get_solution_categories", return_value=[]) 

208 @patch("webapp.publisher.views.get_user_teams_for_solutions", return_value=[]) 

209 @patch("webapp.publisher.views.get_solution_from_backend") 

210 def test_edit_solution_handles_empty_deployable_on( 

211 self, 

212 mock_get_solution_from_backend, 

213 mock_get_user_teams_for_solutions, 

214 mock_get_solution_categories, 

215 ): 

216 mock_get_solution_from_backend.return_value = { 

217 "hash": "test-hash", 

218 "name": "test-solution", 

219 "title": "Test Solution", 

220 "summary": "Test summary", 

221 "status": "published", 

222 "revision": 1, 

223 "deployable-on": [], 

224 "compatibility": {}, 

225 "documentation": {}, 

226 "media": {}, 

227 "charms": [], 

228 "maintainers": [], 

229 "use_cases": [], 

230 "useful_links": [], 

231 "categories": [], 

232 } 

233 

234 res = self.client.get("/solutions/edit/test-hash") 

235 

236 self.assertEqual(res.status_code, 200) 

237 self.assertIn(b"Edit solution: Test Solution", res.data) 

238 self.assertIn(b'name="platform" value="kubernetes"', res.data) 

239 mock_get_solution_from_backend.assert_called_once_with( 

240 "test-hash", prefer_authenticated=True 

241 ) 

242 mock_get_user_teams_for_solutions.assert_called_once_with( 

243 "test-username" 

244 ) 

245 mock_get_solution_categories.assert_called_once() 

246 

247 @patch("webapp.publisher.views.redis_cache.get", return_value=None) 

248 @patch("webapp.store_api.publisher_gateway.get_account_packages") 

249 def test_list_page_expired_session_redirects_login( 

250 self, mock_get_account_packages, _ 

251 ): 

252 mock_get_account_packages.side_effect = ( 

253 PublisherMacaroonRefreshRequired() 

254 ) 

255 

256 response = self.client.get("/charms?page=1") 

257 

258 self.assertEqual(response.status_code, 302) 

259 redirect = urlparse(response.location) 

260 self.assertEqual(redirect.path, "/login") 

261 self.assertEqual( 

262 parse_qs(redirect.query).get("next"), ["/charms?page=1"] 

263 ) 

264 with self.client.session_transaction() as session: 

265 self.assertNotIn("account-auth", session) 

266 self.assertNotIn("account", session) 

267 

268 def test_accept_invite(self): 

269 res = self.client.get("/accept-invite") 

270 self.assertIn(b"Success", res.data) 

271 self.assertEqual(res.status_code, 200) 

272 

273 @patch("webapp.store_api.publisher_gateway.accept_invite") 

274 def test_accept_post_invite(self, mock_accept_invite): 

275 mock_accept_invite.return_value.status_code = 204 

276 res = self.client.post( 

277 "/accept-invite", 

278 data={"token": "test-token", "package": "test-package"}, 

279 ) 

280 self.assertEqual(res.status_code, 200) 

281 

282 @patch("webapp.store_api.publisher_gateway.accept_invite") 

283 def test_accept_post_invite_failed(self, mock_accept_invite): 

284 mock_accept_invite.return_value.status_code = 401 

285 mock_accept_invite.return_value.json.return_value = { 

286 "error-list": [{"message": "Invite token has expired"}] 

287 } 

288 res = self.client.post( 

289 "/accept-invite", 

290 data={"token": "test-token", "package": "test-package"}, 

291 ) 

292 self.assertEqual(res.status_code, 401) 

293 self.assertEqual(res.json["message"], "Invite token has expired") 

294 

295 @patch("webapp.store_api.publisher_gateway.accept_invite") 

296 def test_accept_post_invite_error_list(self, mock_accept_invite): 

297 mock_accept_invite.side_effect = StoreApiResponseErrorList( 

298 "test-error", 400, [{"message": "Invite token has expired"}] 

299 ) 

300 res = self.client.post( 

301 "/accept-invite", 

302 data={"token": "test-token", "package": "test-package"}, 

303 ) 

304 self.assertEqual(res.status_code, 400) 

305 self.assertEqual(res.json["message"], "Invite token has expired") 

306 

307 @patch("webapp.store_api.publisher_gateway.accept_invite") 

308 def test_accept_post_invite_macaroon_refresh_required( 

309 self, mock_accept_invite 

310 ): 

311 mock_accept_invite.side_effect = PublisherMacaroonRefreshRequired() 

312 res = self.client.post( 

313 "/accept-invite", 

314 data={"token": "test-token", "package": "test-package"}, 

315 ) 

316 self.assertEqual(res.status_code, 401) 

317 self.assertTrue(res.json["reauth_required"]) 

318 self.assertFalse(res.json["success"]) 

319 with self.client.session_transaction() as sess: 

320 self.assertNotIn("account-auth", sess) 

321 

322 @patch("webapp.store_api.publisher_gateway.reject_invite") 

323 def test_reject_post_invite(self, mock_reject_invite): 

324 mock_reject_invite.return_value.status_code = 204 

325 res = self.client.post( 

326 "/reject-invite", 

327 data={"token": "test-token", "package": "test-package"}, 

328 ) 

329 self.assertEqual(res.status_code, 200) 

330 

331 @patch("webapp.store_api.publisher_gateway.get_collaborators") 

332 def test_get_collaborators(self, mock_get_collaborators): 

333 mock_get_collaborators.return_value = [{"name": "collaborator"}] 

334 res = self.client.get("/api/packages/test-entity/collaborators") 

335 self.assertEqual(res.status_code, 200) 

336 

337 @patch("webapp.store_api.publisher_gateway.get_collaborators") 

338 def test_get_collaborators_failed(self, mock_get_collaborators): 

339 mock_get_collaborators.side_effect = StoreApiResponseErrorList( 

340 "test-error", 500, [] 

341 ) 

342 res = self.client.get("/api/packages/test-entity/collaborators") 

343 self.assertEqual(res.status_code, 500) 

344 

345 @patch("webapp.store_api.publisher_gateway.get_pending_invites") 

346 def test_get_pending_invites(self, mock_get_pending_invites): 

347 mock_get_pending_invites.return_value = { 

348 "invites": [{"name": "invite"}] 

349 } 

350 res = self.client.get("/api/packages/test-entity/invites") 

351 self.assertEqual(res.status_code, 200) 

352 

353 @patch("webapp.utils.emailer.Emailer.send_email_template") 

354 @patch("webapp.store_api.publisher_gateway.invite_collaborators") 

355 def test_invite_collaborators( 

356 self, mock_invite_collaborators, mock_send_email_template 

357 ): 

358 mock_send_email_template.return_value = None 

359 mock_invite_collaborators.return_value = { 

360 "tokens": [{"token": "test-token"}] 

361 } 

362 res = self.client.post( 

363 "/api/packages/test-entity/invites", 

364 data={"collaborators": "collaborator"}, 

365 ) 

366 self.assertEqual(res.status_code, 200) 

367 

368 @patch("webapp.store_api.publisher_gateway.revoke_invites") 

369 def test_revoke_invite(self, mock_revoke_invites): 

370 mock_revoke_invites.return_value.status_code = 204 

371 res = self.client.delete( 

372 "/api/packages/test-entity/invites", 

373 data={"collaborator": "collaborator"}, 

374 ) 

375 self.assertEqual(res.status_code, 200) 

376 

377 def test_register_name(self): 

378 res = self.client.get("/register-name") 

379 self.assertEqual(res.status_code, 200) 

380 self.assertIn(b"Register a new", res.data) 

381 

382 @patch("webapp.store_api.publisher_gateway.register_package_name") 

383 def test_post_register_name(self, mock_register_package_name): 

384 mock_register_package_name.return_value = True 

385 res = self.client.post( 

386 "/register-name", 

387 data={ 

388 "name": "test-name", 

389 "type": "charm", 

390 "private": "private", 

391 }, 

392 ) 

393 self.assertEqual(res.status_code, 302) 

394 

395 @patch("webapp.store_api.publisher_gateway.register_package_name") 

396 def test_post_register_name_already_owned( 

397 self, mock_register_package_name 

398 ): 

399 mock_register_package_name.side_effect = StoreApiResponseErrorList( 

400 "test-error", 500, [{"code": "already-owned"}] 

401 ) 

402 res = self.client.post( 

403 "/register-name", 

404 data={ 

405 "name": "test-name", 

406 "type": "charm", 

407 "private": "private", 

408 }, 

409 ) 

410 self.assertEqual(res.status_code, 302) 

411 self.assertIn("already_owned=True", res.headers["Location"]) 

412 

413 @patch("webapp.store_api.publisher_gateway.register_package_name") 

414 def test_post_register_name_api_error_relays_message( 

415 self, mock_register_package_name 

416 ): 

417 # Publisher Gateway sometimes wraps more specific failures (e.g. 

418 # name already taken by another publisher) under a generic 

419 # "api-error" code. Rather than guessing what happened, relay the 

420 # API's own message verbatim instead of misreporting it as an 

421 # invalid name format error. 

422 mock_register_package_name.side_effect = StoreApiResponseErrorList( 

423 "test-error", 

424 500, 

425 [ 

426 { 

427 "code": "api-error", 

428 "message": "Name 'openbau' is already taken.", 

429 } 

430 ], 

431 ) 

432 res = self.client.post( 

433 "/register-name", 

434 data={ 

435 "name": "openbau", 

436 "type": "charm", 

437 "private": "private", 

438 }, 

439 ) 

440 self.assertEqual(res.status_code, 302) 

441 self.assertIn( 

442 "api_error_message=Name+'openbau'+is+already+taken.", 

443 res.headers["Location"], 

444 ) 

445 

446 # And the message is rendered on the page as-is. 

447 follow_res = self.client.get(res.headers["Location"]) 

448 self.assertIn( 

449 b"Name 'openbau' is already taken.", follow_res.data 

450 ) 

451 

452 def test_register_name_dispute(self): 

453 res = self.client.get("/register-name-dispute?entity-name=test-name") 

454 self.assertEqual(res.status_code, 200) 

455 self.assertIn(b"Claim the name", res.data) 

456 

457 def test_register_name_dispute_redirect(self): 

458 res = self.client.get("/register-name-dispute") 

459 self.assertEqual(res.status_code, 302) 

460 

461 def test_register_name_dispute_thank_you_redirect(self): 

462 res = self.client.get("/register-name-dispute/thank-you") 

463 self.assertEqual(res.status_code, 302) 

464 

465 def test_register_name_dispute_thank_you(self): 

466 res = self.client.get( 

467 "/register-name-dispute/thank-you?entity-name=test-name" 

468 ) 

469 self.assertEqual(res.status_code, 200) 

470 self.assertIn( 

471 b"We will process the details provided with the name dispute.", 

472 res.data, 

473 ) 

474 

475 @patch("webapp.store_api.publisher_gateway.unregister_package_name") 

476 def test_delete_package(self, mock_unregister_package_name): 

477 mock_unregister_package_name.return_value.status_code = 200 

478 res = self.client.delete("/packages/test-package") 

479 self.assertEqual(res.status_code, 200) 

480 

481 @patch("webapp.store_api.publisher_gateway.unregister_package_name") 

482 def test_delete_package_failed(self, mock_unregister_package_name): 

483 mock_unregister_package_name.return_value.status_code = 500 

484 mock_unregister_package_name.return_value.json = lambda: { 

485 "error-list": [{"message": "test-error"}] 

486 } 

487 res = self.client.delete("/packages/test-package") 

488 self.assertEqual(res.status_code, 500) 

489 

490 @patch("webapp.store_api.publisher_gateway.create_track") 

491 def test_post_create_track(self, mock_create_track): 

492 mock_create_track.return_value.status_code = 201 

493 mock_create_track.return_value.json = lambda: {"track": "test-track"} 

494 res = self.client.post( 

495 "/test-charm/create-track", 

496 data={ 

497 "track-name": "test-track", 

498 "version-pattern": "v1", 

499 "auto-phasing-percentage": "50", 

500 }, 

501 ) 

502 self.assertEqual(res.status_code, 201) 

503 

504 @patch("webapp.store_api.publisher_gateway.create_track") 

505 def test_post_create_track_already_exists(self, mock_create_track): 

506 mock_create_track.return_value.status_code = 409 

507 mock_create_track.return_value.json = lambda: {"track": "test-track"} 

508 res = self.client.post( 

509 "/test-charm/create-track", 

510 data={ 

511 "track-name": "test-track", 

512 "version-pattern": "v1", 

513 "auto-phasing-percentage": "50", 

514 }, 

515 ) 

516 self.assertEqual(res.status_code, 409) 

517 

518 @patch("webapp.store_api.publisher_gateway.get_releases") 

519 @patch("webapp.publisher.logic.process_releases") 

520 @patch("webapp.publisher.logic.get_all_architectures") 

521 def test_get_releases( 

522 self, 

523 mock_get_all_architectures, 

524 mock_process_releases, 

525 mock_get_releases, 

526 ): 

527 mock_get_releases.return_value = { 

528 "channel-map": [], 

529 "package": {"channels": []}, 

530 "revisions": [], 

531 } 

532 mock_process_releases.return_value = [] 

533 mock_get_all_architectures.return_value = [] 

534 self.set_session() 

535 res = self.client.get("/api/packages/test-entity/releases") 

536 self.assertEqual(res.status_code, 200)