create-qpq-app 0.1.25 → 0.1.26

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/package.json +2 -2
  2. package/template/README.md +4 -0
  3. package/template/apps/qpqjs/account.qpq.ts +4 -0
  4. package/template/apps/qpqjs/bootstrap.qpq.ts +13 -2
  5. package/template/apps/qpqjs/packages/service-utils/src/defineQpqjsService.ts +4 -0
  6. package/template/apps/qpqjs/services/test/models/src/SmokeProbeRecord.ts +9 -0
  7. package/template/apps/qpqjs/services/test/models/src/SmokeRunSummary.ts +8 -1
  8. package/template/apps/qpqjs/services/test/models/src/SmokeTestStatus.ts +2 -0
  9. package/template/apps/qpqjs/services/test/service/src/smoke/config/defineSmoke.ts +96 -6
  10. package/template/apps/qpqjs/services/test/service/src/smoke/constants/smokeProbe.ts +25 -0
  11. package/template/apps/qpqjs/services/test/service/src/smoke/email/index.ts +2 -0
  12. package/template/apps/qpqjs/services/test/service/src/smoke/email/onSmokeEmailReceived.ts +33 -0
  13. package/template/apps/qpqjs/services/test/service/src/smoke/email/smokeEmailMarkerId.ts +4 -0
  14. package/template/apps/qpqjs/services/test/service/src/smoke/index.ts +3 -0
  15. package/template/apps/qpqjs/services/test/service/src/smoke/kvsStream/index.ts +2 -0
  16. package/template/apps/qpqjs/services/test/service/src/smoke/kvsStream/onSmokeStreamRecord.ts +31 -0
  17. package/template/apps/qpqjs/services/test/service/src/smoke/kvsStream/smokeStreamMarkerId.ts +8 -0
  18. package/template/apps/qpqjs/services/test/service/src/smoke/logic/smokeRun/askExecuteSmokeTest.ts +3 -2
  19. package/template/apps/qpqjs/services/test/service/src/smoke/logic/smokeRun/askStartSmokeRun.ts +27 -13
  20. package/template/apps/qpqjs/services/test/service/src/smoke/logic/smokeRun/summarizeSmokeRun.ts +5 -1
  21. package/template/apps/qpqjs/services/test/service/src/smoke/storageDrive/index.ts +4 -0
  22. package/template/apps/qpqjs/services/test/service/src/smoke/storageDrive/onSmokeFileEvent.ts +38 -0
  23. package/template/apps/qpqjs/services/test/service/src/smoke/storageDrive/smokeFileEventMarkerId.ts +14 -0
  24. package/template/apps/qpqjs/services/test/service/src/smoke/tests/SmokeTestDefinition.ts +3 -0
  25. package/template/apps/qpqjs/services/test/service/src/smoke/tests/askSmokePollForMarker.ts +44 -0
  26. package/template/apps/qpqjs/services/test/service/src/smoke/tests/emailReceive/askRunEmailReceiveTest.ts +41 -0
  27. package/template/apps/qpqjs/services/test/service/src/smoke/tests/emailReceive/index.ts +1 -0
  28. package/template/apps/qpqjs/services/test/service/src/smoke/tests/encryptedResources/askRunEncryptedResourcesTest.ts +63 -0
  29. package/template/apps/qpqjs/services/test/service/src/smoke/tests/encryptedResources/index.ts +1 -0
  30. package/template/apps/qpqjs/services/test/service/src/smoke/tests/eventBus/askRunEventBusTest.ts +2 -26
  31. package/template/apps/qpqjs/services/test/service/src/smoke/tests/eventDoc/askAwaitSmokeEventDocLog.ts +2 -1
  32. package/template/apps/qpqjs/services/test/service/src/smoke/tests/index.ts +5 -0
  33. package/template/apps/qpqjs/services/test/service/src/smoke/tests/keyValueStoreStream/askRunKeyValueStoreStreamTest.ts +60 -0
  34. package/template/apps/qpqjs/services/test/service/src/smoke/tests/keyValueStoreStream/index.ts +1 -0
  35. package/template/apps/qpqjs/services/test/service/src/smoke/tests/scopedKeyValueStore/askRunScopedKeyValueStoreTest.ts +102 -0
  36. package/template/apps/qpqjs/services/test/service/src/smoke/tests/scopedKeyValueStore/index.ts +1 -0
  37. package/template/apps/qpqjs/services/test/service/src/smoke/tests/scopedStorageDrive/askRunScopedStorageDriveTest.ts +116 -0
  38. package/template/apps/qpqjs/services/test/service/src/smoke/tests/scopedStorageDrive/index.ts +1 -0
  39. package/template/apps/qpqjs/services/test/service/src/smoke/tests/smokeTestRegistry.ts +12 -0
  40. package/template/apps/qpqjs/services/test/service/src/smoke/tests/storageDriveEvent/askRunStorageDriveEventTest.ts +80 -0
  41. package/template/apps/qpqjs/services/test/service/src/smoke/tests/storageDriveEvent/index.ts +1 -0
  42. package/template/docusaurus/docs/actions/core/file/ask-file-write-text-contents.md +1 -1
  43. package/template/docusaurus/docs/actions/core/platform/ask-platform-get-name.md +32 -0
  44. package/template/docusaurus/docs/actions/webserver/email/ask-email-parse.md +54 -0
  45. package/template/docusaurus/docs/config/config-aws/account-email-receiving.md +33 -0
  46. package/template/docusaurus/docs/config/core/crypto-key.md +3 -1
  47. package/template/docusaurus/docs/config/core/key-value-store.md +4 -3
  48. package/template/docusaurus/docs/config/core/storage-drive.md +5 -4
  49. package/template/docusaurus/docs/config/features/event-doc-routes.md +1 -0
  50. package/template/docusaurus/docs/config/features/tenant-stores.md +10 -5
  51. package/template/docusaurus/docs/config/features/tenant.md +36 -12
  52. package/template/docusaurus/docs/config/features/tenanted-email-receiver.md +79 -0
  53. package/template/docusaurus/docs/config/features/tenanted-event-doc.md +22 -7
  54. package/template/docusaurus/docs/config/webserver/email-receiver.md +65 -0
  55. package/template/docusaurus/docs/config/webserver/email-receiving-domain.md +41 -0
  56. package/template/scripts/smoke/runSmokeRun.mjs +1 -1
  57. package/template/docusaurus/docs/config/config-aws/aws-kms-key.md +0 -100
@@ -0,0 +1,41 @@
1
+ import { askDnsResolveHosts, askEmailSendEmail, AskResponse } from 'quidproquo';
2
+
3
+ import { smokeEmailMarkerId } from '../../email/smokeEmailMarkerId';
4
+ import { askSmokeAssert } from '../askSmokeAssert';
5
+ import { askSmokePollForMarker } from '../askSmokePollForMarker';
6
+
7
+ // The inbound email path end to end: send to this app's own receiving domain
8
+ // with its own sender, SES receives it through the account rule set and the
9
+ // app's rule, the receiver parses it and hands it to onSmokeEmailReceived,
10
+ // which writes the marker. Deployed only: nothing receives mail locally.
11
+ export function* askRunEmailReceiveTest(runId: string): AskResponse<void> {
12
+ const [receivingHost] = yield* askDnsResolveHosts({ subdomain: 'inbox' });
13
+ const [siteHost] = yield* askDnsResolveHosts();
14
+
15
+ const recipient = `${runId}@${receivingHost}`;
16
+ const subject = `smoke ${runId}`;
17
+
18
+ yield* askEmailSendEmail({
19
+ from: `smoke@${siteHost}`,
20
+ to: [recipient],
21
+ subject,
22
+ bodyText: `smoke probe ${runId}`,
23
+ });
24
+
25
+ // Mail delivery is the slowest path in the suite: SES receives, scans and
26
+ // writes to S3 before the drive event fires.
27
+ const marker = yield* askSmokePollForMarker(
28
+ smokeEmailMarkerId(recipient),
29
+ 'the inbound email path',
30
+ 45
31
+ );
32
+
33
+ yield* askSmokeAssert(
34
+ marker.path === subject,
35
+ `received subject [${marker.path}] did not match [${subject}]`
36
+ );
37
+ yield* askSmokeAssert(
38
+ marker.authentication === 'pass/pass',
39
+ `received message was not authenticated: spf/dkim [${marker.authentication}]`
40
+ );
41
+ }
@@ -0,0 +1 @@
1
+ export * from './askRunEmailReceiveTest';
@@ -0,0 +1,63 @@
1
+ import {
2
+ askFileDelete,
3
+ askFileReadTextContents,
4
+ askFileWriteTextContents,
5
+ askKeyValueStoreDelete,
6
+ askKeyValueStoreGet,
7
+ askKeyValueStoreUpsert,
8
+ askNewGuid,
9
+ AskResponse,
10
+ } from 'quidproquo';
11
+
12
+ import { SmokeProbeRecord } from '@qpqjs/test-models';
13
+
14
+ import {
15
+ SMOKE_ENCRYPTED_PROBE_DRIVE,
16
+ SMOKE_ENCRYPTED_PROBE_STORE,
17
+ } from '../../constants/smokeProbe';
18
+ import { askSmokeAssert } from '../askSmokeAssert';
19
+
20
+ // A drive and a store encrypted with a defineCryptoKey. Deployed, every write
21
+ // needs kms:GenerateDataKey and every read kms:Decrypt on that key through
22
+ // the alias-conditioned grant, so a plain round trip on each is the proof.
23
+ // Locally there is no encryption at rest and this is a plain round trip.
24
+ export function* askRunEncryptedResourcesTest(): AskResponse<void> {
25
+ const probeId = yield* askNewGuid();
26
+
27
+ const record: SmokeProbeRecord = { probeId, category: 'encrypted', value: 1 };
28
+ yield* askKeyValueStoreUpsert<SmokeProbeRecord>(
29
+ SMOKE_ENCRYPTED_PROBE_STORE,
30
+ record
31
+ );
32
+
33
+ const written = yield* askKeyValueStoreGet<SmokeProbeRecord>(
34
+ SMOKE_ENCRYPTED_PROBE_STORE,
35
+ probeId
36
+ );
37
+ yield* askSmokeAssert(
38
+ written?.value === 1,
39
+ 'get after upsert on the encrypted store did not return the record'
40
+ );
41
+
42
+ yield* askKeyValueStoreDelete(SMOKE_ENCRYPTED_PROBE_STORE, probeId);
43
+
44
+ const filepath = `encrypted/${probeId}.txt`;
45
+ const contents = `encrypted smoke probe ${probeId}`;
46
+
47
+ yield* askFileWriteTextContents(
48
+ SMOKE_ENCRYPTED_PROBE_DRIVE,
49
+ filepath,
50
+ contents
51
+ );
52
+
53
+ const readBack = yield* askFileReadTextContents(
54
+ SMOKE_ENCRYPTED_PROBE_DRIVE,
55
+ filepath
56
+ );
57
+ yield* askSmokeAssert(
58
+ readBack === contents,
59
+ 'file on the encrypted drive did not read back as written'
60
+ );
61
+
62
+ yield* askFileDelete(SMOKE_ENCRYPTED_PROBE_DRIVE, [filepath]);
63
+ }
@@ -0,0 +1 @@
1
+ export * from './askRunEncryptedResourcesTest';
@@ -1,26 +1,16 @@
1
1
  import {
2
- askDelay,
3
2
  askEventBusSendMessages,
4
- askKeyValueStoreDelete,
5
- askKeyValueStoreGet,
6
3
  askNewGuid,
7
4
  AskResponse,
8
5
  EventBusMessage,
9
- Nullable,
10
6
  } from 'quidproquo';
11
7
 
12
- import { SMOKE_PROBE_STORE } from '@qpqjs/constants';
13
- import { SmokeProbeRecord } from '@qpqjs/test-models';
14
-
15
8
  import {
16
9
  SMOKE_PROBE_EVENT_BUS,
17
10
  SMOKE_PROBE_EVENT_TYPE,
18
11
  } from '../../constants/smokeProbe';
19
12
  import { SmokeProbeEventPayload } from '../../models/SmokeProbeEventQueueEvent';
20
- import { askSmokeAssert } from '../askSmokeAssert';
21
-
22
- const POLL_ATTEMPTS = 15;
23
- const POLL_INTERVAL_MS = 2000;
13
+ import { askSmokePollForMarker } from '../askSmokePollForMarker';
24
14
 
25
15
  // The full async fan-out / fan-in path: publish to the owned event bus, the
26
16
  // subscribed queue's entry (onSmokeProbeEvent) writes a marker row, and this
@@ -39,19 +29,5 @@ export function* askRunEventBusTest(): AskResponse<void> {
39
29
  eventBusMessages: [message],
40
30
  });
41
31
 
42
- let marker: Nullable<SmokeProbeRecord> = null;
43
- for (let attempt = 0; attempt < POLL_ATTEMPTS && !marker; attempt += 1) {
44
- yield* askDelay(POLL_INTERVAL_MS);
45
- marker = yield* askKeyValueStoreGet<SmokeProbeRecord>(
46
- SMOKE_PROBE_STORE,
47
- markerId
48
- );
49
- }
50
-
51
- yield* askSmokeAssert(
52
- !!marker,
53
- `marker [${markerId}] never arrived via the event bus and queue`
54
- );
55
-
56
- yield* askKeyValueStoreDelete(SMOKE_PROBE_STORE, markerId);
32
+ yield* askSmokePollForMarker(markerId, 'the event bus and queue');
57
33
  }
@@ -3,7 +3,8 @@ import { askEventDocEventListAll, EventDocEvent } from 'quidproquo-features';
3
3
 
4
4
  import { askSmokeAssert } from '../askSmokeAssert';
5
5
 
6
- const POLL_ATTEMPTS = 20;
6
+ // Long enough to cover a writer's retry laps plus one queue redelivery.
7
+ const POLL_ATTEMPTS = 45;
7
8
  const POLL_INTERVAL_MS = 2000;
8
9
 
9
10
  // Poll a probe doc's log until it holds `expectedLength` events (INIT_STATE included),
@@ -1,8 +1,12 @@
1
1
  export * from './askSmokeAssert';
2
+ export * from './askSmokePollForMarker';
2
3
  export * from './crossService';
4
+ export * from './emailReceive';
5
+ export * from './encryptedResources';
3
6
  export * from './eventBus';
4
7
  export * from './eventDoc';
5
8
  export * from './keyValueStore';
9
+ export * from './keyValueStoreStream';
6
10
  export * from './noop';
7
11
  export * from './openApi';
8
12
  export * from './parameter';
@@ -11,3 +15,4 @@ export * from './signingKey';
11
15
  export * from './SmokeTestDefinition';
12
16
  export * from './smokeTestRegistry';
13
17
  export * from './storageDrive';
18
+ export * from './storageDriveEvent';
@@ -0,0 +1,60 @@
1
+ import {
2
+ askKeyValueStoreDelete,
3
+ askKeyValueStoreUpsert,
4
+ askNewGuid,
5
+ AskResponse,
6
+ KvsStreamEventType,
7
+ } from 'quidproquo';
8
+
9
+ import { SmokeProbeRecord } from '@qpqjs/test-models';
10
+
11
+ import { SMOKE_STREAM_PROBE_STORE } from '../../constants/smokeProbe';
12
+ import { smokeStreamMarkerId } from '../../kvsStream/smokeStreamMarkerId';
13
+ import { askSmokeAssert } from '../askSmokeAssert';
14
+ import { askSmokePollForMarker } from '../askSmokePollForMarker';
15
+
16
+ // The kvs stream path end to end on a scoped store: an upsert streams an
17
+ // Insert and a delete streams a Remove to the handler. On dynamo the scope is
18
+ // composed into the stored partition key, so the record must hand it back as
19
+ // its own field with the key stripped, the same as the dev server does.
20
+ export function* askRunKeyValueStoreStreamTest(): AskResponse<void> {
21
+ const probeId = yield* askNewGuid();
22
+ const scope = `smoke-st-${probeId}`;
23
+
24
+ const record: SmokeProbeRecord = {
25
+ probeId,
26
+ category: 'stream',
27
+ value: 1,
28
+ };
29
+ yield* askKeyValueStoreUpsert<SmokeProbeRecord>(
30
+ SMOKE_STREAM_PROBE_STORE,
31
+ record,
32
+ { scope }
33
+ );
34
+
35
+ const inserted = yield* askSmokePollForMarker(
36
+ smokeStreamMarkerId(probeId, KvsStreamEventType.Insert),
37
+ 'the kvs stream insert'
38
+ );
39
+ yield* askSmokeAssert(
40
+ inserted.scope === scope,
41
+ `stream insert carried scope [${inserted.scope}], expected [${scope}]`
42
+ );
43
+ yield* askSmokeAssert(
44
+ inserted.path === probeId,
45
+ `stream insert key [${inserted.path}] was not the raw key`
46
+ );
47
+
48
+ yield* askKeyValueStoreDelete(SMOKE_STREAM_PROBE_STORE, probeId, undefined, {
49
+ scope,
50
+ });
51
+
52
+ const removed = yield* askSmokePollForMarker(
53
+ smokeStreamMarkerId(probeId, KvsStreamEventType.Remove),
54
+ 'the kvs stream remove'
55
+ );
56
+ yield* askSmokeAssert(
57
+ removed.scope === scope && removed.path === probeId,
58
+ 'stream remove did not carry the scope and raw key'
59
+ );
60
+ }
@@ -0,0 +1 @@
1
+ export * from './askRunKeyValueStoreStreamTest';
@@ -0,0 +1,102 @@
1
+ import {
2
+ askCatch,
3
+ askKeyValueStoreDelete,
4
+ askKeyValueStoreGet,
5
+ askKeyValueStoreGetBase,
6
+ askKeyValueStoreQuery,
7
+ askKeyValueStoreUpsert,
8
+ askKeyValueStoreUpsertBase,
9
+ askNewGuid,
10
+ AskResponse,
11
+ kvsEqual,
12
+ } from 'quidproquo';
13
+
14
+ import { SMOKE_PROBE_STORE } from '@qpqjs/constants';
15
+ import { SmokeProbeRecord } from '@qpqjs/test-models';
16
+
17
+ import { SMOKE_SCOPED_PROBE_STORE } from '../../constants/smokeProbe';
18
+ import { askSmokeAssert } from '../askSmokeAssert';
19
+
20
+ // The scope gate on a store, on whichever backend is running: a scoped store
21
+ // serves scoped calls (including a query) and keeps two scopes apart,
22
+ // refuses an unscoped call, and an unscoped store refuses a scoped one. On
23
+ // dynamo the scope is composed into the partition key, so the read-back also
24
+ // proves the composed form never leaks to the caller.
25
+ export function* askRunScopedKeyValueStoreTest(): AskResponse<void> {
26
+ const probeId = yield* askNewGuid();
27
+ const scopeA = `smoke-a-${probeId}`;
28
+ const scopeB = `smoke-b-${probeId}`;
29
+ const category = `scoped-kvs-${probeId}`;
30
+
31
+ const record: SmokeProbeRecord = { probeId, category, value: 1 };
32
+ yield* askKeyValueStoreUpsert<SmokeProbeRecord>(
33
+ SMOKE_SCOPED_PROBE_STORE,
34
+ record,
35
+ { scope: scopeA }
36
+ );
37
+
38
+ const written = yield* askKeyValueStoreGet<SmokeProbeRecord>(
39
+ SMOKE_SCOPED_PROBE_STORE,
40
+ probeId,
41
+ { scope: scopeA }
42
+ );
43
+ yield* askSmokeAssert(
44
+ written?.probeId === probeId && written.value === 1,
45
+ 'scoped get did not return the record with its raw key'
46
+ );
47
+
48
+ // A scoped query must constrain the partition key (the scope is composed
49
+ // into it on dynamo), so the query is on probeId, not the category index.
50
+ const byKey = yield* askKeyValueStoreQuery<SmokeProbeRecord>(
51
+ SMOKE_SCOPED_PROBE_STORE,
52
+ kvsEqual('probeId', probeId),
53
+ { scope: scopeA }
54
+ );
55
+ yield* askSmokeAssert(
56
+ byKey.items.some((item) => item.category === category),
57
+ 'scoped query by partition key did not return the record'
58
+ );
59
+
60
+ const fromB = yield* askKeyValueStoreGet<SmokeProbeRecord>(
61
+ SMOKE_SCOPED_PROBE_STORE,
62
+ probeId,
63
+ { scope: scopeB }
64
+ );
65
+ yield* askSmokeAssert(
66
+ !fromB,
67
+ 'record written under scope A is visible from scope B'
68
+ );
69
+
70
+ const unscopedGet = yield* askCatch(
71
+ askKeyValueStoreGet<SmokeProbeRecord>(SMOKE_SCOPED_PROBE_STORE, probeId)
72
+ );
73
+ yield* askSmokeAssert(
74
+ !unscopedGet.success &&
75
+ unscopedGet.error.errorType ===
76
+ askKeyValueStoreGetBase.errorType.InvalidScope,
77
+ 'unscoped get on the scoped store was not refused with InvalidScope'
78
+ );
79
+
80
+ const scopedUpsertOnUnscopedStore = yield* askCatch(
81
+ askKeyValueStoreUpsert<SmokeProbeRecord>(SMOKE_PROBE_STORE, record, {
82
+ scope: scopeA,
83
+ })
84
+ );
85
+ yield* askSmokeAssert(
86
+ !scopedUpsertOnUnscopedStore.success &&
87
+ scopedUpsertOnUnscopedStore.error.errorType ===
88
+ askKeyValueStoreUpsertBase.errorType.InvalidScope,
89
+ 'scoped upsert on the unscoped store was not refused with InvalidScope'
90
+ );
91
+
92
+ yield* askKeyValueStoreDelete(SMOKE_SCOPED_PROBE_STORE, probeId, undefined, {
93
+ scope: scopeA,
94
+ });
95
+
96
+ const deleted = yield* askKeyValueStoreGet<SmokeProbeRecord>(
97
+ SMOKE_SCOPED_PROBE_STORE,
98
+ probeId,
99
+ { scope: scopeA }
100
+ );
101
+ yield* askSmokeAssert(!deleted, 'scoped record still present after delete');
102
+ }
@@ -0,0 +1 @@
1
+ export * from './askRunScopedKeyValueStoreTest';
@@ -0,0 +1,116 @@
1
+ import {
2
+ askCatch,
3
+ askFileDelete,
4
+ askFileExists,
5
+ askFileListDirectory,
6
+ askFileReadTextContents,
7
+ askFileWriteTextContents,
8
+ askNewGuid,
9
+ AskResponse,
10
+ } from 'quidproquo';
11
+
12
+ import { SMOKE_PROBE_DRIVE } from '@qpqjs/constants';
13
+
14
+ import { SMOKE_SCOPED_PROBE_DRIVE } from '../../constants/smokeProbe';
15
+ import { askSmokeAssert } from '../askSmokeAssert';
16
+
17
+ // The scope gate on a drive, on whichever backend is running: a scoped drive
18
+ // serves scoped calls and keeps two scopes apart, refuses an unscoped call,
19
+ // and an unscoped drive refuses a scoped one. Each refusal must be the typed
20
+ // InvalidScope error, not a generic failure.
21
+ export function* askRunScopedStorageDriveTest(): AskResponse<void> {
22
+ const fileId = yield* askNewGuid();
23
+ const scopeA = `smoke-a-${fileId}`;
24
+ const scopeB = `smoke-b-${fileId}`;
25
+ const folder = 'scoped';
26
+ const filepath = `${folder}/${fileId}.txt`;
27
+ const contents = `scoped smoke probe ${fileId}`;
28
+
29
+ yield* askFileWriteTextContents(
30
+ SMOKE_SCOPED_PROBE_DRIVE,
31
+ filepath,
32
+ contents,
33
+ undefined,
34
+ scopeA
35
+ );
36
+
37
+ const readBack = yield* askFileReadTextContents(
38
+ SMOKE_SCOPED_PROBE_DRIVE,
39
+ filepath,
40
+ scopeA
41
+ );
42
+ yield* askSmokeAssert(
43
+ readBack === contents,
44
+ 'scoped file contents did not read back as written'
45
+ );
46
+
47
+ const listingA = yield* askFileListDirectory(
48
+ SMOKE_SCOPED_PROBE_DRIVE,
49
+ folder,
50
+ undefined,
51
+ undefined,
52
+ scopeA
53
+ );
54
+ yield* askSmokeAssert(
55
+ listingA.fileInfos.some((info) => info.filepath === filepath),
56
+ 'scoped listing does not show the file under its own scope'
57
+ );
58
+
59
+ const existsInB = yield* askFileExists(
60
+ SMOKE_SCOPED_PROBE_DRIVE,
61
+ filepath,
62
+ scopeB
63
+ );
64
+ yield* askSmokeAssert(
65
+ !existsInB,
66
+ 'file written under scope A is visible from scope B'
67
+ );
68
+
69
+ const unscopedRead = yield* askCatch(
70
+ askFileReadTextContents(SMOKE_SCOPED_PROBE_DRIVE, filepath)
71
+ );
72
+ yield* askSmokeAssert(
73
+ !unscopedRead.success &&
74
+ unscopedRead.error.errorType ===
75
+ askFileReadTextContents.errorType.InvalidScope,
76
+ 'unscoped read on the scoped drive was not refused with InvalidScope'
77
+ );
78
+
79
+ const unscopedList = yield* askCatch(
80
+ askFileListDirectory(SMOKE_SCOPED_PROBE_DRIVE, folder)
81
+ );
82
+ yield* askSmokeAssert(
83
+ !unscopedList.success &&
84
+ unscopedList.error.errorType ===
85
+ askFileListDirectory.errorType.InvalidScope,
86
+ 'unscoped listing on the scoped drive was not refused with InvalidScope'
87
+ );
88
+
89
+ const scopedWriteOnUnscopedDrive = yield* askCatch(
90
+ askFileWriteTextContents(
91
+ SMOKE_PROBE_DRIVE,
92
+ filepath,
93
+ contents,
94
+ undefined,
95
+ scopeA
96
+ )
97
+ );
98
+ yield* askSmokeAssert(
99
+ !scopedWriteOnUnscopedDrive.success &&
100
+ scopedWriteOnUnscopedDrive.error.errorType ===
101
+ askFileWriteTextContents.errorType.InvalidScope,
102
+ 'scoped write on the unscoped drive was not refused with InvalidScope'
103
+ );
104
+
105
+ yield* askFileDelete(SMOKE_SCOPED_PROBE_DRIVE, [filepath], scopeA);
106
+
107
+ const existsAfterDelete = yield* askFileExists(
108
+ SMOKE_SCOPED_PROBE_DRIVE,
109
+ filepath,
110
+ scopeA
111
+ );
112
+ yield* askSmokeAssert(
113
+ !existsAfterDelete,
114
+ 'scoped file still exists after delete'
115
+ );
116
+ }
@@ -0,0 +1 @@
1
+ export * from './askRunScopedStorageDriveTest';
@@ -1,18 +1,24 @@
1
1
  import { askRunCrossServiceKeyValueStoreTest } from './crossService/askRunCrossServiceKeyValueStoreTest';
2
2
  import { askRunCrossServiceSigningKeyTest } from './crossService/askRunCrossServiceSigningKeyTest';
3
3
  import { askRunCrossServiceStorageDriveTest } from './crossService/askRunCrossServiceStorageDriveTest';
4
+ import { askRunEmailReceiveTest } from './emailReceive/askRunEmailReceiveTest';
5
+ import { askRunEncryptedResourcesTest } from './encryptedResources/askRunEncryptedResourcesTest';
4
6
  import { askRunEventBusTest } from './eventBus/askRunEventBusTest';
5
7
  import { askRunEventDocConcurrentAppendTest } from './eventDoc/askRunEventDocConcurrentAppendTest';
6
8
  import { askRunEventDocInterleavedBatchAppendTest } from './eventDoc/askRunEventDocInterleavedBatchAppendTest';
7
9
  import { askRunKeyValueStoreTest } from './keyValueStore/askRunKeyValueStoreTest';
10
+ import { askRunKeyValueStoreStreamTest } from './keyValueStoreStream/askRunKeyValueStoreStreamTest';
8
11
  import { askRunNoopTest } from './noop/askRunNoopTest';
9
12
  import { askRunEchoRoundTripTest } from './openApi/askRunEchoRoundTripTest';
10
13
  import { askRunOpenApiDocumentTest } from './openApi/askRunOpenApiDocumentTest';
11
14
  import { askRunParameterTest } from './parameter/askRunParameterTest';
12
15
  import { askRunScheduleTest } from './schedule/askRunScheduleTest';
16
+ import { askRunScopedKeyValueStoreTest } from './scopedKeyValueStore/askRunScopedKeyValueStoreTest';
17
+ import { askRunScopedStorageDriveTest } from './scopedStorageDrive/askRunScopedStorageDriveTest';
13
18
  import { askRunSecretTest } from './secret/askRunSecretTest';
14
19
  import { askRunSigningKeyTest } from './signingKey/askRunSigningKeyTest';
15
20
  import { askRunStorageDriveTest } from './storageDrive/askRunStorageDriveTest';
21
+ import { askRunStorageDriveEventTest } from './storageDriveEvent/askRunStorageDriveEventTest';
16
22
  import { SmokeTestDefinition } from './SmokeTestDefinition';
17
23
 
18
24
  // Every smoke test a run executes. They run in parallel, one queue message
@@ -51,4 +57,10 @@ export const smokeTestRegistry: SmokeTestDefinition[] = [
51
57
  name: 'crossServiceSigningKey',
52
58
  askRun: askRunCrossServiceSigningKeyTest,
53
59
  },
60
+ { name: 'scopedStorageDrive', askRun: askRunScopedStorageDriveTest },
61
+ { name: 'scopedKeyValueStore', askRun: askRunScopedKeyValueStoreTest },
62
+ { name: 'storageDriveEvent', askRun: askRunStorageDriveEventTest },
63
+ { name: 'keyValueStoreStream', askRun: askRunKeyValueStoreStreamTest },
64
+ { name: 'encryptedResources', askRun: askRunEncryptedResourcesTest },
65
+ { name: 'emailReceive', askRun: askRunEmailReceiveTest, deployedOnly: true },
54
66
  ];
@@ -0,0 +1,80 @@
1
+ import {
2
+ askFileDelete,
3
+ askFileWriteTextContents,
4
+ askNewGuid,
5
+ AskResponse,
6
+ StorageDriveEventType,
7
+ } from 'quidproquo';
8
+
9
+ import {
10
+ SMOKE_FILE_EVENT_DRIVE,
11
+ SMOKE_SCOPED_PROBE_DRIVE,
12
+ } from '../../constants/smokeProbe';
13
+ import { smokeFileEventMarkerId } from '../../storageDrive/smokeFileEventMarkerId';
14
+ import { askSmokeAssert } from '../askSmokeAssert';
15
+ import { askSmokePollForMarker } from '../askSmokePollForMarker';
16
+
17
+ // The storage drive event path end to end: a write fires the create handler
18
+ // and a delete fires the delete handler, locally through the file watcher and
19
+ // deployed through the S3 notification. On the scoped drive the event must
20
+ // arrive with the scope as its own field, the path made scope-relative, and
21
+ // the handler running under that scope.
22
+ export function* askRunStorageDriveEventTest(): AskResponse<void> {
23
+ const fileId = yield* askNewGuid();
24
+ const filepath = `events/${fileId}.txt`;
25
+ const scope = `smoke-ev-${fileId}`;
26
+
27
+ yield* askFileWriteTextContents(SMOKE_FILE_EVENT_DRIVE, filepath, fileId);
28
+
29
+ const created = yield* askSmokePollForMarker(
30
+ smokeFileEventMarkerId(filepath, StorageDriveEventType.Create),
31
+ 'the unscoped drive create event'
32
+ );
33
+ yield* askSmokeAssert(
34
+ created.path === filepath && created.scope === undefined,
35
+ 'unscoped create event did not carry the bare path with no scope'
36
+ );
37
+
38
+ yield* askFileDelete(SMOKE_FILE_EVENT_DRIVE, [filepath]);
39
+
40
+ yield* askSmokePollForMarker(
41
+ smokeFileEventMarkerId(filepath, StorageDriveEventType.Delete),
42
+ 'the unscoped drive delete event'
43
+ );
44
+
45
+ yield* askFileWriteTextContents(
46
+ SMOKE_SCOPED_PROBE_DRIVE,
47
+ filepath,
48
+ fileId,
49
+ undefined,
50
+ scope
51
+ );
52
+
53
+ const scopedCreated = yield* askSmokePollForMarker(
54
+ smokeFileEventMarkerId(filepath, StorageDriveEventType.Create),
55
+ 'the scoped drive create event'
56
+ );
57
+ yield* askSmokeAssert(
58
+ scopedCreated.scope === scope,
59
+ `scoped create event carried scope [${scopedCreated.scope}], expected [${scope}]`
60
+ );
61
+ yield* askSmokeAssert(
62
+ scopedCreated.path === filepath,
63
+ `scoped create event path [${scopedCreated.path}] was not scope-relative`
64
+ );
65
+ yield* askSmokeAssert(
66
+ scopedCreated.ambientScope === scope,
67
+ 'scoped create handler did not run under the event scope'
68
+ );
69
+
70
+ yield* askFileDelete(SMOKE_SCOPED_PROBE_DRIVE, [filepath], scope);
71
+
72
+ const scopedDeleted = yield* askSmokePollForMarker(
73
+ smokeFileEventMarkerId(filepath, StorageDriveEventType.Delete),
74
+ 'the scoped drive delete event'
75
+ );
76
+ yield* askSmokeAssert(
77
+ scopedDeleted.scope === scope && scopedDeleted.path === filepath,
78
+ 'scoped delete event did not carry the scope and scope-relative path'
79
+ );
80
+ }
@@ -0,0 +1 @@
1
+ export * from './askRunStorageDriveEventTest';
@@ -60,7 +60,7 @@ function* askFileWriteTextContents(
60
60
  ## Notes
61
61
 
62
62
  - If the drive declares an `onEvent.create` handler in [defineStorageDrive](../../../config/core/storage-drive.md#file-events-onevent), that story is triggered by this write.
63
- - Writes to a drive with `encryption: true` are encrypted at rest automatically — nothing extra is needed at the call site.
63
+ - Every drive is encrypted at rest; `cryptoKeyName` swaps in one of your own `defineCryptoKey`s. Nothing changes at the call site either way.
64
64
 
65
65
  ## Related
66
66
 
@@ -0,0 +1,32 @@
1
+ ---
2
+ title: askPlatformGetName
3
+ description: The name of the runtime executing the story, as its action processors declare it.
4
+ ---
5
+
6
+ # askPlatformGetName
7
+
8
+ Returns the name of the platform the story is running on, an opaque string each runtime's action processor set registers for itself: `aws` on Lambda, `devServer` on the dev server, `browser` in the browser, `node` on a plain node runtime.
9
+
10
+ It exists for the rare story that must skip or degrade work a platform cannot do (a smoke test with no local equivalent, say). It is not for ordinary logic: a story that branches on the platform is doing what action processors are for.
11
+
12
+ ```typescript
13
+ import { askPlatformGetName } from 'quidproquo-core';
14
+
15
+ export function* askRunDeployedOnlyCheck() {
16
+ const platform = yield* askPlatformGetName();
17
+ if (platform === 'devServer') {
18
+ return;
19
+ }
20
+ // ...
21
+ }
22
+ ```
23
+
24
+ ## Signature
25
+
26
+ ```typescript
27
+ function* askPlatformGetName(): AskResponse<string>;
28
+ ```
29
+
30
+ ## Returns
31
+
32
+ `string` — the registered platform name. Core defines no names; each runtime registers its own literal.