|
8 | 8 | * - Chain-redirect detection (409 on A→B when B→C would chain) |
9 | 9 | * - Abuse-counter threshold (Ford PasswordResetAbuse: 403 after N attempts) |
10 | 10 | * |
11 | | - * Self-contained: deploys an inline component with schema + resources JS and |
12 | | - * restarts http_workers. Skipped on Windows (restart_service http_workers |
13 | | - * crashes the Harper instance on Windows — see HarperFast/harper#549). |
| 11 | + * Component files live in integrationTests/fixtures/custom-resources/. |
| 12 | + * Skipped on Windows (restart_service http_workers crashes the Harper instance |
| 13 | + * on Windows — see HarperFast/harper#549). |
14 | 14 | * |
15 | 15 | * Implements HarperFast/harper#1190. |
16 | 16 | */ |
17 | 17 | import { suite, test, before, after } from 'node:test'; |
18 | 18 | import { strictEqual, ok, deepStrictEqual } from 'node:assert/strict'; |
| 19 | +import { readFileSync } from 'node:fs'; |
| 20 | +import { join, dirname } from 'node:path'; |
| 21 | +import { fileURLToPath } from 'node:url'; |
19 | 22 | import { startHarper, teardownHarper } from '@harperfast/integration-testing'; |
20 | | -import { createApiClient } from './utils/client.mjs'; |
21 | | -import { installAppComponent } from './utils/components.mjs'; |
| 23 | +import { createApiClient } from '../apiTests/utils/client.mjs'; |
| 24 | +import { installAppComponent } from '../apiTests/utils/components.mjs'; |
22 | 25 |
|
23 | | -// --------------------------------------------------------------------------- |
24 | | -// Component definition |
25 | | -// --------------------------------------------------------------------------- |
26 | | - |
27 | | -const SCHEMA_GRAPHQL = ` |
28 | | -type WorkItem @table @export { |
29 | | - id: ID @primaryKey |
30 | | - state: String @indexed |
31 | | - payload: String |
32 | | - result: String |
33 | | - resultAt: Date @updatedTime |
34 | | -} |
35 | | -
|
36 | | -type RedirectRule @table @export @sealed { |
37 | | - id: ID @primaryKey |
38 | | - matchUrl: String @indexed |
39 | | - redirectUrl: String @indexed |
40 | | - statusCode: Int |
41 | | - startTime: Date |
42 | | - endTime: Date |
43 | | - createdBy: String |
44 | | -} |
45 | | -
|
46 | | -type RedirectChange @table @export { |
47 | | - id: ID @primaryKey |
48 | | - redirectId: String @indexed |
49 | | - operation: String |
50 | | - previousState: String |
51 | | - createdAt: Date @createdTime |
52 | | -} |
53 | | -
|
54 | | -type AbuseCounter @table(expiration: 10) @export { |
55 | | - id: ID @primaryKey |
56 | | - count: Int |
57 | | -} |
58 | | -`; |
59 | | - |
60 | | -const RESOURCES_JS = ` |
61 | | -// WorkItem: async write-then-patch pattern (CDI RT enqueueing + AI inference result attachment) |
62 | | -export class WorkItem extends tables.WorkItem { |
63 | | - async post(body, ctx) { |
64 | | - const id = Math.random().toString(36).slice(2); |
65 | | - // Use static class method to create a new record by explicit id |
66 | | - await tables.WorkItem.put({ id, state: 'pending', payload: JSON.stringify(body) }); |
67 | | - return { id, state: 'pending' }; |
68 | | - } |
69 | | - async patch(body, ctx) { |
70 | | - // this is the loaded record instance; doesExist() tells us if it was found |
71 | | - if (!this.doesExist()) return new Response(null, { status: 404 }); |
72 | | - const current = await this.get(); |
73 | | - // Full update via single-arg super.put (legacy: update(record, true) + save) |
74 | | - await super.put({ ...current, state: 'completed', result: body.result, resultAt: new Date() }); |
75 | | - return { state: 'completed' }; |
76 | | - } |
77 | | -} |
78 | | -
|
79 | | -// RedirectRule: routing decision with audit trail (Walmart USGM pattern) |
80 | | -export class RedirectRule extends tables.RedirectRule { |
81 | | - async post(body, ctx) { |
82 | | - // chain detection: check if redirectUrl matches any existing matchUrl |
83 | | - const existing = []; |
84 | | - for await (const r of tables.RedirectRule.search({ conditions: [{ attribute: 'matchUrl', value: body.redirectUrl }] })) { |
85 | | - existing.push(r); |
86 | | - } |
87 | | - if (existing.length > 0) { |
88 | | - const context = this.getContext(); |
89 | | - if (context?.response) context.response.status = 409; |
90 | | - return { error: 'Chain redirect detected' }; |
91 | | - } |
92 | | - const id = Math.random().toString(36).slice(2); |
93 | | - const user = this.getContext()?.user; |
94 | | - const record = { id, ...body, createdBy: user?.username || 'anonymous' }; |
95 | | - // Write the redirect rule and audit record using static methods |
96 | | - await tables.RedirectRule.put(record); |
97 | | - await tables.RedirectChange.put({ |
98 | | - id: Math.random().toString(36).slice(2), |
99 | | - redirectId: id, |
100 | | - operation: 'create', |
101 | | - previousState: null, |
102 | | - }); |
103 | | - return record; |
104 | | - } |
105 | | -} |
106 | | -
|
107 | | -// Block external mutations on RedirectChange (audit log is immutable from outside) |
108 | | -export class RedirectChange extends tables.RedirectChange { |
109 | | - post() { return new Response(null, { status: 405 }); } |
110 | | - put() { return new Response(null, { status: 405 }); } |
111 | | - patch() { return new Response(null, { status: 405 }); } |
112 | | - delete() { return new Response(null, { status: 405 }); } |
113 | | -} |
114 | | -
|
115 | | -// RoutingDecision: POST-only routing lookup endpoint (Walmart USGM) |
116 | | -export class RoutingDecision extends Resource { |
117 | | - static loadAsInstance = false; |
118 | | - async post(query, body) { |
119 | | - const { path } = body; |
120 | | - if (!path) return {}; |
121 | | - const now = new Date(); |
122 | | - const rules = []; |
123 | | - for await (const r of tables.RedirectRule.search({ conditions: [{ attribute: 'matchUrl', value: path }] })) { |
124 | | - rules.push(r); |
125 | | - } |
126 | | - const rule = rules.find(r => { |
127 | | - if (r.startTime && new Date(r.startTime) > now) return false; |
128 | | - if (r.endTime && new Date(r.endTime) < now) return false; |
129 | | - return true; |
130 | | - }); |
131 | | - if (rule) return { shouldRedirect: true, status: rule.statusCode || 302, location: rule.redirectUrl }; |
132 | | - return {}; |
133 | | - } |
134 | | -} |
135 | | -
|
136 | | -// AbuseCounter: atomic counter with 403 threshold (Ford PasswordResetAbuse pattern) |
137 | | -export class AbuseCounter extends tables.AbuseCounter { |
138 | | - async put(body, ctx) { |
139 | | - // this is the loaded record instance; get current count from the stored record |
140 | | - const current = await this.get(); |
141 | | - const id = this.getId(); |
142 | | - const newCount = ((current && current.count) || 0) + 1; |
143 | | - if (newCount > 5) { |
144 | | - const context = this.getContext(); |
145 | | - if (context?.response) context.response.status = 403; |
146 | | - return { error: 'Too many attempts' }; |
147 | | - } |
148 | | - // Full update via single-arg super.put (legacy: update(record, true) + save) |
149 | | - await super.put({ id, count: newCount }); |
150 | | - return { count: newCount }; |
151 | | - } |
152 | | -} |
153 | | -`; |
154 | | - |
155 | | -const CONFIG_YAML = `graphqlSchema: |
156 | | - files: '*.graphql' |
157 | | -jsResource: |
158 | | - files: resources.js |
159 | | -rest: true |
160 | | -`; |
| 26 | +const __dirname = dirname(fileURLToPath(import.meta.url)); |
| 27 | +const FIXTURE_DIR = join(__dirname, '../fixtures/custom-resources'); |
161 | 28 |
|
162 | 29 | // --------------------------------------------------------------------------- |
163 | 30 | // Helpers |
@@ -191,9 +58,9 @@ suite('Custom resource patterns', { skip: skipSuite }, (ctx) => { |
191 | 58 | await installAppComponent(client, { |
192 | 59 | project: 'customResources', |
193 | 60 | files: { |
194 | | - 'schema.graphql': SCHEMA_GRAPHQL, |
195 | | - 'resources.js': RESOURCES_JS, |
196 | | - 'config.yaml': CONFIG_YAML, |
| 61 | + 'schema.graphql': readFileSync(join(FIXTURE_DIR, 'schema.graphql'), 'utf-8'), |
| 62 | + 'resources.js': readFileSync(join(FIXTURE_DIR, 'resources.js'), 'utf-8'), |
| 63 | + 'config.yaml': readFileSync(join(FIXTURE_DIR, 'config.yaml'), 'utf-8'), |
197 | 64 | }, |
198 | 65 | probePath: '/WorkItem/', |
199 | 66 | restartTimeoutMs: 120_000, |
|
0 commit comments