Compare commits
13 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b00a4320a7 | ||
| 8677faf54b | |||
| 4c4d52e3de | |||
| 5ee6894916 | |||
| fd451fd452 | |||
| 90c5ca2248 | |||
| f4f9289bdc | |||
| b3b2903c75 | |||
| b1a13b83fd | |||
| 5b0bec8850 | |||
|
|
b14b8188fe | ||
|
|
0673c4c0d8 | ||
|
|
84693a7af5 |
18 changed files with 3486 additions and 218 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -2,3 +2,6 @@ node_modules/
|
|||
dist/
|
||||
*.tgz
|
||||
.DS_Store
|
||||
__pycache__/
|
||||
.pytest_cache/
|
||||
*.pyc
|
||||
|
|
|
|||
14
README.md
14
README.md
|
|
@ -43,12 +43,16 @@ webapp-scaffold-postprocess-openapi && \
|
|||
orval
|
||||
```
|
||||
|
||||
## `createCoreFetch` (v0.2)
|
||||
## `createCoreFetch` (v0.4 — default flipped to `'body'`)
|
||||
|
||||
Orval's `client: 'fetch'` calls a mutator `coreFetch<T>(url, init)` and
|
||||
expects `{data, status, headers}` back. `createCoreFetch(opts)` returns
|
||||
such a function, with credentials/CSRF headers / 204 / content-type
|
||||
handling already wired, and hooks for project-specific concerns:
|
||||
Orval's `client: 'fetch'` calls a mutator `coreFetch<T>(url, init)`. The
|
||||
return shape is configurable; **the default is `'body'`** (returns the
|
||||
parsed JSON body directly, matching Orval's
|
||||
`includeHttpResponseReturnType: false`). Pass `responseShape: 'wrapped'`
|
||||
to opt back into `{data, status, headers}` when callers need the
|
||||
Response metadata. `createCoreFetch(opts)` returns such a function, with
|
||||
credentials/CSRF headers / 204 / content-type handling already wired,
|
||||
and hooks for project-specific concerns:
|
||||
|
||||
```ts
|
||||
import { createCoreFetch } from '@uschuster/webapp-scaffold/core-fetch';
|
||||
|
|
|
|||
|
|
@ -29,9 +29,10 @@ curl -fsS "${AUTH_HEADER[@]}" "$URL" -o "$OUT"
|
|||
|
||||
# Verify it's actually JSON with an `openapi` key — oatpp sometimes returns
|
||||
# HTML on 401 which would silently land here otherwise.
|
||||
python3 -c "
|
||||
python3 - "$OUT" <<'PY'
|
||||
import json, sys
|
||||
d = json.load(open('$OUT'))
|
||||
assert 'openapi' in d, 'not an OpenAPI spec (missing \"openapi\" key)'
|
||||
print(f\" openapi={d['openapi']}, paths={len(d.get('paths', {}))}\")
|
||||
"
|
||||
path = sys.argv[1]
|
||||
d = json.load(open(path))
|
||||
assert 'openapi' in d, 'not an OpenAPI spec (missing "openapi" key)'
|
||||
print(f" openapi={d['openapi']}, paths={len(d.get('paths', {}))}")
|
||||
PY
|
||||
|
|
|
|||
|
|
@ -26,9 +26,29 @@ containing the config file's parent chain → `$PWD`).
|
|||
"""
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
|
||||
|
||||
def _build_pattern(old_src: str) -> "re.Pattern[str]":
|
||||
# Match `src` on <script> and `href` on <link>, single- or double-quoted.
|
||||
# We anchor to the tag name so an `old_src` substring sitting inside an
|
||||
# HTML comment, a JSON literal, or a `data-…` attribute is not rewritten.
|
||||
return re.compile(
|
||||
r'(<(?:script|link)\b[^>]*?\b(?:src|href)\s*=\s*["\'])'
|
||||
+ re.escape(old_src)
|
||||
+ r'(["\'])',
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
|
||||
def rewrite(html: str, old_src: str, new_src: str) -> "tuple[str, int]":
|
||||
"""Return (new_html, count). Tag-aware: only rewrites <script src> /
|
||||
<link href> attributes, never substring matches in comments or JSON."""
|
||||
pattern = _build_pattern(old_src)
|
||||
return pattern.subn(lambda m: m.group(1) + new_src + m.group(2), html)
|
||||
|
||||
|
||||
def inject(manifest_path: str, html_path: str, old_src: str) -> None:
|
||||
if not os.path.exists(manifest_path):
|
||||
print(f"skip: no manifest at {manifest_path}")
|
||||
|
|
@ -45,12 +65,18 @@ def inject(manifest_path: str, html_path: str, old_src: str) -> None:
|
|||
new_src = f"{os.path.dirname(old_src)}/{hashed}"
|
||||
with open(html_path) as f:
|
||||
html = f.read()
|
||||
if old_src not in html:
|
||||
print(f"skip: {old_src!r} not in {html_path}")
|
||||
new_html, count = rewrite(html, old_src, new_src)
|
||||
if count == 0:
|
||||
# Loud warning — silent skip used to mask typos in `old_src`.
|
||||
print(
|
||||
f"WARN: no <script src> or <link href> matching {old_src!r} "
|
||||
f"in {html_path} — leaving file unchanged",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return
|
||||
with open(html_path, "w") as f:
|
||||
f.write(html.replace(old_src, new_src))
|
||||
print(f"{old_src} -> {new_src}")
|
||||
f.write(new_html)
|
||||
print(f"{old_src} -> {new_src} ({count} occurrence{'s' if count != 1 else ''})")
|
||||
return
|
||||
print(f"skip: no isEntry row in {manifest_path}")
|
||||
|
||||
|
|
|
|||
|
|
@ -43,5 +43,60 @@ for path, methods in paths.items():
|
|||
op.setdefault("operationId", op_id(method, path))
|
||||
op.setdefault("tags", [path.strip("/").split("/")[1] if "/" in path.strip("/") else "default"])
|
||||
|
||||
# oatpp 1.3 emits `"required": []` on schemas that have no required fields.
|
||||
# OpenAPI 3.0.x rejects this (`must NOT have fewer than 1 items`), which
|
||||
# breaks strict consumers like Orval's Zod generator. Strip empty arrays —
|
||||
# absence of the keyword has the same semantics.
|
||||
schemas = spec.get("components", {}).get("schemas", {})
|
||||
stripped_required = 0
|
||||
for sch in schemas.values():
|
||||
if isinstance(sch, dict) and sch.get("required") == []:
|
||||
del sch["required"]
|
||||
stripped_required += 1
|
||||
|
||||
# oatpp 1.3 emits `"type": "Any"` for `oatpp::Any` / `oatpp::Fields` fields.
|
||||
# `Any` is not a valid OpenAPI 3.0 type — the spec uses an empty schema `{}`
|
||||
# (or `nullable: true` with no type) to mean "any value". Replace `Any` with
|
||||
# an empty schema; recurse so we catch nested cases (object property,
|
||||
# array item, etc.).
|
||||
fixed_any = 0
|
||||
def fix_any(node):
|
||||
global fixed_any
|
||||
if isinstance(node, dict):
|
||||
if node.get("type") == "Any":
|
||||
del node["type"]
|
||||
fixed_any += 1
|
||||
for v in node.values():
|
||||
fix_any(v)
|
||||
elif isinstance(node, list):
|
||||
for item in node:
|
||||
fix_any(item)
|
||||
fix_any(spec)
|
||||
|
||||
# oatpp 1.3 sometimes emits the same path parameter twice (same name + same
|
||||
# `in: path`). OpenAPI 3.0 rejects duplicate parameters. Dedupe by (name, in)
|
||||
# preserving first occurrence.
|
||||
deduped_params = 0
|
||||
for path_item in paths.values():
|
||||
if not isinstance(path_item, dict):
|
||||
continue
|
||||
for op in path_item.values():
|
||||
if not isinstance(op, dict) or "parameters" not in op:
|
||||
continue
|
||||
seen = set()
|
||||
unique = []
|
||||
for p in op["parameters"]:
|
||||
key = (p.get("name"), p.get("in"))
|
||||
if key in seen:
|
||||
deduped_params += 1
|
||||
continue
|
||||
seen.add(key)
|
||||
unique.append(p)
|
||||
op["parameters"] = unique
|
||||
|
||||
SRC.write_text(json.dumps(spec, indent=2))
|
||||
print(f" postprocessed {SRC} — {len(paths)} paths")
|
||||
report = [f"{len(paths)} paths"]
|
||||
if stripped_required: report.append(f"stripped empty `required` from {stripped_required} schemas")
|
||||
if fixed_any: report.append(f"replaced {fixed_any} invalid `type: Any` with any-schema")
|
||||
if deduped_params: report.append(f"deduped {deduped_params} duplicate parameter(s)")
|
||||
print(f" postprocessed {SRC} — " + ", ".join(report))
|
||||
|
|
|
|||
2677
package-lock.json
generated
2677
package-lock.json
generated
File diff suppressed because it is too large
Load diff
21
package.json
21
package.json
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@uschuster/webapp-scaffold",
|
||||
"version": "0.3.2",
|
||||
"version": "0.5.0",
|
||||
"description": "Shared build scripts + Vite config factories for webapp-template-derived projects.",
|
||||
"type": "module",
|
||||
"bin": {
|
||||
|
|
@ -32,12 +32,23 @@
|
|||
},
|
||||
"files": ["bin/", "dist/", "src/", "templates/", "README.md", "LICENSE"],
|
||||
"scripts": {
|
||||
"prepare": "tsc || true"
|
||||
"prepare": "tsc && vitest run",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.0.0",
|
||||
"@types/node": "^22.0.0",
|
||||
"@types/react": "^19.0.0"
|
||||
"typescript": "^5.0.0",
|
||||
"@types/node": "^22.0.0",
|
||||
"@types/react": "^19.0.0",
|
||||
"vitest": "^2.0.0",
|
||||
"jsdom": "^25.0.0",
|
||||
"@testing-library/react": "^16.0.0",
|
||||
"@testing-library/dom": "^10.0.0",
|
||||
"@types/react-dom": "^19.0.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"vite": "^6.0.0",
|
||||
"@vitejs/plugin-react": "^4.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"vite": "^6.0.0",
|
||||
|
|
|
|||
195
src/core-fetch.test.ts
Normal file
195
src/core-fetch.test.ts
Normal file
|
|
@ -0,0 +1,195 @@
|
|||
// Vitest suite for createCoreFetch (#4).
|
||||
//
|
||||
// Covers the user-visible response shapes, hook wiring (onEnqueue, on401,
|
||||
// on409, onNetworkFailure, formatError), and the boundary cases the audit
|
||||
// flagged: 401 hook fires + caller still gets an error, 409 hook can swallow,
|
||||
// network failure hook can synthesise a body, non-JSON 2xx returns text.
|
||||
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { createCoreFetch } from './core-fetch.js';
|
||||
|
||||
function jsonResponse(body: unknown, init: ResponseInit = {}): Response {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status: 200,
|
||||
...init,
|
||||
headers: { 'content-type': 'application/json', ...(init.headers ?? {}) },
|
||||
});
|
||||
}
|
||||
|
||||
describe('createCoreFetch — response shapes', () => {
|
||||
it("'body' default returns the parsed body directly", async () => {
|
||||
const cf = createCoreFetch({ fetchImpl: () => Promise.resolve(jsonResponse({ ok: 1 })) });
|
||||
const r = await cf<{ ok: number }>('/x');
|
||||
expect(r).toEqual({ ok: 1 });
|
||||
});
|
||||
|
||||
it("'wrapped' opt-in returns { data, status, headers }", async () => {
|
||||
const cf = createCoreFetch({
|
||||
responseShape: 'wrapped',
|
||||
fetchImpl: () => Promise.resolve(jsonResponse({ ok: 1 })),
|
||||
});
|
||||
const r = await cf<{ data: { ok: number }; status: number }>('/x');
|
||||
expect(r.data).toEqual({ ok: 1 });
|
||||
expect(r.status).toBe(200);
|
||||
});
|
||||
|
||||
it('non-JSON 2xx returns the body as plain text', async () => {
|
||||
const cf = createCoreFetch({
|
||||
responseShape: 'body',
|
||||
fetchImpl: () => Promise.resolve(new Response('hello', {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'text/plain' },
|
||||
})),
|
||||
});
|
||||
const r = await cf<string>('/x');
|
||||
expect(r).toBe('hello');
|
||||
});
|
||||
|
||||
it('204 No Content returns undefined data with no JSON parse', async () => {
|
||||
const cf = createCoreFetch({
|
||||
responseShape: 'body',
|
||||
fetchImpl: () => Promise.resolve(new Response(null, { status: 204 })),
|
||||
});
|
||||
const r = await cf<undefined>('/x');
|
||||
expect(r).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('createCoreFetch — error hooks', () => {
|
||||
it('401 fires on401 then throws', async () => {
|
||||
const on401 = vi.fn();
|
||||
const cf = createCoreFetch({
|
||||
on401,
|
||||
fetchImpl: () => Promise.resolve(new Response('', { status: 401 })),
|
||||
});
|
||||
await expect(cf('/x')).rejects.toThrow();
|
||||
expect(on401).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('409 fires on409; resolved value is returned in place of throw', async () => {
|
||||
const cf = createCoreFetch({
|
||||
responseShape: 'body',
|
||||
on409: () => ({ swallowed: true }),
|
||||
fetchImpl: () => Promise.resolve(jsonResponse({ message: 'conflict' }, { status: 409 })),
|
||||
});
|
||||
const r = await cf<{ swallowed: boolean }>('/x');
|
||||
expect(r).toEqual({ swallowed: true });
|
||||
});
|
||||
|
||||
it("409 without on409 throws an error decorated with status=409 + body", async () => {
|
||||
const cf = createCoreFetch({
|
||||
fetchImpl: () => Promise.resolve(jsonResponse({ message: 'nope' }, { status: 409 })),
|
||||
});
|
||||
try {
|
||||
await cf('/x');
|
||||
expect.unreachable('expected throw');
|
||||
} catch (e) {
|
||||
const err = e as Error & { status?: number; body?: unknown };
|
||||
expect(err.status).toBe(409);
|
||||
expect(err.body).toEqual({ message: 'nope' });
|
||||
expect(err.message).toBe('nope');
|
||||
}
|
||||
});
|
||||
|
||||
it('formatError overrides the default error message', async () => {
|
||||
const cf = createCoreFetch({
|
||||
formatError: () => 'custom',
|
||||
fetchImpl: () => Promise.resolve(new Response('boom', { status: 500 })),
|
||||
});
|
||||
await expect(cf('/x')).rejects.toThrow('custom');
|
||||
});
|
||||
|
||||
it('network failure rethrows by default', async () => {
|
||||
const cf = createCoreFetch({
|
||||
fetchImpl: () => Promise.reject(new Error('offline')),
|
||||
});
|
||||
await expect(cf('/x')).rejects.toThrow('offline');
|
||||
});
|
||||
|
||||
it('onNetworkFailure can synthesise a replacement body', async () => {
|
||||
const cf = createCoreFetch({
|
||||
responseShape: 'body',
|
||||
onNetworkFailure: () => ({ offline: true }),
|
||||
fetchImpl: () => Promise.reject(new Error('offline')),
|
||||
});
|
||||
const r = await cf<{ offline: boolean }>('/x');
|
||||
expect(r).toEqual({ offline: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe('createCoreFetch — sync queue', () => {
|
||||
it('onEnqueue can short-circuit a mutating request on a sync table', async () => {
|
||||
const fetchImpl = vi.fn();
|
||||
const onEnqueue = vi.fn().mockReturnValue(true);
|
||||
const cf = createCoreFetch({
|
||||
responseShape: 'body',
|
||||
syncTables: new Set(['bookings']),
|
||||
onEnqueue,
|
||||
fetchImpl: fetchImpl as unknown as typeof fetch,
|
||||
});
|
||||
await cf('/api/bookings', { method: 'POST', body: '{}' });
|
||||
expect(onEnqueue).toHaveBeenCalledOnce();
|
||||
expect(fetchImpl).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('non-mutating request still hits the network even on sync table', async () => {
|
||||
const fetchImpl = vi.fn(() => Promise.resolve(jsonResponse({})));
|
||||
const onEnqueue = vi.fn();
|
||||
const cf = createCoreFetch({
|
||||
syncTables: new Set(['bookings']),
|
||||
onEnqueue,
|
||||
fetchImpl: fetchImpl as unknown as typeof fetch,
|
||||
});
|
||||
await cf('/api/bookings');
|
||||
expect(onEnqueue).not.toHaveBeenCalled();
|
||||
expect(fetchImpl).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
|
||||
describe('createCoreFetch — request shape', () => {
|
||||
it('always sends X-Requested-With and credentials: include', async () => {
|
||||
let captured: { url: string; init: RequestInit } | null = null;
|
||||
const cf = createCoreFetch({
|
||||
fetchImpl: ((url: string, init: RequestInit) => {
|
||||
captured = { url, init };
|
||||
return Promise.resolve(jsonResponse({}));
|
||||
}) as unknown as typeof fetch,
|
||||
});
|
||||
await cf('/x');
|
||||
expect(captured!.init.credentials).toBe('include');
|
||||
const h = captured!.init.headers as Headers;
|
||||
expect(h.get('X-Requested-With')).toBe('XMLHttpRequest');
|
||||
});
|
||||
|
||||
it('baseUrl is prepended and trailing slash stripped', async () => {
|
||||
let captured = '';
|
||||
const cf = createCoreFetch({
|
||||
baseUrl: 'https://api.example.com/',
|
||||
fetchImpl: ((url: string) => {
|
||||
captured = url;
|
||||
return Promise.resolve(jsonResponse({}));
|
||||
}) as unknown as typeof fetch,
|
||||
});
|
||||
await cf('/v1/x');
|
||||
expect(captured).toBe('https://api.example.com/v1/x');
|
||||
});
|
||||
|
||||
it('baseUrl getter is resolved at request time, not factory time', async () => {
|
||||
const captured: string[] = [];
|
||||
let dynamicBase = 'https://first.example.com';
|
||||
const cf = createCoreFetch({
|
||||
baseUrl: () => dynamicBase,
|
||||
fetchImpl: ((url: string) => {
|
||||
captured.push(url);
|
||||
return Promise.resolve(jsonResponse({}));
|
||||
}) as unknown as typeof fetch,
|
||||
});
|
||||
await cf('/v1/x');
|
||||
dynamicBase = 'https://second.example.com/';
|
||||
await cf('/v1/y');
|
||||
expect(captured).toEqual([
|
||||
'https://first.example.com/v1/x',
|
||||
'https://second.example.com/v1/y',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
|
@ -3,10 +3,12 @@
|
|||
// Orval's `client: 'fetch'` emits functions that call
|
||||
// coreFetch<T>(url, init)
|
||||
// and expect one of:
|
||||
// • { data, status, headers } back (responseShape: 'wrapped', default —
|
||||
// matches orval's default `includeHttpResponseReturnType: true`)
|
||||
// • the parsed body directly (responseShape: 'body' — matches orval's
|
||||
// `includeHttpResponseReturnType: false`, fewo's convention)
|
||||
// • the parsed body directly (responseShape: 'body', DEFAULT — matches
|
||||
// orval's `includeHttpResponseReturnType: false`, the Orval-driven
|
||||
// "no extra ceremony" path)
|
||||
// • { data, status, headers } back (responseShape: 'wrapped' — matches
|
||||
// orval's `includeHttpResponseReturnType: true`; opt in when callers
|
||||
// need the Response metadata)
|
||||
//
|
||||
// Baseline wired in:
|
||||
// • credentials: 'include' — cookies always sent
|
||||
|
|
@ -26,10 +28,18 @@ export interface CoreFetchRequest {
|
|||
}
|
||||
|
||||
export interface CoreFetchOptions {
|
||||
/** Prefix for every request; strip trailing slash. */
|
||||
baseUrl?: string;
|
||||
/**
|
||||
* Prefix for every request; trailing slash stripped at resolve time.
|
||||
*
|
||||
* Accepts a string OR a getter so consumers that derive the base from
|
||||
* `import.meta.env.BASE_URL` (or any other late-bound source) can pass
|
||||
* the getter form. The getter is invoked at request time, never at
|
||||
* factory-construction time — which keeps orval's CJS bundling of the
|
||||
* mutator file from blowing up on top-level `import.meta` references.
|
||||
*/
|
||||
baseUrl?: string | (() => string);
|
||||
|
||||
/** `'wrapped'` returns { data, status, headers } (default); `'body'` returns the parsed body. */
|
||||
/** `'body'` returns the parsed body (default); `'wrapped'` returns { data, status, headers }. */
|
||||
responseShape?: ResponseShape;
|
||||
|
||||
/**
|
||||
|
|
@ -95,9 +105,17 @@ export type CoreFetch = CoreFetchWrapped & CoreFetchBody;
|
|||
const MUTATING = new Set(['POST', 'PUT', 'PATCH', 'DELETE']);
|
||||
|
||||
export function createCoreFetch(opts: CoreFetchOptions = {}): CoreFetch {
|
||||
const base = (opts.baseUrl ?? '').replace(/\/$/, '');
|
||||
const fetchImpl = opts.fetchImpl ?? fetch;
|
||||
const shape: ResponseShape = opts.responseShape ?? 'wrapped';
|
||||
// Lazy base resolution — see CoreFetchOptions.baseUrl JSDoc.
|
||||
const resolveBase = (): string => {
|
||||
const raw = typeof opts.baseUrl === 'function' ? opts.baseUrl() : (opts.baseUrl ?? '');
|
||||
return raw.replace(/\/$/, '');
|
||||
};
|
||||
// Resolve fetch at call time, not construction time, so tests that
|
||||
// `vi.stubGlobal('fetch', ...)` after module import see the stub.
|
||||
const callFetch: typeof fetch = opts.fetchImpl
|
||||
? opts.fetchImpl
|
||||
: ((...args) => fetch(...(args as Parameters<typeof fetch>))) as typeof fetch;
|
||||
const shape: ResponseShape = opts.responseShape ?? 'body';
|
||||
|
||||
function wrap(data: unknown, status: number, headers: Headers): unknown {
|
||||
return shape === 'body' ? data : { data, status, headers };
|
||||
|
|
@ -129,7 +147,7 @@ export function createCoreFetch(opts: CoreFetchOptions = {}): CoreFetch {
|
|||
|
||||
let r: Response;
|
||||
try {
|
||||
r = await fetchImpl(`${base}${url}`, {
|
||||
r = await callFetch(`${resolveBase()}${url}`, {
|
||||
credentials: 'include',
|
||||
...init,
|
||||
headers,
|
||||
|
|
@ -145,24 +163,25 @@ export function createCoreFetch(opts: CoreFetchOptions = {}): CoreFetch {
|
|||
}
|
||||
|
||||
if (r.status === 401) opts.on401?.(req);
|
||||
if (r.status === 409 && opts.on409) {
|
||||
const body = await r.text();
|
||||
const resolved = opts.on409(req, body);
|
||||
if (resolved !== undefined) {
|
||||
return wrap(resolved, r.status, r.headers) as T;
|
||||
if (r.status === 409) {
|
||||
const [text, json] = await readBody(r);
|
||||
if (opts.on409) {
|
||||
const resolved = opts.on409(req, text);
|
||||
if (resolved !== undefined) {
|
||||
return wrap(resolved, r.status, r.headers) as T;
|
||||
}
|
||||
}
|
||||
// Fall through to error-formatting path below.
|
||||
const bodyJson = safeJson(body);
|
||||
const msg = opts.formatError?.(req, r, body, bodyJson)
|
||||
?? `${r.status} ${r.statusText}: ${body}`;
|
||||
throw decorate409(new Error(msg), bodyJson);
|
||||
const msg = opts.formatError?.(req, r, text, json)
|
||||
?? (json && typeof (json as Record<string, unknown>).message === 'string'
|
||||
? String((json as Record<string, unknown>).message)
|
||||
: `${r.status} ${r.statusText}: ${text}`);
|
||||
throw decorate409(new Error(msg), json);
|
||||
}
|
||||
|
||||
if (!r.ok) {
|
||||
const body = await r.text();
|
||||
const bodyJson = safeJson(body);
|
||||
const msg = opts.formatError?.(req, r, body, bodyJson)
|
||||
?? `${r.status} ${r.statusText}: ${body}`;
|
||||
const [text, json] = await readBody(r);
|
||||
const msg = opts.formatError?.(req, r, text, json)
|
||||
?? `${r.status} ${r.statusText}: ${text}`;
|
||||
throw new Error(msg);
|
||||
}
|
||||
|
||||
|
|
@ -179,6 +198,28 @@ function safeJson(text: string): unknown | null {
|
|||
try { return JSON.parse(text); } catch { return null; }
|
||||
}
|
||||
|
||||
/**
|
||||
* Read an error-response body. Prefer `.json()` (tests commonly mock only
|
||||
* that); fall back to `.text()` if json() throws. Returns both the raw
|
||||
* text (for `formatError` callers that want to log it) and the parsed
|
||||
* JSON (may be null when the body isn't parseable).
|
||||
*/
|
||||
async function readBody(r: Response): Promise<[string, unknown | null]> {
|
||||
// Some mocks expose .json() but not .clone() (e.g. vitest object mocks).
|
||||
// Try .json() directly; if it throws, fall back to .text().
|
||||
try {
|
||||
const j = await r.json();
|
||||
return [JSON.stringify(j), j];
|
||||
} catch {
|
||||
try {
|
||||
const text = await r.text();
|
||||
return [text, safeJson(text)];
|
||||
} catch {
|
||||
return ['', null];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Attach status/body to 409 errors so conflict-resolvers can reach them. */
|
||||
function decorate409(err: Error, body: unknown): Error {
|
||||
(err as Error & { status?: number; body?: unknown }).status = 409;
|
||||
|
|
|
|||
31
src/i18n-react.test.tsx
Normal file
31
src/i18n-react.test.tsx
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
// Vitest suite for useI18nStore (#4) — confirms the React binding re-renders
|
||||
// on locale change. Kept minimal: the React peer is optional, so this is the
|
||||
// only file that pulls in @testing-library/react.
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { act, render, screen } from '@testing-library/react';
|
||||
import { createI18n } from './i18n.js';
|
||||
import { useI18nStore } from './i18n-react.js';
|
||||
|
||||
interface B extends Record<string, string> {
|
||||
hello: string;
|
||||
}
|
||||
|
||||
const bundles = {
|
||||
'de': { hello: 'Hallo' } as Partial<B>,
|
||||
'en': { hello: 'Hello' } as Partial<B>,
|
||||
};
|
||||
|
||||
describe('useI18nStore', () => {
|
||||
it('re-renders when locale changes', () => {
|
||||
const i = createI18n<B>({ bundles, defaultLocale: 'de' });
|
||||
const Comp = () => {
|
||||
const store = useI18nStore(i);
|
||||
return <span data-testid="t">{store.t('hello')}</span>;
|
||||
};
|
||||
render(<Comp />);
|
||||
expect(screen.getByTestId('t').textContent).toBe('Hallo');
|
||||
act(() => { i.setLocale('en'); });
|
||||
expect(screen.getByTestId('t').textContent).toBe('Hello');
|
||||
});
|
||||
});
|
||||
96
src/i18n.test.ts
Normal file
96
src/i18n.test.ts
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
// Vitest suite for createI18n (#4).
|
||||
//
|
||||
// Pins the resolver chain (`<locale>-<tone>` → `<locale>` → default), the
|
||||
// once-per-key onMiss firing, and the subscribe/notify behaviour.
|
||||
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { createI18n } from './i18n.js';
|
||||
|
||||
interface B extends Record<string, string> {
|
||||
hello: string;
|
||||
bye: string;
|
||||
}
|
||||
|
||||
const bundles = {
|
||||
'de': { hello: 'Hallo', bye: 'Tschüss' } as Partial<B>,
|
||||
'de-informal': { hello: 'Hi' } as Partial<B>,
|
||||
'en': { hello: 'Hello' } as Partial<B>,
|
||||
};
|
||||
|
||||
describe('createI18n — resolver chain', () => {
|
||||
it('hits <locale>-<tone> first', () => {
|
||||
const i = createI18n<B>({ bundles, defaultLocale: 'de', initialLocale: 'de', initialTone: 'informal' });
|
||||
expect(i.t('hello')).toBe('Hi');
|
||||
});
|
||||
|
||||
it('falls through tone to <locale> when key missing in tone bundle', () => {
|
||||
// 'bye' is only in 'de'; informal asks for 'de-informal' first, falls back to 'de'.
|
||||
const i = createI18n<B>({ bundles, defaultLocale: 'de', initialLocale: 'de', initialTone: 'informal' });
|
||||
expect(i.t('bye')).toBe('Tschüss');
|
||||
});
|
||||
|
||||
it('falls through locale to default when current locale has no entry', () => {
|
||||
// 'bye' missing in en → falls back to default 'de'.
|
||||
const i = createI18n<B>({ bundles, defaultLocale: 'de', initialLocale: 'en' });
|
||||
expect(i.t('bye')).toBe('Tschüss');
|
||||
});
|
||||
|
||||
it('returns the raw key when nothing resolves', () => {
|
||||
const i = createI18n<B>({ bundles: {}, defaultLocale: 'de' });
|
||||
expect(i.t('hello')).toBe('hello');
|
||||
});
|
||||
});
|
||||
|
||||
describe('createI18n — onMiss', () => {
|
||||
it('fires once per (key, locale, tone) triple', () => {
|
||||
const onMiss = vi.fn();
|
||||
const i = createI18n<B>({ bundles: {}, defaultLocale: 'de', onMiss });
|
||||
i.t('hello');
|
||||
i.t('hello');
|
||||
i.t('hello');
|
||||
expect(onMiss).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('fires again after locale flip', () => {
|
||||
const onMiss = vi.fn();
|
||||
const i = createI18n<B>({ bundles: {}, defaultLocale: 'de', onMiss });
|
||||
i.t('hello');
|
||||
i.setLocale('en');
|
||||
i.t('hello');
|
||||
expect(onMiss).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createI18n — subscribe/notify', () => {
|
||||
it('setLocale notifies subscribers exactly once', () => {
|
||||
const i = createI18n<B>({ bundles, defaultLocale: 'de' });
|
||||
const fn = vi.fn();
|
||||
i.subscribe(fn);
|
||||
i.setLocale('en');
|
||||
expect(fn).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('setLocale to same value does not notify', () => {
|
||||
const i = createI18n<B>({ bundles, defaultLocale: 'de' });
|
||||
const fn = vi.fn();
|
||||
i.subscribe(fn);
|
||||
i.setLocale('de');
|
||||
expect(fn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('unsubscribe stops further notifications', () => {
|
||||
const i = createI18n<B>({ bundles, defaultLocale: 'de' });
|
||||
const fn = vi.fn();
|
||||
const unsub = i.subscribe(fn);
|
||||
unsub();
|
||||
i.setLocale('en');
|
||||
expect(fn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('getSnapshot reflects the current <locale>-<tone>', () => {
|
||||
const i = createI18n<B>({ bundles, defaultLocale: 'de' });
|
||||
expect(i.getSnapshot()).toBe('de-formal');
|
||||
i.setTone('informal');
|
||||
expect(i.getSnapshot()).toBe('de-informal');
|
||||
});
|
||||
});
|
||||
|
|
@ -6,8 +6,15 @@
|
|||
// Both honour the same Apache-proxy-prefix baseUrl convention and keep
|
||||
// their outputs under a conventional path so the `StaticController` and
|
||||
// Apache vhost can serve them without per-project tweaks.
|
||||
//
|
||||
// VITE_BASE resolution: Vite does NOT populate `process.env.VITE_BASE` from
|
||||
// .env files at config-evaluation time (those go into `import.meta.env` for
|
||||
// the client bundle only). To pick up the value `new-project.sh` writes to
|
||||
// `frontend/.env.production`, we use Vite's own `loadEnv()` helper. A
|
||||
// `process.env.VITE_BASE` override still wins so CI invocations that
|
||||
// `export VITE_BASE=…` keep working.
|
||||
|
||||
import type { UserConfig } from 'vite';
|
||||
import { defineConfig, loadEnv, type UserConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
import path from 'path';
|
||||
|
||||
|
|
@ -20,10 +27,15 @@ export interface AdminConfigOptions {
|
|||
outDir?: string;
|
||||
}
|
||||
|
||||
export function defineAdminConfig(opts: AdminConfigOptions): UserConfig {
|
||||
const base = process.env.VITE_BASE || '/';
|
||||
return {
|
||||
base,
|
||||
function resolveBase(mode: string, root: string): string {
|
||||
if (process.env.VITE_BASE) return process.env.VITE_BASE;
|
||||
const env = loadEnv(mode, root, '');
|
||||
return env.VITE_BASE || '/';
|
||||
}
|
||||
|
||||
export function defineAdminConfig(opts: AdminConfigOptions) {
|
||||
return defineConfig(({ mode }): UserConfig => ({
|
||||
base: resolveBase(mode, opts.root),
|
||||
plugins: [react()],
|
||||
build: {
|
||||
outDir: opts.outDir ?? path.resolve(opts.root, '../static/dist'),
|
||||
|
|
@ -33,7 +45,7 @@ export function defineAdminConfig(opts: AdminConfigOptions): UserConfig {
|
|||
? { output: { manualChunks: opts.vendorChunks } }
|
||||
: undefined,
|
||||
},
|
||||
};
|
||||
}));
|
||||
}
|
||||
|
||||
export interface GuestConfigOptions extends AdminConfigOptions {
|
||||
|
|
@ -41,10 +53,9 @@ export interface GuestConfigOptions extends AdminConfigOptions {
|
|||
entry?: string;
|
||||
}
|
||||
|
||||
export function defineGuestConfig(opts: GuestConfigOptions): UserConfig {
|
||||
const base = process.env.VITE_BASE || '/';
|
||||
return {
|
||||
base,
|
||||
export function defineGuestConfig(opts: GuestConfigOptions) {
|
||||
return defineConfig(({ mode }): UserConfig => ({
|
||||
base: resolveBase(mode, opts.root),
|
||||
plugins: [react()],
|
||||
build: {
|
||||
outDir: opts.outDir ?? path.resolve(opts.root, '../static/guest/dist'),
|
||||
|
|
@ -60,5 +71,5 @@ export function defineGuestConfig(opts: GuestConfigOptions): UserConfig {
|
|||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}));
|
||||
}
|
||||
|
|
|
|||
0
tests/e2e/__init__.py
Normal file
0
tests/e2e/__init__.py
Normal file
165
tests/e2e/conftest.py
Normal file
165
tests/e2e/conftest.py
Normal file
|
|
@ -0,0 +1,165 @@
|
|||
"""
|
||||
Fixture chain for the e2e password-setup test (issue #7, Option A1 — scoped).
|
||||
|
||||
Skips the host-provisioning side of `new-project.sh` (root, systemd, Apache,
|
||||
Forgejo). Instead clones webapp-template into a tmp dir, builds it with
|
||||
`VITE_BASE` pinned to a synthetic prefix, boots the binary on an ephemeral
|
||||
port, and fronts it with the in-process PrefixStrippingProxy. The result
|
||||
exercises the same contract that broke twice in production: the build
|
||||
pipeline + the reverse-proxy prefix + the set-password flow.
|
||||
|
||||
Required environment / tooling on the test host:
|
||||
- `WEBAPP_TEMPLATE_DIR` env var → path to a webapp-template source tree
|
||||
(defaults to /home/git/webapp-template).
|
||||
- cmake + make + a C++ toolchain.
|
||||
- node + npm.
|
||||
- A built webapp binary appears at `<build>/webapp`.
|
||||
|
||||
The build is cached per pytest session in `tmp_path_factory`'s root, so a
|
||||
warm rerun is fast. To skip the build entirely, set
|
||||
`WEBAPP_TEMPLATE_BUILD_DIR=/path/to/prebuilt-build` (must contain `webapp`
|
||||
and `static/dist/index.html` with `/projects/tmp-foo/` baked into asset
|
||||
URLs).
|
||||
"""
|
||||
|
||||
import os
|
||||
import shutil
|
||||
import socket
|
||||
import subprocess
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Iterator
|
||||
|
||||
import pytest
|
||||
|
||||
from .proxy import PrefixStrippingProxy
|
||||
|
||||
PREFIX = "/projects/tmp-foo"
|
||||
TEMPLATE_DIR_DEFAULT = "/home/git/webapp-template"
|
||||
|
||||
|
||||
def _free_port() -> int:
|
||||
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
||||
s.bind(("127.0.0.1", 0))
|
||||
return s.getsockname()[1]
|
||||
|
||||
|
||||
def _wait_port(port: int, timeout: float = 30.0) -> None:
|
||||
deadline = time.monotonic() + timeout
|
||||
while time.monotonic() < deadline:
|
||||
try:
|
||||
with socket.create_connection(("127.0.0.1", port), timeout=0.5):
|
||||
return
|
||||
except OSError:
|
||||
time.sleep(0.1)
|
||||
raise RuntimeError(f"port {port} did not open within {timeout}s")
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def webapp_template_src() -> Path:
|
||||
src = Path(os.environ.get("WEBAPP_TEMPLATE_DIR", TEMPLATE_DIR_DEFAULT))
|
||||
if not (src / "CMakeLists.txt").exists():
|
||||
pytest.skip(f"webapp-template source not found at {src}")
|
||||
return src
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def built_webapp(webapp_template_src: Path, tmp_path_factory) -> Path:
|
||||
"""Returns a build directory containing `webapp` and a Vite bundle whose
|
||||
asset URLs include the PREFIX. Honours WEBAPP_TEMPLATE_BUILD_DIR to skip
|
||||
the build step entirely (CI provides a pre-built tree)."""
|
||||
pre = os.environ.get("WEBAPP_TEMPLATE_BUILD_DIR")
|
||||
if pre:
|
||||
return Path(pre)
|
||||
|
||||
work = tmp_path_factory.mktemp("webapp-template-build")
|
||||
# Shallow copy: source files only, skip node_modules + build/ if present.
|
||||
for entry in webapp_template_src.iterdir():
|
||||
if entry.name in {"build", "node_modules", ".git"}:
|
||||
continue
|
||||
dest = work / entry.name
|
||||
if entry.is_dir():
|
||||
shutil.copytree(entry, dest, symlinks=True,
|
||||
ignore=shutil.ignore_patterns("node_modules", "build"))
|
||||
else:
|
||||
shutil.copy2(entry, dest)
|
||||
|
||||
# Pin VITE_BASE for the production build (matches what new-project.sh
|
||||
# would write into a deployed project).
|
||||
(work / "frontend").mkdir(exist_ok=True)
|
||||
(work / "frontend" / ".env.production").write_text(f"VITE_BASE={PREFIX}/\n")
|
||||
|
||||
build_dir = work / "build"
|
||||
build_dir.mkdir(exist_ok=True)
|
||||
|
||||
env = {**os.environ, "VITE_BASE": f"{PREFIX}/"}
|
||||
subprocess.run(
|
||||
["cmake", "-DCMAKE_BUILD_TYPE=Release", ".."],
|
||||
cwd=build_dir, env=env, check=True,
|
||||
stdout=subprocess.DEVNULL, stderr=subprocess.STDOUT,
|
||||
)
|
||||
subprocess.run(
|
||||
["make", "-j2"],
|
||||
cwd=build_dir, env=env, check=True,
|
||||
)
|
||||
return build_dir
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def boot_app(built_webapp: Path, tmp_path: Path) -> Iterator[dict]:
|
||||
"""Spawns the built webapp binary on an ephemeral port. Returns
|
||||
{port, data_dir, db_path, proc}. Tears down on test exit."""
|
||||
binary = built_webapp / "webapp"
|
||||
if not binary.exists():
|
||||
pytest.skip(f"webapp binary not at {binary}")
|
||||
|
||||
port = _free_port()
|
||||
data_dir = tmp_path / "data"
|
||||
data_dir.mkdir()
|
||||
db_path = tmp_path / "app.sqlite"
|
||||
log = open(tmp_path / "webapp.log", "wb")
|
||||
|
||||
env = {**os.environ, "FEWO_ENCRYPTION_KEY": "x" * 64}
|
||||
proc = subprocess.Popen(
|
||||
[str(binary),
|
||||
"--data-dir", str(built_webapp / ".." / "static"), # served from source tree
|
||||
"--db", str(db_path),
|
||||
"--port", str(port)],
|
||||
env=env, stdout=log, stderr=subprocess.STDOUT,
|
||||
)
|
||||
try:
|
||||
_wait_port(port)
|
||||
yield {"port": port, "data_dir": data_dir, "db_path": db_path, "proc": proc,
|
||||
"binary": binary}
|
||||
finally:
|
||||
proc.terminate()
|
||||
try: proc.wait(timeout=5)
|
||||
except subprocess.TimeoutExpired: proc.kill()
|
||||
log.close()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def proxy(boot_app: dict) -> Iterator[dict]:
|
||||
"""Spawns the prefix-stripping proxy in front of the booted app."""
|
||||
p = PrefixStrippingProxy("127.0.0.1", boot_app["port"], PREFIX)
|
||||
p.start()
|
||||
try:
|
||||
yield {"port": p.port, "prefix": PREFIX, "base_url": f"http://127.0.0.1:{p.port}{PREFIX}"}
|
||||
finally:
|
||||
p.stop()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def admin_token(boot_app: dict) -> str:
|
||||
"""Issues a one-shot password-setup token via the binary's CLI mode."""
|
||||
out = subprocess.run(
|
||||
[str(boot_app["binary"]),
|
||||
"--db", str(boot_app["db_path"]),
|
||||
"--issue-admin-reset", "tester"],
|
||||
capture_output=True, text=True, check=True,
|
||||
)
|
||||
# `--issue-admin-reset` prints the raw token on stdout (last line).
|
||||
token = out.stdout.strip().splitlines()[-1].strip()
|
||||
if not token or len(token) < 16:
|
||||
raise RuntimeError(f"unexpected --issue-admin-reset output: {out.stdout!r}")
|
||||
return token
|
||||
103
tests/e2e/proxy.py
Normal file
103
tests/e2e/proxy.py
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
"""
|
||||
PrefixStrippingProxy — minimal in-process HTTP reverse proxy for e2e tests.
|
||||
|
||||
Mirrors the production Apache vhost contract (see `templates/projects-*.conf`):
|
||||
|
||||
GET /projects/<name>/foo → GET /foo with `X-Forwarded-Prefix: /projects/<name>`
|
||||
|
||||
Used by the e2e password-setup test to exercise the same path-rewriting
|
||||
flow that broke twice in production (oatpp-authkit query-string 401, and
|
||||
the VITE_BASE blank-page bug). Plain stdlib only — no third-party deps.
|
||||
"""
|
||||
|
||||
import threading
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
from typing import Optional
|
||||
|
||||
_HOP_BY_HOP = {
|
||||
"connection", "keep-alive", "proxy-authenticate", "proxy-authorization",
|
||||
"te", "trailers", "transfer-encoding", "upgrade", "content-length", "host",
|
||||
}
|
||||
|
||||
|
||||
class PrefixStrippingProxy:
|
||||
"""Forwards every request from `:proxy_port/<prefix>/...` to the backend.
|
||||
|
||||
Backend sees the un-prefixed path plus an `X-Forwarded-Prefix` header,
|
||||
matching what Apache's `ProxyPass /projects/<name>/ http://127.0.0.1:N/`
|
||||
does in production.
|
||||
"""
|
||||
|
||||
def __init__(self, target_host: str, target_port: int, prefix: str):
|
||||
self.target = f"http://{target_host}:{target_port}"
|
||||
self.prefix = "/" + prefix.strip("/")
|
||||
self.server: Optional[ThreadingHTTPServer] = None
|
||||
self.thread: Optional[threading.Thread] = None
|
||||
self.port: Optional[int] = None
|
||||
|
||||
def start(self) -> int:
|
||||
proxy = self
|
||||
|
||||
class Handler(BaseHTTPRequestHandler):
|
||||
def do_GET(self): proxy._forward(self)
|
||||
def do_POST(self): proxy._forward(self)
|
||||
def do_PUT(self): proxy._forward(self)
|
||||
def do_DELETE(self): proxy._forward(self)
|
||||
def do_PATCH(self): proxy._forward(self)
|
||||
def log_message(self, *a, **kw): pass
|
||||
|
||||
self.server = ThreadingHTTPServer(("127.0.0.1", 0), Handler)
|
||||
self.port = self.server.server_address[1]
|
||||
self.thread = threading.Thread(target=self.server.serve_forever, daemon=True)
|
||||
self.thread.start()
|
||||
return self.port
|
||||
|
||||
def stop(self) -> None:
|
||||
if self.server:
|
||||
self.server.shutdown()
|
||||
self.server.server_close()
|
||||
|
||||
def _strip_prefix(self, path: str) -> str:
|
||||
if path.startswith(self.prefix + "/"):
|
||||
return path[len(self.prefix):]
|
||||
if path == self.prefix:
|
||||
return "/"
|
||||
return path
|
||||
|
||||
def _forward(self, h: BaseHTTPRequestHandler) -> None:
|
||||
upstream_path = self._strip_prefix(h.path)
|
||||
body_len = int(h.headers.get("Content-Length") or 0)
|
||||
body = h.rfile.read(body_len) if body_len else None
|
||||
req = urllib.request.Request(self.target + upstream_path, method=h.command, data=body)
|
||||
for k, v in h.headers.items():
|
||||
if k.lower() in _HOP_BY_HOP:
|
||||
continue
|
||||
req.add_header(k, v)
|
||||
req.add_header("X-Forwarded-Prefix", self.prefix)
|
||||
req.add_header("X-Forwarded-Host", h.headers.get("Host", "127.0.0.1"))
|
||||
req.add_header("X-Forwarded-Proto", "http")
|
||||
|
||||
try:
|
||||
r = urllib.request.urlopen(req, timeout=15)
|
||||
status, headers, content = r.status, r.headers, r.read()
|
||||
except urllib.error.HTTPError as e:
|
||||
status, headers, content = e.code, e.headers, e.read()
|
||||
except Exception as exc:
|
||||
h.send_response(502)
|
||||
msg = f"proxy upstream error: {exc}".encode()
|
||||
h.send_header("Content-Type", "text/plain; charset=utf-8")
|
||||
h.send_header("Content-Length", str(len(msg)))
|
||||
h.end_headers()
|
||||
h.wfile.write(msg)
|
||||
return
|
||||
|
||||
h.send_response(status)
|
||||
for k, v in headers.items():
|
||||
if k.lower() in _HOP_BY_HOP:
|
||||
continue
|
||||
h.send_header(k, v)
|
||||
h.send_header("Content-Length", str(len(content)))
|
||||
h.end_headers()
|
||||
h.wfile.write(content)
|
||||
87
tests/e2e/test_password_setup.py
Normal file
87
tests/e2e/test_password_setup.py
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
"""
|
||||
End-to-end test for the initial password-setup flow on a scaffolded project
|
||||
(webapp-scaffold #7, Option A1).
|
||||
|
||||
Reproduces the contract that broke twice in production:
|
||||
1. oatpp-authkit AuthInterceptor rejected `/set-password?token=…` with 401
|
||||
because the public-path check compared against the request-target
|
||||
(which includes the query string). v0.3.3 / commit 46971ac.
|
||||
2. Newly-scaffolded projects shipped with `VITE_BASE='/'` so SPA assets
|
||||
404'd behind the `/projects/<name>/` Apache prefix → blank page.
|
||||
webapp-scaffold v0.3.6 / commit b1a13b8.
|
||||
|
||||
Both regressions slipped past the existing unit/component test layers
|
||||
because none of them exercises the *deployed* shape of a scaffolded
|
||||
project. The fixtures in `conftest.py` recreate that shape inline:
|
||||
clone webapp-template, build with VITE_BASE pinned, boot the binary,
|
||||
front it with the in-process PrefixStrippingProxy, follow the email
|
||||
link.
|
||||
"""
|
||||
|
||||
import re
|
||||
import urllib.request
|
||||
import urllib.error
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
def _http_get(url: str, *, headers=None) -> tuple[int, str, dict]:
|
||||
req = urllib.request.Request(url, headers=headers or {"Accept": "text/html"})
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=10) as r:
|
||||
return r.status, r.read().decode("utf-8", "replace"), dict(r.headers)
|
||||
except urllib.error.HTTPError as e:
|
||||
return e.code, e.read().decode("utf-8", "replace"), dict(e.headers)
|
||||
|
||||
|
||||
def test_set_password_link_returns_html(proxy, admin_token):
|
||||
"""Regression for oatpp-authkit query-string 401: the link must serve
|
||||
the SPA HTML, not a JSON 401."""
|
||||
url = f"{proxy['base_url']}/set-password?token={admin_token}"
|
||||
status, body, headers = _http_get(url)
|
||||
assert status == 200, f"got {status}: {body[:200]}"
|
||||
assert "text/html" in headers.get("Content-Type", "").lower(), headers
|
||||
# The SPA shell loads the bundle; the form itself is rendered by JS,
|
||||
# so we just assert the shell is well-formed.
|
||||
assert "<html" in body.lower() or "<!doctype" in body.lower(), body[:200]
|
||||
|
||||
|
||||
def test_assets_resolve_through_prefix(proxy, admin_token):
|
||||
"""Regression for VITE_BASE blank-page bug: every asset URL referenced
|
||||
by the served HTML must resolve under the prefix."""
|
||||
url = f"{proxy['base_url']}/set-password?token={admin_token}"
|
||||
_, body, _ = _http_get(url)
|
||||
|
||||
asset_urls = re.findall(
|
||||
r'<(?:script|link)[^>]*\b(?:src|href)\s*=\s*["\']([^"\']+)["\']',
|
||||
body, re.IGNORECASE,
|
||||
)
|
||||
# Only check absolute-path asset URLs (skip cross-origin, data:, etc.).
|
||||
local = [u for u in asset_urls if u.startswith("/")]
|
||||
assert local, f"no local asset URLs found in HTML: {body[:300]}"
|
||||
|
||||
failures = []
|
||||
for path in local:
|
||||
# The asset URL is browser-side — already includes the proxy prefix.
|
||||
# Fetch it through the proxy so we exercise the same path the
|
||||
# browser would.
|
||||
full = f"http://127.0.0.1:{proxy['port']}{path}"
|
||||
s, _, _ = _http_get(full)
|
||||
if s != 200:
|
||||
failures.append(f"{path} → {s}")
|
||||
|
||||
assert not failures, "asset URLs failed to resolve through the prefix:\n " \
|
||||
+ "\n ".join(failures)
|
||||
|
||||
|
||||
def test_api_path_still_returns_json_401(proxy):
|
||||
"""Sanity check the content-negotiation: an /api/ call without a token
|
||||
must still get JSON 401 (browser navigation gets HTML, but API stays JSON)."""
|
||||
full = f"http://127.0.0.1:{proxy['port']}{proxy['prefix']}/api/users"
|
||||
req = urllib.request.Request(full, headers={"Accept": "application/json"})
|
||||
try:
|
||||
urllib.request.urlopen(req, timeout=5)
|
||||
pytest.fail("expected 401 from unauth /api/ call")
|
||||
except urllib.error.HTTPError as e:
|
||||
assert e.code == 401
|
||||
assert "application/json" in e.headers.get("Content-Type", "").lower()
|
||||
77
tests/test_inject_hashed_filenames.py
Normal file
77
tests/test_inject_hashed_filenames.py
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
"""Tests for bin/inject-hashed-filenames.py rewrite() (#3).
|
||||
|
||||
Pinned behaviour: the rewrite is tag-aware — only `<script src="…">` and
|
||||
`<link href="…">` attribute values are replaced. Inert occurrences of the
|
||||
old src in HTML comments, JSON literals, or unrelated attributes must be
|
||||
left alone (the previous `html.replace` was substring-blind).
|
||||
"""
|
||||
import importlib.util
|
||||
import os
|
||||
import sys
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
SCRIPT = os.path.join(os.path.dirname(HERE), "bin", "inject-hashed-filenames.py")
|
||||
|
||||
spec = importlib.util.spec_from_file_location("ihf", SCRIPT)
|
||||
ihf = importlib.util.module_from_spec(spec)
|
||||
sys.modules["ihf"] = ihf
|
||||
spec.loader.exec_module(ihf)
|
||||
|
||||
|
||||
def test_rewrites_script_src_double_quoted():
|
||||
html = '<script src="/static/dist/app.js"></script>'
|
||||
out, n = ihf.rewrite(html, "/static/dist/app.js", "/static/dist/app.abc123.js")
|
||||
assert n == 1
|
||||
assert out == '<script src="/static/dist/app.abc123.js"></script>'
|
||||
|
||||
|
||||
def test_rewrites_script_src_single_quoted():
|
||||
html = "<script src='/static/dist/app.js'></script>"
|
||||
out, n = ihf.rewrite(html, "/static/dist/app.js", "/static/dist/app.abc123.js")
|
||||
assert n == 1
|
||||
assert "/static/dist/app.abc123.js" in out
|
||||
|
||||
|
||||
def test_rewrites_link_href():
|
||||
html = '<link rel="stylesheet" href="/static/dist/app.css">'
|
||||
out, n = ihf.rewrite(html, "/static/dist/app.css", "/static/dist/app.abc123.css")
|
||||
assert n == 1
|
||||
assert '/static/dist/app.abc123.css' in out
|
||||
|
||||
|
||||
def test_does_not_rewrite_inside_html_comment():
|
||||
html = '<!-- old script was at /static/dist/app.js --><script src="/other.js"></script>'
|
||||
out, n = ihf.rewrite(html, "/static/dist/app.js", "/static/dist/app.abc123.js")
|
||||
assert n == 0
|
||||
assert "/static/dist/app.js" in out
|
||||
assert "/static/dist/app.abc123.js" not in out
|
||||
|
||||
|
||||
def test_does_not_rewrite_inside_json_literal():
|
||||
html = '<pre>{ "src": "/static/dist/app.js" }</pre>'
|
||||
out, n = ihf.rewrite(html, "/static/dist/app.js", "/static/dist/app.abc123.js")
|
||||
assert n == 0
|
||||
assert out == html
|
||||
|
||||
|
||||
def test_does_not_rewrite_unrelated_attribute():
|
||||
html = '<img data-bundle="/static/dist/app.js">'
|
||||
out, n = ihf.rewrite(html, "/static/dist/app.js", "/static/dist/app.abc123.js")
|
||||
assert n == 0
|
||||
assert out == html
|
||||
|
||||
|
||||
def test_does_not_rewrite_anchor_href():
|
||||
# Even though <a href="…"> is a `href` attribute, it isn't a <link>.
|
||||
html = '<a href="/static/dist/app.js">debug link</a>'
|
||||
out, n = ihf.rewrite(html, "/static/dist/app.js", "/static/dist/app.abc123.js")
|
||||
assert n == 0
|
||||
assert out == html
|
||||
|
||||
|
||||
def test_rewrites_with_extra_attributes_around_src():
|
||||
html = '<script type="module" src="/static/dist/app.js" defer></script>'
|
||||
out, n = ihf.rewrite(html, "/static/dist/app.js", "/static/dist/app.abc123.js")
|
||||
assert n == 1
|
||||
assert '/static/dist/app.abc123.js' in out
|
||||
assert 'type="module"' in out and 'defer' in out
|
||||
9
vitest.config.ts
Normal file
9
vitest.config.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
environment: 'jsdom',
|
||||
globals: false,
|
||||
include: ['src/**/*.test.ts', 'src/**/*.test.tsx'],
|
||||
},
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue