synapse 2.196.0__py311-none-any.whl → 2.198.0__py311-none-any.whl
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.
Potentially problematic release.
This version of synapse might be problematic. Click here for more details.
- synapse/axon.py +3 -0
- synapse/common.py +3 -0
- synapse/cortex.py +13 -11
- synapse/cryotank.py +2 -2
- synapse/lib/aha.py +3 -0
- synapse/lib/ast.py +277 -165
- synapse/lib/auth.py +39 -11
- synapse/lib/cell.py +24 -6
- synapse/lib/config.py +3 -3
- synapse/lib/hive.py +2 -1
- synapse/lib/hiveauth.py +10 -1
- synapse/lib/jsonstor.py +6 -5
- synapse/lib/layer.py +6 -5
- synapse/lib/multislabseqn.py +2 -2
- synapse/lib/node.py +10 -4
- synapse/lib/parser.py +46 -21
- synapse/lib/schemas.py +491 -1
- synapse/lib/snap.py +68 -26
- synapse/lib/storm.lark +13 -11
- synapse/lib/storm.py +13 -395
- synapse/lib/storm_format.py +3 -2
- synapse/lib/stormlib/graph.py +0 -61
- synapse/lib/stormlib/index.py +52 -0
- synapse/lib/stormtypes.py +16 -5
- synapse/lib/task.py +13 -2
- synapse/lib/urlhelp.py +1 -1
- synapse/lib/version.py +2 -2
- synapse/models/doc.py +62 -0
- synapse/models/infotech.py +18 -0
- synapse/models/orgs.py +6 -4
- synapse/models/risk.py +9 -0
- synapse/models/syn.py +18 -2
- synapse/tests/files/stormpkg/badendpoints.yaml +7 -0
- synapse/tests/files/stormpkg/testpkg.yaml +8 -0
- synapse/tests/test_cortex.py +108 -0
- synapse/tests/test_datamodel.py +7 -0
- synapse/tests/test_lib_aha.py +12 -42
- synapse/tests/test_lib_ast.py +57 -0
- synapse/tests/test_lib_auth.py +143 -2
- synapse/tests/test_lib_boss.py +15 -6
- synapse/tests/test_lib_cell.py +43 -0
- synapse/tests/test_lib_grammar.py +54 -2
- synapse/tests/test_lib_lmdbslab.py +24 -0
- synapse/tests/test_lib_storm.py +20 -0
- synapse/tests/test_lib_stormlib_index.py +39 -0
- synapse/tests/test_lib_stormlib_macro.py +3 -3
- synapse/tests/test_lib_stormtypes.py +14 -2
- synapse/tests/test_lib_task.py +31 -13
- synapse/tests/test_model_doc.py +38 -0
- synapse/tests/test_model_infotech.py +13 -0
- synapse/tests/test_model_orgs.py +7 -0
- synapse/tests/test_model_risk.py +6 -0
- synapse/tests/test_model_syn.py +58 -0
- synapse/tests/test_tools_genpkg.py +10 -0
- synapse/tools/genpkg.py +2 -2
- synapse/tools/hive/load.py +1 -0
- {synapse-2.196.0.dist-info → synapse-2.198.0.dist-info}/METADATA +1 -1
- {synapse-2.196.0.dist-info → synapse-2.198.0.dist-info}/RECORD +61 -58
- {synapse-2.196.0.dist-info → synapse-2.198.0.dist-info}/LICENSE +0 -0
- {synapse-2.196.0.dist-info → synapse-2.198.0.dist-info}/WHEEL +0 -0
- {synapse-2.196.0.dist-info → synapse-2.198.0.dist-info}/top_level.txt +0 -0
synapse/tests/test_lib_auth.py
CHANGED
|
@@ -1,9 +1,15 @@
|
|
|
1
1
|
import string
|
|
2
2
|
import pathlib
|
|
3
3
|
|
|
4
|
+
from unittest import mock
|
|
5
|
+
|
|
4
6
|
import synapse.exc as s_exc
|
|
7
|
+
import synapse.common as s_common
|
|
5
8
|
import synapse.telepath as s_telepath
|
|
6
|
-
|
|
9
|
+
|
|
10
|
+
import synapse.lib.auth as s_auth
|
|
11
|
+
import synapse.lib.cell as s_cell
|
|
12
|
+
import synapse.lib.lmdbslab as s_lmdbslab
|
|
7
13
|
|
|
8
14
|
import synapse.tests.utils as s_test
|
|
9
15
|
|
|
@@ -426,6 +432,126 @@ class AuthTest(s_test.SynTest):
|
|
|
426
432
|
with self.raises(s_exc.SchemaViolation):
|
|
427
433
|
await core.auth.allrole.setRules([(True, )])
|
|
428
434
|
|
|
435
|
+
async def test_auth_archived_locked_interaction(self):
|
|
436
|
+
|
|
437
|
+
# Check that we can't unlock an archived user
|
|
438
|
+
async with self.getTestCore() as core:
|
|
439
|
+
lowuser = await core.addUser('lowuser')
|
|
440
|
+
useriden = lowuser.get('iden')
|
|
441
|
+
|
|
442
|
+
await core.setUserArchived(useriden, True)
|
|
443
|
+
|
|
444
|
+
udef = await core.getUserDef(useriden)
|
|
445
|
+
self.true(udef.get('archived'))
|
|
446
|
+
self.true(udef.get('locked'))
|
|
447
|
+
|
|
448
|
+
# Unlocking an archived user is invalid
|
|
449
|
+
with self.raises(s_exc.BadArg) as exc:
|
|
450
|
+
await core.setUserLocked(useriden, False)
|
|
451
|
+
self.eq(exc.exception.get('mesg'), 'Cannot unlock archived user.')
|
|
452
|
+
self.eq(exc.exception.get('user'), useriden)
|
|
453
|
+
self.eq(exc.exception.get('username'), 'lowuser')
|
|
454
|
+
|
|
455
|
+
# Check our cell migration that locks archived users
|
|
456
|
+
async with self.getRegrCore('unlocked-archived-users') as core:
|
|
457
|
+
for ii in range(10):
|
|
458
|
+
user = await core.getUserDefByName(f'lowuser{ii:02d}')
|
|
459
|
+
self.nn(user)
|
|
460
|
+
self.true(user.get('archived'))
|
|
461
|
+
self.true(user.get('locked'))
|
|
462
|
+
|
|
463
|
+
# Check behavior of upgraded mirrors and non-upgraded leader
|
|
464
|
+
async with self.getTestAha() as aha:
|
|
465
|
+
|
|
466
|
+
with self.getTestDir() as dirn:
|
|
467
|
+
path00 = s_common.gendir(dirn, 'cell00')
|
|
468
|
+
path01 = s_common.gendir(dirn, 'cell01')
|
|
469
|
+
|
|
470
|
+
with mock.patch('synapse.lib.cell.NEXUS_VERSION', (2, 177)):
|
|
471
|
+
async with self.addSvcToAha(aha, '00.cell', s_cell.Cell, dirn=path00) as cell00:
|
|
472
|
+
lowuser = await cell00.addUser('lowuser')
|
|
473
|
+
useriden = lowuser.get('iden')
|
|
474
|
+
await cell00.setUserArchived(useriden, True)
|
|
475
|
+
|
|
476
|
+
with mock.patch('synapse.lib.cell.NEXUS_VERSION', (2, 198)):
|
|
477
|
+
async with self.addSvcToAha(aha, '01.cell', s_cell.Cell, dirn=path01, provinfo={'mirror': 'cell'}) as cell01:
|
|
478
|
+
await cell01.sync()
|
|
479
|
+
udef = await cell01.getUserDef(useriden)
|
|
480
|
+
self.true(udef.get('locked'))
|
|
481
|
+
self.true(udef.get('archived'))
|
|
482
|
+
|
|
483
|
+
# Simulate a call to cell00.setUserLocked(useriden, False) to bypass
|
|
484
|
+
# the check in cell00.auth.setUserInfo()
|
|
485
|
+
await cell00.auth._push('user:info', useriden, 'locked', False)
|
|
486
|
+
await cell01.sync()
|
|
487
|
+
|
|
488
|
+
udef00 = await cell00.getUserDef(useriden)
|
|
489
|
+
self.true(udef00.get('archived'))
|
|
490
|
+
self.false(udef00.get('locked'))
|
|
491
|
+
|
|
492
|
+
udef01 = await cell01.getUserDef(useriden)
|
|
493
|
+
self.true(udef01.get('archived'))
|
|
494
|
+
self.false(udef01.get('locked'))
|
|
495
|
+
|
|
496
|
+
# Check that we don't blowup/schism if an upgraded mirror is behind a leader with a pending
|
|
497
|
+
# user:info event that unlocks an archived user
|
|
498
|
+
async with self.getTestAha() as aha:
|
|
499
|
+
|
|
500
|
+
with self.getTestDir() as dirn:
|
|
501
|
+
path00 = s_common.gendir(dirn, 'cell00')
|
|
502
|
+
path01 = s_common.gendir(dirn, 'cell01')
|
|
503
|
+
|
|
504
|
+
async with self.addSvcToAha(aha, '00.cell', s_cell.Cell, dirn=path00) as cell00:
|
|
505
|
+
lowuser = await cell00.addUser('lowuser')
|
|
506
|
+
useriden = lowuser.get('iden')
|
|
507
|
+
await cell00.setUserLocked(useriden, True)
|
|
508
|
+
|
|
509
|
+
async with self.addSvcToAha(aha, '01.cell', s_cell.Cell, dirn=path01, provinfo={'mirror': 'cell'}) as cell01:
|
|
510
|
+
await cell01.sync()
|
|
511
|
+
udef = await cell01.getUserDef(useriden)
|
|
512
|
+
self.true(udef.get('locked'))
|
|
513
|
+
self.false(udef.get('archived'))
|
|
514
|
+
|
|
515
|
+
# Set user locked while cell01 is offline so it will get the edit when it comes
|
|
516
|
+
# back
|
|
517
|
+
await cell00.setUserLocked(useriden, False)
|
|
518
|
+
await cell00.sync()
|
|
519
|
+
|
|
520
|
+
# Edit the slabs on both cells directly to archive the user
|
|
521
|
+
lmdb00 = s_common.genpath(path00, 'slabs', 'cell.lmdb')
|
|
522
|
+
lmdb01 = s_common.genpath(path01, 'slabs', 'cell.lmdb')
|
|
523
|
+
|
|
524
|
+
slab00 = await s_lmdbslab.Slab.anit(lmdb00, map_size=s_cell.SLAB_MAP_SIZE)
|
|
525
|
+
slab01 = await s_lmdbslab.Slab.anit(lmdb01, map_size=s_cell.SLAB_MAP_SIZE)
|
|
526
|
+
|
|
527
|
+
# Simulate the cell migration which locks archived users
|
|
528
|
+
for slab in (slab00, slab01):
|
|
529
|
+
authkv = slab.getSafeKeyVal('auth')
|
|
530
|
+
userkv = authkv.getSubKeyVal('user:info:')
|
|
531
|
+
|
|
532
|
+
info = userkv.get(useriden)
|
|
533
|
+
info['archived'] = True
|
|
534
|
+
info['locked'] = True
|
|
535
|
+
userkv.set(useriden, info)
|
|
536
|
+
|
|
537
|
+
await slab00.fini()
|
|
538
|
+
await slab01.fini()
|
|
539
|
+
|
|
540
|
+
# Spin the cells back up and wait for the edit to sync to cell01
|
|
541
|
+
async with self.addSvcToAha(aha, '00.cell', s_cell.Cell, dirn=path00) as cell00:
|
|
542
|
+
udef = await cell00.getUserDef(useriden)
|
|
543
|
+
self.true(udef.get('archived'))
|
|
544
|
+
self.true(udef.get('locked'))
|
|
545
|
+
|
|
546
|
+
async with self.addSvcToAha(aha, '01.cell', s_cell.Cell, dirn=path01, provinfo={'mirror': 'cell'}) as cell01:
|
|
547
|
+
await cell01.sync()
|
|
548
|
+
udef = await cell01.getUserDef(useriden)
|
|
549
|
+
self.true(udef.get('archived'))
|
|
550
|
+
self.true(udef.get('locked'))
|
|
551
|
+
|
|
552
|
+
self.ge(cell00.nexsvers, (2, 198))
|
|
553
|
+
self.ge(cell01.nexsvers, (2, 198))
|
|
554
|
+
|
|
429
555
|
async def test_auth_password_policy(self):
|
|
430
556
|
policy = {
|
|
431
557
|
'complexity': {
|
|
@@ -446,6 +572,21 @@ class AuthTest(s_test.SynTest):
|
|
|
446
572
|
pass3 = 'ZXN-pyv7ber-kzq2kgh'
|
|
447
573
|
|
|
448
574
|
conf = {'auth:passwd:policy': policy}
|
|
575
|
+
async with self.getTestCore(conf=conf) as core:
|
|
576
|
+
|
|
577
|
+
user = await core.auth.addUser('blackout@vertex.link')
|
|
578
|
+
|
|
579
|
+
self.none(user.info.get('policy:previous'))
|
|
580
|
+
await user.setPasswd(pass1, nexs=False)
|
|
581
|
+
await user.setPasswd(pass2, nexs=False)
|
|
582
|
+
await user.setPasswd(pass3, nexs=False)
|
|
583
|
+
self.len(2, user.info.get('policy:previous'))
|
|
584
|
+
|
|
585
|
+
await user.tryPasswd('newp')
|
|
586
|
+
self.eq(1, user.info.get('policy:attempts'))
|
|
587
|
+
await user.setLocked(False, logged=False)
|
|
588
|
+
self.eq(0, user.info.get('policy:attempts'))
|
|
589
|
+
|
|
449
590
|
async with self.getTestCore(conf=conf) as core:
|
|
450
591
|
auth = core.auth
|
|
451
592
|
self.nn(auth.policy)
|
|
@@ -708,7 +849,7 @@ class AuthTest(s_test.SynTest):
|
|
|
708
849
|
await core.callStorm('auth.role.addrule ninjas --gate $gate another.rule',
|
|
709
850
|
opts={'vars': {'gate': fork}})
|
|
710
851
|
|
|
711
|
-
user = await core.auth.getUserByName('lowuser') # type:
|
|
852
|
+
user = await core.auth.getUserByName('lowuser') # type: s_auth.User
|
|
712
853
|
self.false(user.allowed(('hehe',)))
|
|
713
854
|
self.false(user.allowed(('hehe',), deepdeny=True))
|
|
714
855
|
self.true(user.allowed(('hehe', 'haha')))
|
synapse/tests/test_lib_boss.py
CHANGED
|
@@ -3,13 +3,21 @@ import asyncio
|
|
|
3
3
|
import synapse.exc as s_exc
|
|
4
4
|
import synapse.common as s_common
|
|
5
5
|
import synapse.lib.boss as s_boss
|
|
6
|
+
import synapse.lib.cell as s_cell
|
|
6
7
|
import synapse.tests.utils as s_test
|
|
7
8
|
|
|
9
|
+
class BossCell(s_cell.Cell):
|
|
10
|
+
async def initServiceRuntime(self):
|
|
11
|
+
self.cboss = await s_boss.Boss.anit()
|
|
12
|
+
self.onfini(self.cboss)
|
|
13
|
+
|
|
8
14
|
class BossTest(s_test.SynTest):
|
|
9
15
|
|
|
10
16
|
async def test_boss_base(self):
|
|
11
17
|
|
|
12
|
-
async with
|
|
18
|
+
async with self.getTestCell(BossCell) as bcell:
|
|
19
|
+
boss = bcell.cboss
|
|
20
|
+
root = await bcell.auth.getUserByName('root')
|
|
13
21
|
|
|
14
22
|
evnt = asyncio.Event()
|
|
15
23
|
|
|
@@ -20,18 +28,19 @@ class BossTest(s_test.SynTest):
|
|
|
20
28
|
|
|
21
29
|
self.len(0, boss.ps())
|
|
22
30
|
|
|
23
|
-
synt = await boss.promote('test',
|
|
31
|
+
synt = await boss.promote('test', root, info={'hehe': 'haha'})
|
|
24
32
|
|
|
25
33
|
self.len(1, boss.ps())
|
|
26
34
|
|
|
27
35
|
self.eq('test', synt.name)
|
|
28
36
|
self.eq('haha', synt.info.get('hehe'))
|
|
37
|
+
self.eq(root.iden, synt.user.iden)
|
|
29
38
|
|
|
30
|
-
synt0 = await boss.execute(testloop(), 'testloop',
|
|
39
|
+
synt0 = await boss.execute(testloop(), 'testloop', root, info={'foo': 'bar'})
|
|
31
40
|
iden = synt0.iden
|
|
32
41
|
|
|
33
42
|
with self.raises(s_exc.BadArg):
|
|
34
|
-
_ = await boss.execute(asyncio.sleep(1), 'testsleep',
|
|
43
|
+
_ = await boss.execute(asyncio.sleep(1), 'testsleep', root, iden=iden)
|
|
35
44
|
|
|
36
45
|
await evnt.wait()
|
|
37
46
|
|
|
@@ -47,8 +56,8 @@ class BossTest(s_test.SynTest):
|
|
|
47
56
|
iden = s_common.guid()
|
|
48
57
|
|
|
49
58
|
async def double_promote():
|
|
50
|
-
await boss.promote(f'double',
|
|
51
|
-
await boss.promote(f'double',
|
|
59
|
+
await boss.promote(f'double', root, taskiden=iden)
|
|
60
|
+
await boss.promote(f'double', root, taskiden=iden + iden)
|
|
52
61
|
|
|
53
62
|
coro = boss.schedCoro(double_promote())
|
|
54
63
|
self.true(await stream.wait(timeout=6))
|
synapse/tests/test_lib_cell.py
CHANGED
|
@@ -3417,3 +3417,46 @@ class CellTest(s_t_utils.SynTest):
|
|
|
3417
3417
|
pass
|
|
3418
3418
|
async for item in cell.callPeerGenr(todo):
|
|
3419
3419
|
pass
|
|
3420
|
+
|
|
3421
|
+
async def test_cell_task_apis(self):
|
|
3422
|
+
async with self.getTestAha() as aha:
|
|
3423
|
+
|
|
3424
|
+
# test some of the gather API implementations...
|
|
3425
|
+
purl00 = await aha.addAhaSvcProv('00.cell')
|
|
3426
|
+
purl01 = await aha.addAhaSvcProv('01.cell', provinfo={'mirror': 'cell'})
|
|
3427
|
+
|
|
3428
|
+
cell00 = await aha.enter_context(self.getTestCell(conf={'aha:provision': purl00}))
|
|
3429
|
+
cell01 = await aha.enter_context(self.getTestCell(conf={'aha:provision': purl01}))
|
|
3430
|
+
|
|
3431
|
+
await cell01.sync()
|
|
3432
|
+
|
|
3433
|
+
async def sleep99(cell):
|
|
3434
|
+
await cell.boss.promote('sleep99', cell.auth.rootuser)
|
|
3435
|
+
await cell00.fire('sleep99')
|
|
3436
|
+
await asyncio.sleep(99)
|
|
3437
|
+
|
|
3438
|
+
async with cell00.waiter(2, 'sleep99', timeout=6):
|
|
3439
|
+
task00 = cell00.schedCoro(sleep99(cell00))
|
|
3440
|
+
task01 = cell01.schedCoro(sleep99(cell01))
|
|
3441
|
+
|
|
3442
|
+
tasks = [task async for task in cell00.getTasks(timeout=6)]
|
|
3443
|
+
|
|
3444
|
+
self.len(2, tasks)
|
|
3445
|
+
self.eq(tasks[0]['service'], '00.cell.synapse')
|
|
3446
|
+
self.eq(tasks[1]['service'], '01.cell.synapse')
|
|
3447
|
+
self.eq(('sleep99', 'sleep99'), [task.get('name') for task in tasks])
|
|
3448
|
+
self.eq(('root', 'root'), [task.get('username') for task in tasks])
|
|
3449
|
+
|
|
3450
|
+
self.eq(tasks[0], await cell00.getTask(tasks[0].get('iden')))
|
|
3451
|
+
self.eq(tasks[1], await cell00.getTask(tasks[1].get('iden')))
|
|
3452
|
+
self.none(await cell00.getTask(tasks[1].get('iden'), peers=False))
|
|
3453
|
+
|
|
3454
|
+
self.true(await cell00.killTask(tasks[0].get('iden')))
|
|
3455
|
+
|
|
3456
|
+
task01 = tasks[1].get('iden')
|
|
3457
|
+
self.false(await cell00.killTask(task01, peers=False))
|
|
3458
|
+
|
|
3459
|
+
self.true(await cell00.killTask(task01))
|
|
3460
|
+
|
|
3461
|
+
self.none(await cell00.getTask(task01))
|
|
3462
|
+
self.false(await cell00.killTask(task01))
|
|
@@ -735,6 +735,17 @@ Queries = [
|
|
|
735
735
|
'[test:str=foo :$foo*$bar.baz=heval]',
|
|
736
736
|
'[test:str=foo :$foo*$bar.("baz")=heval]',
|
|
737
737
|
'[test:str=foo :$foo*$bar.baz()=heval]',
|
|
738
|
+
'[test:str=foo +(refs)> $n]',
|
|
739
|
+
'[test:str=foo +(refs)> $n.baz()]',
|
|
740
|
+
'[test:str=foo -(refs)> $n]',
|
|
741
|
+
'[test:str=foo <(refs)+ $n]',
|
|
742
|
+
'[test:str=foo <(refs)+ $n.baz()]',
|
|
743
|
+
'[test:str=foo <(refs)- $n]',
|
|
744
|
+
'[test:str=foo :bar++=([1, 2])]',
|
|
745
|
+
'[test:str=foo :$foo++=([1, 2])]',
|
|
746
|
+
'[test:str=foo :bar--=(foo, bar)]',
|
|
747
|
+
'[test:str=foo :bar?++=$baz]',
|
|
748
|
+
'[test:str=foo :bar?--={[it:dev:str=foo]}]',
|
|
738
749
|
]
|
|
739
750
|
|
|
740
751
|
# Generated with print_parse_list below
|
|
@@ -778,7 +789,7 @@ _ParseResults = [
|
|
|
778
789
|
'Query: [TryCatch: [Query: [LiftPropBy: [Const: inet:ipv4, Const: =, Const: asdf]], CatchBlock: [Const: TypeError, Const: err, Query: []]]]',
|
|
779
790
|
'Query: [TryCatch: [Query: [LiftPropBy: [Const: inet:ipv4, Const: =, Const: asdf]], CatchBlock: [Const: FooBar, Const: err, Query: []], CatchBlock: [Const: *, Const: err, Query: []]]]',
|
|
780
791
|
'Query: [LiftByArray: [Const: test:array, Const: =, Const: 1.2.3.4]]',
|
|
781
|
-
'Query: [CmdOper: [Const: macro.set, List: [Const: hehe, EmbedQuery:
|
|
792
|
+
'Query: [CmdOper: [Const: macro.set, List: [Const: hehe, EmbedQuery: inet:ipv4 ]]]',
|
|
782
793
|
'Query: [SetVarOper: [Const: q, EmbedQuery: #foo.bar]]',
|
|
783
794
|
'Query: [CmdOper: [Const: metrics.edits.byprop, List: [Const: inet:fqdn:domain, Const: --newv, VarDeref: [VarValue: [Const: lib], Const: null]]]]',
|
|
784
795
|
'Query: [CmdOper: [Const: tee, Const: ()]]',
|
|
@@ -1364,7 +1375,7 @@ _ParseResults = [
|
|
|
1364
1375
|
'Query: [SetVarOper: [Const: p, Const: names], LiftPropBy: [Const: ps:contact:name, Const: =, Const: foo], EditPropSet: [RelProp: [VarValue: [Const: p]], Const: ?-=, Const: bar]]',
|
|
1365
1376
|
'Query: [SetVarOper: [Const: pvar, Const: stuff], LiftProp: [Const: test:arrayprop], FiltOper: [Const: +, ArrayCond: [RelProp: [VarValue: [Const: pvar]], Const: =, Const: neato]]]',
|
|
1366
1377
|
'Query: [SetVarOper: [Const: pvar, Const: ints], LiftProp: [Const: test:arrayprop], FiltOper: [Const: +, ArrayCond: [RelProp: [VarValue: [Const: pvar]], Const: =, VarValue: [Const: othervar]]]]',
|
|
1367
|
-
'Query: [SetVarOper: [Const: foo, DollarExpr: [ExprDict: [Const: foo, EmbedQuery:
|
|
1378
|
+
'Query: [SetVarOper: [Const: foo, DollarExpr: [ExprDict: [Const: foo, EmbedQuery: inet:fqdn ]]]]',
|
|
1368
1379
|
'Query: [EditNodeAdd: [FormName: [Const: test:str], Const: =, Const: foo], EditCondPropSet: [RelProp: [Const: hehe], CondSetOper: [Const: unset], Const: heval]]',
|
|
1369
1380
|
'Query: [EditNodeAdd: [FormName: [Const: test:str], Const: =, Const: foo], EditCondPropSet: [RelProp: [Const: hehe], CondSetOper: [VarValue: [Const: foo]], Const: heval]]',
|
|
1370
1381
|
'Query: [EditNodeAdd: [FormName: [Const: test:str], Const: =, Const: foo], EditCondPropSet: [RelProp: [VarValue: [Const: foo]], CondSetOper: [Const: unset], Const: heval]]',
|
|
@@ -1372,6 +1383,17 @@ _ParseResults = [
|
|
|
1372
1383
|
'Query: [EditNodeAdd: [FormName: [Const: test:str], Const: =, Const: foo], EditCondPropSet: [RelProp: [VarValue: [Const: foo]], CondSetOper: [VarDeref: [VarValue: [Const: bar], Const: baz]], Const: heval]]',
|
|
1373
1384
|
'Query: [EditNodeAdd: [FormName: [Const: test:str], Const: =, Const: foo], EditCondPropSet: [RelProp: [VarValue: [Const: foo]], CondSetOper: [VarDeref: [VarValue: [Const: bar], DollarExpr: [Const: baz]]], Const: heval]]',
|
|
1374
1385
|
'Query: [EditNodeAdd: [FormName: [Const: test:str], Const: =, Const: foo], EditCondPropSet: [RelProp: [VarValue: [Const: foo]], CondSetOper: [FuncCall: [VarDeref: [VarValue: [Const: bar], Const: baz], CallArgs: [], CallKwargs: []]], Const: heval]]',
|
|
1386
|
+
'Query: [EditNodeAdd: [FormName: [Const: test:str], Const: =, Const: foo], EditEdgeAdd: [Const: refs, VarValue: [Const: n]]]',
|
|
1387
|
+
'Query: [EditNodeAdd: [FormName: [Const: test:str], Const: =, Const: foo], EditEdgeAdd: [Const: refs, FuncCall: [VarDeref: [VarValue: [Const: n], Const: baz], CallArgs: [], CallKwargs: []]]]',
|
|
1388
|
+
'Query: [EditNodeAdd: [FormName: [Const: test:str], Const: =, Const: foo], EditEdgeDel: [Const: refs, VarValue: [Const: n]]]',
|
|
1389
|
+
'Query: [EditNodeAdd: [FormName: [Const: test:str], Const: =, Const: foo], EditEdgeAdd: [Const: refs, VarValue: [Const: n]]]',
|
|
1390
|
+
'Query: [EditNodeAdd: [FormName: [Const: test:str], Const: =, Const: foo], EditEdgeAdd: [Const: refs, FuncCall: [VarDeref: [VarValue: [Const: n], Const: baz], CallArgs: [], CallKwargs: []]]]',
|
|
1391
|
+
'Query: [EditNodeAdd: [FormName: [Const: test:str], Const: =, Const: foo], EditEdgeDel: [Const: refs, VarValue: [Const: n]]]',
|
|
1392
|
+
'Query: [EditNodeAdd: [FormName: [Const: test:str], Const: =, Const: foo], EditPropSetMulti: [RelProp: [Const: bar], Const: ++=, DollarExpr: [ExprList: [Const: 1, Const: 2]]]]',
|
|
1393
|
+
'Query: [EditNodeAdd: [FormName: [Const: test:str], Const: =, Const: foo], EditPropSetMulti: [RelProp: [VarValue: [Const: foo]], Const: ++=, DollarExpr: [ExprList: [Const: 1, Const: 2]]]]',
|
|
1394
|
+
'Query: [EditNodeAdd: [FormName: [Const: test:str], Const: =, Const: foo], EditPropSetMulti: [RelProp: [Const: bar], Const: --=, List: [Const: foo, Const: bar]]]',
|
|
1395
|
+
'Query: [EditNodeAdd: [FormName: [Const: test:str], Const: =, Const: foo], EditPropSetMulti: [RelProp: [Const: bar], Const: ?++=, VarValue: [Const: baz]]]',
|
|
1396
|
+
'Query: [EditNodeAdd: [FormName: [Const: test:str], Const: =, Const: foo], EditPropSetMulti: [RelProp: [Const: bar], Const: ?--=, SubQuery: [Query: [EditNodeAdd: [FormName: [Const: it:dev:str], Const: =, Const: foo]]]]]',
|
|
1375
1397
|
]
|
|
1376
1398
|
|
|
1377
1399
|
class GrammarTest(s_t_utils.SynTest):
|
|
@@ -1663,6 +1685,27 @@ class GrammarTest(s_t_utils.SynTest):
|
|
|
1663
1685
|
errinfo = cm.exception.errinfo
|
|
1664
1686
|
self.eq(1, errinfo.get('mesg').count('#'))
|
|
1665
1687
|
|
|
1688
|
+
query = '$q = ${ /* secret comment */ $lib.print([hello) } $lib.macro.set(hehe, $q)'
|
|
1689
|
+
parser = s_parser.Parser(query)
|
|
1690
|
+
with self.raises(s_exc.BadSyntax) as cm:
|
|
1691
|
+
_ = parser.query()
|
|
1692
|
+
info = cm.exception.errinfo.get('highlight')
|
|
1693
|
+
self.eq((40, 41), info['offsets'])
|
|
1694
|
+
self.eq((1, 1), info['lines'])
|
|
1695
|
+
self.eq((41, 42), info['columns'])
|
|
1696
|
+
|
|
1697
|
+
query = """function test(hello) {
|
|
1698
|
+
+'''asdf
|
|
1699
|
+
asdfasdf'''
|
|
1700
|
+
}"""
|
|
1701
|
+
parser = s_parser.Parser(query)
|
|
1702
|
+
with self.raises(s_exc.BadSyntax) as cm:
|
|
1703
|
+
_ = parser.query()
|
|
1704
|
+
info = cm.exception.errinfo.get('highlight')
|
|
1705
|
+
self.eq((44, 83), info['offsets'])
|
|
1706
|
+
self.eq((2, 3), info['lines'])
|
|
1707
|
+
self.eq((22, 31), info['columns'])
|
|
1708
|
+
|
|
1666
1709
|
async def test_quotes(self):
|
|
1667
1710
|
|
|
1668
1711
|
# Test vectors
|
|
@@ -1766,6 +1809,15 @@ class GrammarTest(s_t_utils.SynTest):
|
|
|
1766
1809
|
self.false(s_grammar.isPropName('.hehe'))
|
|
1767
1810
|
self.false(s_grammar.isPropName('testcmd'))
|
|
1768
1811
|
|
|
1812
|
+
async def test_embed_offsets(self):
|
|
1813
|
+
|
|
1814
|
+
embq = ' /* secret comment */ $lib.print(hello) /* haha */ $lib.print(goodbye) /*foo */ '
|
|
1815
|
+
query = f'$q = ${{{embq}}} $lib.print($q)'
|
|
1816
|
+
parser = s_parser.Parser(query)
|
|
1817
|
+
q = parser.query()
|
|
1818
|
+
embed = q.kids[0].kids[1]
|
|
1819
|
+
self.eq(embq, embed.getAstText())
|
|
1820
|
+
|
|
1769
1821
|
def gen_parse_list():
|
|
1770
1822
|
'''
|
|
1771
1823
|
Prints out the Asts for a list of queries in order to compare ASTs between versions of parsers
|
|
@@ -371,6 +371,30 @@ class LmdbSlabTest(s_t_utils.SynTest):
|
|
|
371
371
|
'vm.dirty_ratio',
|
|
372
372
|
], msgs[0].get('sysctls', {}).keys())
|
|
373
373
|
|
|
374
|
+
async def test_lmdbslab_commit_over_max_xactops(self):
|
|
375
|
+
|
|
376
|
+
# Make sure that we don't confuse the periodic commit with the max replay log commit
|
|
377
|
+
with (self.getTestDir() as dirn,
|
|
378
|
+
patch('synapse.lib.lmdbslab.Slab.WARN_COMMIT_TIME_MS', 1),
|
|
379
|
+
patch('synapse.lib.lmdbslab.Slab.COMMIT_PERIOD', 100)
|
|
380
|
+
):
|
|
381
|
+
path = os.path.join(dirn, 'test.lmdb')
|
|
382
|
+
|
|
383
|
+
async with await s_lmdbslab.Slab.anit(path, max_replay_log=100, map_size=100_000_000) as slab:
|
|
384
|
+
foo = slab.initdb('foo', dupsort=True)
|
|
385
|
+
|
|
386
|
+
byts = b'\x00' * 256
|
|
387
|
+
for i in range(1000):
|
|
388
|
+
slab.put(b'\xff\xff\xff\xff' + s_common.guid(i).encode('utf8'), byts, db=foo)
|
|
389
|
+
await asyncio.sleep(0)
|
|
390
|
+
|
|
391
|
+
# Let the slab close and then grab its stats
|
|
392
|
+
stats = slab.statinfo()
|
|
393
|
+
commitstats = stats.get('commitstats', ())
|
|
394
|
+
self.gt(len(commitstats), 0)
|
|
395
|
+
commitstats = [x[1] for x in commitstats if x[1] != 0]
|
|
396
|
+
self.eq(commitstats, (100, 100, 100, 100, 100, 100, 100, 100, 100, 100))
|
|
397
|
+
|
|
374
398
|
async def test_lmdbslab_max_replay(self):
|
|
375
399
|
with self.getTestDir() as dirn:
|
|
376
400
|
path = os.path.join(dirn, 'test.lmdb')
|
synapse/tests/test_lib_storm.py
CHANGED
|
@@ -143,6 +143,26 @@ class StormTest(s_t_utils.SynTest):
|
|
|
143
143
|
self.eq(props.get('name'), 'org name 77')
|
|
144
144
|
self.eq(props.get('desc'), 'an org desc')
|
|
145
145
|
|
|
146
|
+
nodes = await core.nodes('ou:org=({"name": "the vertex project", "type": "lulz"})')
|
|
147
|
+
self.len(1, nodes)
|
|
148
|
+
orgn = nodes[0].ndef
|
|
149
|
+
self.eq(orgn, nodes11[0].ndef)
|
|
150
|
+
|
|
151
|
+
q = '[ ps:contact=* :org={ ou:org=({"name": "the vertex project", "type": "lulz"}) } ]'
|
|
152
|
+
nodes = await core.nodes(q)
|
|
153
|
+
self.len(1, nodes)
|
|
154
|
+
cont = nodes[0]
|
|
155
|
+
self.eq(cont.get('org'), orgn[1])
|
|
156
|
+
|
|
157
|
+
nodes = await core.nodes('ps:contact:org=({"name": "the vertex project", "type": "lulz"})')
|
|
158
|
+
self.len(1, nodes)
|
|
159
|
+
self.eq(nodes[0].ndef, cont.ndef)
|
|
160
|
+
|
|
161
|
+
self.len(0, await core.nodes('ps:contact:org=({"name": "vertex", "type": "newp"})'))
|
|
162
|
+
|
|
163
|
+
with self.raises(s_exc.BadTypeValu):
|
|
164
|
+
await core.nodes('inet:flow:from=({"name": "vertex", "type": "newp"})')
|
|
165
|
+
|
|
146
166
|
async def test_lib_storm_jsonexpr(self):
|
|
147
167
|
async with self.getTestCore() as core:
|
|
148
168
|
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import synapse.tests.utils as s_test
|
|
2
|
+
|
|
3
|
+
count_prop_00 = '''
|
|
4
|
+
Count | Layer Iden | Layer Name
|
|
5
|
+
==============|==================================|============
|
|
6
|
+
16 | 5cc56afbb22fad9b96c51110812af8f7 |
|
|
7
|
+
16 | 2371390b1fd0162ba6820f85a863e7b2 | default
|
|
8
|
+
Total: 32
|
|
9
|
+
'''
|
|
10
|
+
|
|
11
|
+
count_prop_01 = '''
|
|
12
|
+
Count | Layer Iden | Layer Name
|
|
13
|
+
==============|==================================|============
|
|
14
|
+
16 | 9782c920718d3059b8806fddaf917bd8 |
|
|
15
|
+
0 | 511122a9b2d576c5be2cdfcaef541bb9 | default
|
|
16
|
+
Total: 16
|
|
17
|
+
'''
|
|
18
|
+
|
|
19
|
+
class StormIndexTest(s_test.SynTest):
|
|
20
|
+
|
|
21
|
+
async def test_lib_stormlib_index(self):
|
|
22
|
+
|
|
23
|
+
async with self.getTestCore() as core:
|
|
24
|
+
viewiden = await core.callStorm('return($lib.view.get().fork().iden)')
|
|
25
|
+
viewopts = {'view': viewiden}
|
|
26
|
+
await core.nodes('[ inet:ipv4=1.2.3.0/28 :asn=19 ]')
|
|
27
|
+
await core.nodes('[ inet:ipv4=1.2.4.0/28 :asn=42 ]', opts=viewopts)
|
|
28
|
+
|
|
29
|
+
msgs = await core.stormlist('index.count.prop inet:ipv4', opts=viewopts)
|
|
30
|
+
self.stormIsInPrint(count_prop_00, msgs, deguid=True, whitespace=False)
|
|
31
|
+
|
|
32
|
+
msgs = await core.stormlist('index.count.prop inet:ipv4:asn', opts=viewopts)
|
|
33
|
+
self.stormIsInPrint(count_prop_00, msgs, deguid=True, whitespace=False)
|
|
34
|
+
|
|
35
|
+
msgs = await core.stormlist('index.count.prop inet:ipv4:asn --value 42', opts=viewopts)
|
|
36
|
+
self.stormIsInPrint(count_prop_01, msgs, deguid=True, whitespace=False)
|
|
37
|
+
|
|
38
|
+
msgs = await core.stormlist('index.count.prop inet:ipv4:newp', opts=viewopts)
|
|
39
|
+
self.stormIsInErr('No property named inet:ipv4:newp', msgs)
|
|
@@ -89,7 +89,7 @@ class MacroTest(s_test.SynTest):
|
|
|
89
89
|
name = 'v' * 491
|
|
90
90
|
q = '$lib.macro.set($name, ${ help }) return ( $lib.macro.get($name) )'
|
|
91
91
|
mdef = await core.callStorm(q, opts={'vars': {'name': name}})
|
|
92
|
-
self.eq(mdef.get('storm'), 'help')
|
|
92
|
+
self.eq(mdef.get('storm'), ' help ')
|
|
93
93
|
|
|
94
94
|
badname = 'v' * 492
|
|
95
95
|
with self.raises(s_exc.BadArg):
|
|
@@ -381,7 +381,7 @@ class MacroTest(s_test.SynTest):
|
|
|
381
381
|
self.eq('storm:macro:add', addmesg['data']['event'])
|
|
382
382
|
macro = addmesg['data']['info']['macro']
|
|
383
383
|
self.eq(macro['name'], 'foobar')
|
|
384
|
-
self.eq(macro['storm'], 'file:bytes | [+#neato]')
|
|
384
|
+
self.eq(macro['storm'], ' file:bytes | [+#neato] ')
|
|
385
385
|
self.ne(visi.iden, macro['user'])
|
|
386
386
|
self.ne(visi.iden, macro['creator'])
|
|
387
387
|
self.nn(macro['iden'])
|
|
@@ -390,7 +390,7 @@ class MacroTest(s_test.SynTest):
|
|
|
390
390
|
self.eq('storm:macro:mod', setmesg['data']['event'])
|
|
391
391
|
event = setmesg['data']['info']
|
|
392
392
|
self.nn(event['macro'])
|
|
393
|
-
self.eq(event['info']['storm'], 'inet:ipv4 | [+#burrito]')
|
|
393
|
+
self.eq(event['info']['storm'], ' inet:ipv4 | [+#burrito] ')
|
|
394
394
|
self.nn(event['info']['updated'])
|
|
395
395
|
|
|
396
396
|
modmesg = await sock.receive_json()
|
|
@@ -749,7 +749,7 @@ class StormTypesTest(s_test.SynTest):
|
|
|
749
749
|
self.stormIsInPrint("['1', 2, '3']", mesgs)
|
|
750
750
|
|
|
751
751
|
mesgs = await core.stormlist('$lib.print(${ $foo=bar })')
|
|
752
|
-
self.stormIsInPrint('storm:query: "$foo=bar"', mesgs)
|
|
752
|
+
self.stormIsInPrint('storm:query: " $foo=bar "', mesgs)
|
|
753
753
|
|
|
754
754
|
mesgs = await core.stormlist('$lib.print($lib.set(1,2,3))')
|
|
755
755
|
self.stormIsInPrint("'1'", mesgs)
|
|
@@ -1168,7 +1168,7 @@ class StormTypesTest(s_test.SynTest):
|
|
|
1168
1168
|
fires = [m for m in msgs if m[0] == 'storm:fire']
|
|
1169
1169
|
self.len(1, fires)
|
|
1170
1170
|
self.eq(fires[0][1].get('data').get('q'),
|
|
1171
|
-
"$lib.print('fire in the hole')")
|
|
1171
|
+
" $lib.print('fire in the hole') ")
|
|
1172
1172
|
|
|
1173
1173
|
q = '''
|
|
1174
1174
|
$q=${ [test:int=1 test:int=2] }
|
|
@@ -1409,6 +1409,18 @@ class StormTypesTest(s_test.SynTest):
|
|
|
1409
1409
|
with self.raises(s_exc.BadJsonText):
|
|
1410
1410
|
await core.callStorm('return(("foo").json())')
|
|
1411
1411
|
|
|
1412
|
+
with self.raises(s_exc.BadArg):
|
|
1413
|
+
await core.nodes("$lib.regex.search('?id=([0-9]+)', 'foo')")
|
|
1414
|
+
|
|
1415
|
+
with self.raises(s_exc.BadArg):
|
|
1416
|
+
await core.nodes("$lib.regex.search('(?au)\\w', 'foo')")
|
|
1417
|
+
|
|
1418
|
+
with self.raises(s_exc.BadArg):
|
|
1419
|
+
await core.nodes("$lib.regex.replace('(?P<a>x)', '\\g<ab>', 'xx')")
|
|
1420
|
+
|
|
1421
|
+
with self.raises(s_exc.BadArg):
|
|
1422
|
+
await core.nodes("$lib.regex.replace('(?P<a>x)', '(?au)\\w', 'xx')")
|
|
1423
|
+
|
|
1412
1424
|
async def test_storm_lib_bytes_gzip(self):
|
|
1413
1425
|
async with self.getTestCore() as core:
|
|
1414
1426
|
hstr = 'ohhai'
|
synapse/tests/test_lib_task.py
CHANGED
|
@@ -2,12 +2,14 @@ import asyncio
|
|
|
2
2
|
|
|
3
3
|
import synapse.exc as s_exc
|
|
4
4
|
import synapse.lib.boss as s_boss
|
|
5
|
+
import synapse.lib.cell as s_cell
|
|
5
6
|
import synapse.lib.task as s_task
|
|
6
7
|
import synapse.tests.utils as s_test
|
|
7
8
|
|
|
8
|
-
class
|
|
9
|
-
def
|
|
10
|
-
self.
|
|
9
|
+
class BossCell(s_cell.Cell):
|
|
10
|
+
async def initServiceRuntime(self):
|
|
11
|
+
self.cboss = await s_boss.Boss.anit()
|
|
12
|
+
self.onfini(self.cboss)
|
|
11
13
|
|
|
12
14
|
class TaskTest(s_test.SynTest):
|
|
13
15
|
|
|
@@ -19,15 +21,27 @@ class TaskTest(s_test.SynTest):
|
|
|
19
21
|
|
|
20
22
|
async def test_task_module(self):
|
|
21
23
|
|
|
22
|
-
async with
|
|
24
|
+
async with self.getTestCell(BossCell) as bcell:
|
|
25
|
+
boss = bcell.cboss
|
|
26
|
+
root = await bcell.auth.getUserByName('root')
|
|
23
27
|
|
|
24
|
-
|
|
28
|
+
synt = await boss.promote('test', root, info={'hehe': 'haha'})
|
|
25
29
|
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
self.eq(s_task.user(), user)
|
|
30
|
+
self.eq(s_task.user(), root)
|
|
29
31
|
self.eq(s_task.current(), synt)
|
|
30
|
-
self.eq(s_task.username(), '
|
|
32
|
+
self.eq(s_task.username(), 'root')
|
|
33
|
+
|
|
34
|
+
ret = synt.pack()
|
|
35
|
+
self.nn(ret.pop('iden'))
|
|
36
|
+
self.nn(ret.pop('tick'))
|
|
37
|
+
self.eq(ret, {'name': 'test', 'info': {'hehe': 'haha'},
|
|
38
|
+
'user': 'root', 'kids': {}})
|
|
39
|
+
|
|
40
|
+
ret = synt.packv2()
|
|
41
|
+
self.nn(ret.pop('iden'))
|
|
42
|
+
self.nn(ret.pop('tick'))
|
|
43
|
+
self.eq(ret, {'name': 'test', 'info': {'hehe': 'haha'},
|
|
44
|
+
'user': root.iden, 'username': 'root', 'kids': {}})
|
|
31
45
|
|
|
32
46
|
async def test_taskvars(self):
|
|
33
47
|
s_task.varset('test', 'foo')
|
|
@@ -51,7 +65,11 @@ class TaskTest(s_test.SynTest):
|
|
|
51
65
|
self.eq(s_task.varget('test'), 'foo')
|
|
52
66
|
|
|
53
67
|
async def test_task_iden(self):
|
|
54
|
-
with self.
|
|
55
|
-
await
|
|
56
|
-
|
|
57
|
-
|
|
68
|
+
async with self.getTestCell(BossCell) as bcell:
|
|
69
|
+
root = await bcell.auth.getUserByName('root')
|
|
70
|
+
boss = bcell.cboss
|
|
71
|
+
|
|
72
|
+
with self.raises(s_exc.BadArg):
|
|
73
|
+
await s_task.Task.anit(boss, asyncio.current_task(), None, root, iden=10)
|
|
74
|
+
with self.raises(s_exc.BadArg):
|
|
75
|
+
await s_task.Task.anit(boss, asyncio.current_task(), None, root, iden='woot')
|
synapse/tests/test_model_doc.py
CHANGED
|
@@ -49,3 +49,41 @@ class DocModelTest(s_tests.SynTest):
|
|
|
49
49
|
self.eq('V-99', nodes[0].get('id'))
|
|
50
50
|
self.nn(nodes[0].get('policy'))
|
|
51
51
|
self.len(1, await core.nodes('doc:standard -> doc:policy'))
|
|
52
|
+
|
|
53
|
+
nodes = await core.nodes('''
|
|
54
|
+
[ doc:requirement=*
|
|
55
|
+
:id=V-99
|
|
56
|
+
:priority=low
|
|
57
|
+
:optional=(false)
|
|
58
|
+
:summary="Some requirement text."
|
|
59
|
+
:standard={doc:standard}
|
|
60
|
+
]
|
|
61
|
+
''')
|
|
62
|
+
self.eq('V-99', nodes[0].get('id'))
|
|
63
|
+
self.eq('Some requirement text.', nodes[0].get('summary'))
|
|
64
|
+
self.eq(20, nodes[0].get('priority'))
|
|
65
|
+
self.false(nodes[0].get('optional'))
|
|
66
|
+
self.nn(nodes[0].get('standard'))
|
|
67
|
+
self.len(1, await core.nodes('doc:requirement -> doc:standard'))
|
|
68
|
+
|
|
69
|
+
nodes = await core.nodes('''
|
|
70
|
+
[ doc:resume=*
|
|
71
|
+
:id=V-99
|
|
72
|
+
:contact={[ ps:contact=* :name=visi ]}
|
|
73
|
+
:summary="Thought leader seeks..."
|
|
74
|
+
:workhist={[ ps:workhist=* ]}
|
|
75
|
+
:education={[ ps:education=* ]}
|
|
76
|
+
:achievements={[ ps:achievement=* ]}
|
|
77
|
+
]
|
|
78
|
+
''')
|
|
79
|
+
self.eq('V-99', nodes[0].get('id'))
|
|
80
|
+
self.eq('Thought leader seeks...', nodes[0].get('summary'))
|
|
81
|
+
self.nn(nodes[0].get('contact'))
|
|
82
|
+
self.len(1, nodes[0].get('workhist'))
|
|
83
|
+
self.len(1, nodes[0].get('education'))
|
|
84
|
+
self.len(1, nodes[0].get('achievements'))
|
|
85
|
+
|
|
86
|
+
self.len(1, await core.nodes('doc:resume :contact -> ps:contact'))
|
|
87
|
+
self.len(1, await core.nodes('doc:resume :workhist -> ps:workhist'))
|
|
88
|
+
self.len(1, await core.nodes('doc:resume :education -> ps:education'))
|
|
89
|
+
self.len(1, await core.nodes('doc:resume :achievements -> ps:achievement'))
|