Mercurial > p > roundup > code
comparison test/rest_common.py @ 6325:1a15089c2e49 issue2550923_computed_property
Merge trunk into branch
| author | John Rouillard <rouilj@ieee.org> |
|---|---|
| date | Sat, 06 Feb 2021 20:15:26 -0500 |
| parents | ec853cef2f09 |
| children | 6a69584d117e |
comparison
equal
deleted
inserted
replaced
| 6319:20e77c3ce6f6 | 6325:1a15089c2e49 |
|---|---|
| 66 | 66 |
| 67 backend = None | 67 backend = None |
| 68 url_pfx = 'http://tracker.example/cgi-bin/roundup.cgi/bugs/rest/data/' | 68 url_pfx = 'http://tracker.example/cgi-bin/roundup.cgi/bugs/rest/data/' |
| 69 | 69 |
| 70 def setUp(self): | 70 def setUp(self): |
| 71 from packaging import version | |
| 72 | |
| 71 self.dirname = '_test_rest' | 73 self.dirname = '_test_rest' |
| 72 # set up and open a tracker | 74 # set up and open a tracker |
| 73 # Set optimize=True as code under test (Client.main()::determine_user) | 75 # Set optimize=True as code under test (Client.main()::determine_user) |
| 74 # will close and re-open the database on user changes. This wipes | 76 # will close and re-open the database on user changes. This wipes |
| 75 # out additions to the schema needed for testing. | 77 # out additions to the schema needed for testing. |
| 160 'aud': self.db.config.TRACKER_WEB, | 162 'aud': self.db.config.TRACKER_WEB, |
| 161 'roles': [ 'User' ], | 163 'roles': [ 'User' ], |
| 162 'iat': now_ts, | 164 'iat': now_ts, |
| 163 'exp': plus1min_ts, | 165 'exp': plus1min_ts, |
| 164 } | 166 } |
| 167 | |
| 168 # in version 2.0.0 and newer jwt.encode returns string | |
| 169 # not bytestring. So we have to skip b2s conversion | |
| 165 | 170 |
| 171 if version.parse(jwt.__version__) >= version.parse('2.0.0'): | |
| 172 tostr = lambda x: x | |
| 173 else: | |
| 174 tostr = b2s | |
| 175 | |
| 166 self.jwt = {} | 176 self.jwt = {} |
| 167 self.claim = {} | 177 self.claim = {} |
| 168 # generate invalid claim with expired timestamp | 178 # generate invalid claim with expired timestamp |
| 169 self.claim['expired'] = copy(claim) | 179 self.claim['expired'] = copy(claim) |
| 170 self.claim['expired']['exp'] = expired_ts | 180 self.claim['expired']['exp'] = expired_ts |
| 171 self.jwt['expired'] = b2s(jwt.encode(self.claim['expired'], secret, | 181 self.jwt['expired'] = tostr(jwt.encode(self.claim['expired'], secret, |
| 172 algorithm='HS256')) | 182 algorithm='HS256')) |
| 173 | 183 |
| 174 # generate valid claim with user role | 184 # generate valid claim with user role |
| 175 self.claim['user'] = copy(claim) | 185 self.claim['user'] = copy(claim) |
| 176 self.claim['user']['exp'] = plus1min_ts | 186 self.claim['user']['exp'] = plus1min_ts |
| 177 self.jwt['user'] = b2s(jwt.encode(self.claim['user'], secret, | 187 self.jwt['user'] = tostr(jwt.encode(self.claim['user'], secret, |
| 178 algorithm='HS256')) | 188 algorithm='HS256')) |
| 179 # generate invalid claim bad issuer | 189 # generate invalid claim bad issuer |
| 180 self.claim['badiss'] = copy(claim) | 190 self.claim['badiss'] = copy(claim) |
| 181 self.claim['badiss']['iss'] = "http://someissuer/bugs" | 191 self.claim['badiss']['iss'] = "http://someissuer/bugs" |
| 182 self.jwt['badiss'] = b2s(jwt.encode(self.claim['badiss'], secret, | 192 self.jwt['badiss'] = tostr(jwt.encode(self.claim['badiss'], secret, |
| 183 algorithm='HS256')) | 193 algorithm='HS256')) |
| 184 # generate invalid claim bad aud(ience) | 194 # generate invalid claim bad aud(ience) |
| 185 self.claim['badaud'] = copy(claim) | 195 self.claim['badaud'] = copy(claim) |
| 186 self.claim['badaud']['aud'] = "http://someaudience/bugs" | 196 self.claim['badaud']['aud'] = "http://someaudience/bugs" |
| 187 self.jwt['badaud'] = b2s(jwt.encode(self.claim['badaud'], secret, | 197 self.jwt['badaud'] = tostr(jwt.encode(self.claim['badaud'], secret, |
| 188 algorithm='HS256')) | 198 algorithm='HS256')) |
| 189 # generate invalid claim bad sub(ject) | 199 # generate invalid claim bad sub(ject) |
| 190 self.claim['badsub'] = copy(claim) | 200 self.claim['badsub'] = copy(claim) |
| 191 self.claim['badsub']['sub'] = str("99") | 201 self.claim['badsub']['sub'] = str("99") |
| 192 self.jwt['badsub'] = b2s(jwt.encode(self.claim['badsub'], secret, | 202 self.jwt['badsub'] = tostr(jwt.encode(self.claim['badsub'], secret, |
| 193 algorithm='HS256')) | 203 algorithm='HS256')) |
| 194 # generate invalid claim bad roles | 204 # generate invalid claim bad roles |
| 195 self.claim['badroles'] = copy(claim) | 205 self.claim['badroles'] = copy(claim) |
| 196 self.claim['badroles']['roles'] = [ "badrole1", "badrole2" ] | 206 self.claim['badroles']['roles'] = [ "badrole1", "badrole2" ] |
| 197 self.jwt['badroles'] = b2s(jwt.encode(self.claim['badroles'], secret, | 207 self.jwt['badroles'] = tostr(jwt.encode(self.claim['badroles'], secret, |
| 198 algorithm='HS256')) | 208 algorithm='HS256')) |
| 199 # generate valid claim with limited user:email role | 209 # generate valid claim with limited user:email role |
| 200 self.claim['user:email'] = copy(claim) | 210 self.claim['user:email'] = copy(claim) |
| 201 self.claim['user:email']['roles'] = [ "user:email" ] | 211 self.claim['user:email']['roles'] = [ "user:email" ] |
| 202 self.jwt['user:email'] = b2s(jwt.encode(self.claim['user:email'], secret, | 212 self.jwt['user:email'] = tostr(jwt.encode(self.claim['user:email'], secret, |
| 203 algorithm='HS256')) | 213 algorithm='HS256')) |
| 204 | 214 |
| 205 # generate valid claim with limited user:emailnorest role | 215 # generate valid claim with limited user:emailnorest role |
| 206 self.claim['user:emailnorest'] = copy(claim) | 216 self.claim['user:emailnorest'] = copy(claim) |
| 207 self.claim['user:emailnorest']['roles'] = [ "user:emailnorest" ] | 217 self.claim['user:emailnorest']['roles'] = [ "user:emailnorest" ] |
| 208 self.jwt['user:emailnorest'] = b2s(jwt.encode(self.claim['user:emailnorest'], secret, | 218 self.jwt['user:emailnorest'] = tostr(jwt.encode(self.claim['user:emailnorest'], secret, |
| 209 algorithm='HS256')) | 219 algorithm='HS256')) |
| 210 | 220 |
| 211 self.db.tx_Source = 'web' | 221 self.db.tx_Source = 'web' |
| 212 | 222 |
| 213 self.db.issue.addprop(tx_Source=hyperdb.String()) | 223 self.db.issue.addprop(tx_Source=hyperdb.String()) |
| 1320 self.assertEqual(json_dict['data']['attributes']\ | 1330 self.assertEqual(json_dict['data']['attributes']\ |
| 1321 ['assignedto']['link'], | 1331 ['assignedto']['link'], |
| 1322 "http://tracker.example/cgi-bin/roundup.cgi/bugs/rest/data/user/2") | 1332 "http://tracker.example/cgi-bin/roundup.cgi/bugs/rest/data/user/2") |
| 1323 | 1333 |
| 1324 | 1334 |
| 1335 def testDispatchDelete(self): | |
| 1336 """ | |
| 1337 run Delete through rest dispatch(). | |
| 1338 """ | |
| 1339 | |
| 1340 # TEST #0 | |
| 1341 # Delete class raises unauthorized error | |
| 1342 # simulate: /rest/data/issue | |
| 1343 env = { "REQUEST_METHOD": "DELETE" | |
| 1344 } | |
| 1345 headers={"accept": "application/json; version=1", | |
| 1346 } | |
| 1347 self.headers=headers | |
| 1348 self.server.client.request.headers.get=self.get_header | |
| 1349 results = self.server.dispatch(env["REQUEST_METHOD"], | |
| 1350 "/rest/data/issue", | |
| 1351 self.empty_form) | |
| 1352 | |
| 1353 print(results) | |
| 1354 self.assertEqual(self.server.client.response_code, 403) | |
| 1355 json_dict = json.loads(b2s(results)) | |
| 1356 | |
| 1357 self.assertEqual(json_dict['error']['msg'], | |
| 1358 "Deletion of a whole class disabled") | |
| 1359 | |
| 1360 | |
| 1361 def testDispatchBadContent(self): | |
| 1362 """ | |
| 1363 runthrough rest dispatch() with bad content_type patterns. | |
| 1364 """ | |
| 1365 | |
| 1366 # simulate: /rest/data/issue | |
| 1367 body=b'{ "title": "Joe Doe has problems", \ | |
| 1368 "nosy": [ "1", "3" ], \ | |
| 1369 "assignedto": "2", \ | |
| 1370 "abool": true, \ | |
| 1371 "afloat": 2.3, \ | |
| 1372 "anint": 567890 \ | |
| 1373 }' | |
| 1374 env = { "CONTENT_TYPE": "application/jzot", | |
| 1375 "CONTENT_LENGTH": len(body), | |
| 1376 "REQUEST_METHOD": "POST" | |
| 1377 } | |
| 1378 | |
| 1379 headers={"accept": "application/json; version=1", | |
| 1380 "content-type": env['CONTENT_TYPE'], | |
| 1381 "content-length": env['CONTENT_LENGTH'], | |
| 1382 } | |
| 1383 | |
| 1384 self.headers=headers | |
| 1385 # we need to generate a FieldStorage the looks like | |
| 1386 # FieldStorage(None, None, 'string') rather than | |
| 1387 # FieldStorage(None, None, []) | |
| 1388 body_file=BytesIO(body) # FieldStorage needs a file | |
| 1389 form = client.BinaryFieldStorage(body_file, | |
| 1390 headers=headers, | |
| 1391 environ=env) | |
| 1392 self.server.client.request.headers.get=self.get_header | |
| 1393 results = self.server.dispatch(env["REQUEST_METHOD"], | |
| 1394 "/rest/data/issue", | |
| 1395 form) | |
| 1396 | |
| 1397 print(results) | |
| 1398 self.assertEqual(self.server.client.response_code, 415) | |
| 1399 json_dict = json.loads(b2s(results)) | |
| 1400 self.assertEqual(json_dict['error']['msg'], | |
| 1401 "Unable to process input of type application/jzot") | |
| 1402 | |
| 1403 # Test GET as well. I am not sure if this should pass or not. | |
| 1404 # Arguably GET doesn't use any form/json input but.... | |
| 1405 results = self.server.dispatch('GET', | |
| 1406 "/rest/data/issue", | |
| 1407 form) | |
| 1408 print(results) | |
| 1409 self.assertEqual(self.server.client.response_code, 415) | |
| 1410 | |
| 1411 | |
| 1412 | |
| 1413 def testDispatchBadAccept(self): | |
| 1414 # simulate: /rest/data/issue expect failure unknown accept settings | |
| 1415 body=b'{ "title": "Joe Doe has problems", \ | |
| 1416 "nosy": [ "1", "3" ], \ | |
| 1417 "assignedto": "2", \ | |
| 1418 "abool": true, \ | |
| 1419 "afloat": 2.3, \ | |
| 1420 "anint": 567890 \ | |
| 1421 }' | |
| 1422 env = { "CONTENT_TYPE": "application/json", | |
| 1423 "CONTENT_LENGTH": len(body), | |
| 1424 "REQUEST_METHOD": "POST" | |
| 1425 } | |
| 1426 | |
| 1427 headers={"accept": "application/zot; version=1; q=0.5", | |
| 1428 "content-type": env['CONTENT_TYPE'], | |
| 1429 "content-length": env['CONTENT_LENGTH'], | |
| 1430 } | |
| 1431 | |
| 1432 self.headers=headers | |
| 1433 # we need to generate a FieldStorage the looks like | |
| 1434 # FieldStorage(None, None, 'string') rather than | |
| 1435 # FieldStorage(None, None, []) | |
| 1436 body_file=BytesIO(body) # FieldStorage needs a file | |
| 1437 form = client.BinaryFieldStorage(body_file, | |
| 1438 headers=headers, | |
| 1439 environ=env) | |
| 1440 self.server.client.request.headers.get=self.get_header | |
| 1441 results = self.server.dispatch(env["REQUEST_METHOD"], | |
| 1442 "/rest/data/issue", | |
| 1443 form) | |
| 1444 | |
| 1445 print(results) | |
| 1446 self.assertEqual(self.server.client.response_code, 406) | |
| 1447 self.assertIn(b"Requested content type 'application/zot; version=1; q=0.5' is not available.\nAcceptable types: */*, application/json", results) | |
| 1448 | |
| 1449 # simulate: /rest/data/issue works, multiple acceptable output, one | |
| 1450 # is valid | |
| 1451 env = { "CONTENT_TYPE": "application/json", | |
| 1452 "CONTENT_LENGTH": len(body), | |
| 1453 "REQUEST_METHOD": "POST" | |
| 1454 } | |
| 1455 | |
| 1456 headers={"accept": "application/zot; version=1; q=0.75, " | |
| 1457 "application/json; version=1; q=0.5", | |
| 1458 "content-type": env['CONTENT_TYPE'], | |
| 1459 "content-length": env['CONTENT_LENGTH'], | |
| 1460 } | |
| 1461 | |
| 1462 self.headers=headers | |
| 1463 # we need to generate a FieldStorage the looks like | |
| 1464 # FieldStorage(None, None, 'string') rather than | |
| 1465 # FieldStorage(None, None, []) | |
| 1466 body_file=BytesIO(body) # FieldStorage needs a file | |
| 1467 form = client.BinaryFieldStorage(body_file, | |
| 1468 headers=headers, | |
| 1469 environ=env) | |
| 1470 self.server.client.request.headers.get=self.get_header | |
| 1471 results = self.server.dispatch(env["REQUEST_METHOD"], | |
| 1472 "/rest/data/issue", | |
| 1473 form) | |
| 1474 | |
| 1475 print(results) | |
| 1476 self.assertEqual(self.server.client.response_code, 201) | |
| 1477 json_dict = json.loads(b2s(results)) | |
| 1478 # ERROR this should be 1. What's happening is that the code | |
| 1479 # for handling 406 error code runs through everything and creates | |
| 1480 # the item. Then it throws a 406 after the work is done when it | |
| 1481 # realizes it can't respond as requested. So the 406 post above | |
| 1482 # creates issue 1 and this one creates issue 2. | |
| 1483 self.assertEqual(json_dict['data']['id'], "2") | |
| 1484 | |
| 1485 | |
| 1486 # test 3 accept is empty. This triggers */* so passes | |
| 1487 headers={"accept": "", | |
| 1488 "content-type": env['CONTENT_TYPE'], | |
| 1489 "content-length": env['CONTENT_LENGTH'], | |
| 1490 } | |
| 1491 | |
| 1492 self.headers=headers | |
| 1493 # we need to generate a FieldStorage the looks like | |
| 1494 # FieldStorage(None, None, 'string') rather than | |
| 1495 # FieldStorage(None, None, []) | |
| 1496 body_file=BytesIO(body) # FieldStorage needs a file | |
| 1497 form = client.BinaryFieldStorage(body_file, | |
| 1498 headers=headers, | |
| 1499 environ=env) | |
| 1500 self.server.client.request.headers.get=self.get_header | |
| 1501 results = self.server.dispatch(env["REQUEST_METHOD"], | |
| 1502 "/rest/data/issue", | |
| 1503 form) | |
| 1504 | |
| 1505 print(results) | |
| 1506 self.assertEqual(self.server.client.response_code, 201) | |
| 1507 json_dict = json.loads(b2s(results)) | |
| 1508 # This is one more than above. Will need to be fixed | |
| 1509 # When error above is fixed. | |
| 1510 self.assertEqual(json_dict['data']['id'], "3") | |
| 1511 | |
| 1512 # test 4 accept is random junk. | |
| 1513 headers={"accept": "Xyzzy I am not a mime, type;", | |
| 1514 "content-type": env['CONTENT_TYPE'], | |
| 1515 "content-length": env['CONTENT_LENGTH'], | |
| 1516 } | |
| 1517 | |
| 1518 self.headers=headers | |
| 1519 # we need to generate a FieldStorage the looks like | |
| 1520 # FieldStorage(None, None, 'string') rather than | |
| 1521 # FieldStorage(None, None, []) | |
| 1522 body_file=BytesIO(body) # FieldStorage needs a file | |
| 1523 form = client.BinaryFieldStorage(body_file, | |
| 1524 headers=headers, | |
| 1525 environ=env) | |
| 1526 self.server.client.request.headers.get=self.get_header | |
| 1527 results = self.server.dispatch(env["REQUEST_METHOD"], | |
| 1528 "/rest/data/issue", | |
| 1529 form) | |
| 1530 | |
| 1531 print(results) | |
| 1532 self.assertEqual(self.server.client.response_code, 406) | |
| 1533 json_dict = json.loads(b2s(results)) | |
| 1534 self.assertIn('Unable to parse Accept Header. Invalid media type: Xyzzy I am not a mime. Acceptable types: */* application/json', json_dict['error']['msg']) | |
| 1535 | |
| 1536 # test 5 accept mimetype is ok, param is not | |
| 1537 headers={"accept": "*/*; foo", | |
| 1538 "content-type": env['CONTENT_TYPE'], | |
| 1539 "content-length": env['CONTENT_LENGTH'], | |
| 1540 } | |
| 1541 | |
| 1542 self.headers=headers | |
| 1543 # we need to generate a FieldStorage the looks like | |
| 1544 # FieldStorage(None, None, 'string') rather than | |
| 1545 # FieldStorage(None, None, []) | |
| 1546 body_file=BytesIO(body) # FieldStorage needs a file | |
| 1547 form = client.BinaryFieldStorage(body_file, | |
| 1548 headers=headers, | |
| 1549 environ=env) | |
| 1550 self.server.client.request.headers.get=self.get_header | |
| 1551 results = self.server.dispatch(env["REQUEST_METHOD"], | |
| 1552 "/rest/data/issue", | |
| 1553 form) | |
| 1554 | |
| 1555 print(results) | |
| 1556 self.assertEqual(self.server.client.response_code, 406) | |
| 1557 json_dict = json.loads(b2s(results)) | |
| 1558 self.assertIn('Unable to parse Accept Header. Invalid param: foo. Acceptable types: */* application/json', json_dict['error']['msg']) | |
| 1559 | |
| 1325 def testStatsGen(self): | 1560 def testStatsGen(self): |
| 1326 # check stats being returned by put and get ops | 1561 # check stats being returned by put and get ops |
| 1327 # using dispatch which parses the @stats query param | 1562 # using dispatch which parses the @stats query param |
| 1328 | 1563 |
| 1329 # find correct py2/py3 list comparison ignoring order | 1564 # find correct py2/py3 list comparison ignoring order |
| 2032 self.assertEqual(self.server.client.response_code, 200) | 2267 self.assertEqual(self.server.client.response_code, 200) |
| 2033 self.assertEqual(self.server.client.additional_headers['Content-Type'], | 2268 self.assertEqual(self.server.client.additional_headers['Content-Type'], |
| 2034 "application/xml") | 2269 "application/xml") |
| 2035 ''' | 2270 ''' |
| 2036 | 2271 |
| 2272 # TEST #8 | |
| 2273 # invalid api version | |
| 2274 # application/json is selected with invalid version | |
| 2275 self.server.client.request.headers.get=self.get_header | |
| 2276 headers={"accept": "application/json; version=99" | |
| 2277 } | |
| 2278 self.headers=headers | |
| 2279 with self.assertRaises(UsageError) as ctx: | |
| 2280 results = self.server.dispatch('GET', | |
| 2281 "/rest/data/status/1", | |
| 2282 self.empty_form) | |
| 2283 print(results) | |
| 2284 self.assertEqual(self.server.client.response_code, 200) | |
| 2285 self.assertEqual(ctx.exception.args[0], | |
| 2286 "Unrecognized version: 99. See /rest without " | |
| 2287 "specifying version for supported versions.") | |
| 2288 | |
| 2037 def testMethodOverride(self): | 2289 def testMethodOverride(self): |
| 2038 # TEST #1 | 2290 # TEST #1 |
| 2039 # Use GET, PUT, PATCH to tunnel DELETE expect error | 2291 # Use GET, PUT, PATCH to tunnel DELETE expect error |
| 2040 | 2292 |
| 2041 body=b'{ "order": 5 }' | 2293 body=b'{ "order": 5 }' |
| 2516 {'link': 'http://tracker.example/cgi-bin/roundup.cgi/bugs/file1/'}) | 2768 {'link': 'http://tracker.example/cgi-bin/roundup.cgi/bugs/file1/'}) |
| 2517 | 2769 |
| 2518 # File content is only shown with verbose=3 | 2770 # File content is only shown with verbose=3 |
| 2519 form = cgi.FieldStorage() | 2771 form = cgi.FieldStorage() |
| 2520 form.list = [ | 2772 form.list = [ |
| 2521 cgi.MiniFieldStorage('@verbose', '3') | 2773 cgi.MiniFieldStorage('@verbose', '3'), |
| 2774 cgi.MiniFieldStorage('@protected', 'true') | |
| 2522 ] | 2775 ] |
| 2523 results = self.server.get_element('file', fileid, form) | 2776 results = self.server.get_element('file', fileid, form) |
| 2524 results = results['data'] | 2777 results = results['data'] |
| 2525 self.assertEqual(self.dummy_client.response_code, 200) | 2778 self.assertEqual(self.dummy_client.response_code, 200) |
| 2526 self.assertEqual(results['attributes']['content'], 'hello\r\nthere') | 2779 self.assertEqual(results['attributes']['content'], 'hello\r\nthere') |
| 2780 self.assertIn('creator', results['attributes']) # added by @protected | |
| 2781 self.assertEqual(results['attributes']['creator']['username'], "joe") | |
| 2527 | 2782 |
| 2528 def testAuthDeniedPut(self): | 2783 def testAuthDeniedPut(self): |
| 2529 """ | 2784 """ |
| 2530 Test unauthorized PUT request | 2785 Test unauthorized PUT request |
| 2531 """ | 2786 """ |
| 3020 self.assertEqual(self.dummy_client.response_code, 200) | 3275 self.assertEqual(self.dummy_client.response_code, 200) |
| 3021 | 3276 |
| 3022 # verify the result | 3277 # verify the result |
| 3023 self.assertTrue(not self.db.issue.is_retired(issue_id)) | 3278 self.assertTrue(not self.db.issue.is_retired(issue_id)) |
| 3024 | 3279 |
| 3280 def testPatchBadAction(self): | |
| 3281 """ | |
| 3282 Test Patch Action 'Unknown' | |
| 3283 """ | |
| 3284 # create a new issue with userid 1 and 2 in the nosy list | |
| 3285 issue_id = self.db.issue.create(title='foo') | |
| 3286 | |
| 3287 # execute action retire | |
| 3288 form = cgi.FieldStorage() | |
| 3289 etag = calculate_etag(self.db.issue.getnode(issue_id), | |
| 3290 self.db.config['WEB_SECRET_KEY']) | |
| 3291 form.list = [ | |
| 3292 cgi.MiniFieldStorage('@op', 'action'), | |
| 3293 cgi.MiniFieldStorage('@action_name', 'unknown'), | |
| 3294 cgi.MiniFieldStorage('@etag', etag) | |
| 3295 ] | |
| 3296 results = self.server.patch_element('issue', issue_id, form) | |
| 3297 self.assertEqual(self.dummy_client.response_code, 400) | |
| 3298 # verify the result, note order of allowed elements changes | |
| 3299 # for python2/3 so just check prefix. | |
| 3300 self.assertIn('action "unknown" is not supported, allowed: ', | |
| 3301 results['error']['msg'].args[0]) | |
| 3302 | |
| 3025 def testPatchRemove(self): | 3303 def testPatchRemove(self): |
| 3026 """ | 3304 """ |
| 3027 Test Patch Action 'Remove' only some element from a list | 3305 Test Patch Action 'Remove' only some element from a list |
| 3028 """ | 3306 """ |
| 3029 # create a new issue with userid 1, 2, 3 in the nosy list | 3307 # create a new issue with userid 1, 2, 3 in the nosy list |
