修复bug
This commit is contained in:
1 parent
fc9bbc20e4
commit
e87c4ee6a8
90 files changed
+3828
-452
No files matched your search
@@ -72,10 +72,12 @@ import ChuSQL.Interface.Link (
|
||||
closeClient,
|
||||
connectClient,
|
||||
)
|
||||
import ChuSQL.Interface.Normalization (NormalizationRequest, decodeNormalizationJSON)
|
||||
import ChuSQL.Interface.Protocol (Grant (..), RoleView (..))
|
||||
import ChuSQL.Interface.RateLimit (RateLimiter, rateLimitBlock, rateLimitClear, rateLimitRecord, setRateLimit)
|
||||
import ChuSQL.Interface.Session (
|
||||
Session,
|
||||
analyzeNormalization,
|
||||
authenticateSession,
|
||||
catalog,
|
||||
databases,
|
||||
@@ -132,7 +134,7 @@ import qualified Data.Text.Encoding.Error as TEE
|
||||
import qualified Data.Text.Lazy as TL
|
||||
import Data.Time.Clock (getCurrentTime)
|
||||
import qualified Data.Vector as V
|
||||
import Network.HTTP.Types (Status, status204, status400, status401, status403, status404, status409, status413, status429, status500)
|
||||
import Network.HTTP.Types (Status, status204, status400, status401, status403, status404, status409, status413, status429, status500, status503)
|
||||
import Network.Wai (Application, Request (..))
|
||||
import Text.Read (readMaybe)
|
||||
import Web.Scotty
|
||||
@@ -337,6 +339,7 @@ routes env = do
|
||||
delete "/api/roles/:name/grants" (withDatabase env revokeRoleH)
|
||||
post "/api/roles/:name/members" (withDatabase env addRoleMemberH)
|
||||
delete "/api/roles/:name/members/:user" (withDatabase env removeRoleMemberH)
|
||||
post "/api/normalization/analyze" (withDatabase env normalizationH)
|
||||
post "/api/query" (withDatabase env queryH)
|
||||
post "/api/query/rollback" (rollbackConsoleH env)
|
||||
post "/api/edit/begin" (beginEditH env)
|
||||
@@ -357,12 +360,13 @@ withDatabase env action = do
|
||||
else do
|
||||
editing <- header "X-ChuSQL-Edit"
|
||||
req <- request
|
||||
let snapshotRead = editing /= Nothing && requestMethod req == "GET" && take 2 (pathInfo req) == ["api", "tables"]
|
||||
let snapshotRead = editing /= Nothing && ((requestMethod req == "GET" && take 2 (pathInfo req) == ["api", "tables"])
|
||||
|| (requestMethod req == "POST" && pathInfo req == ["api", "normalization", "analyze"]))
|
||||
console <- liftIO (readIORef (wsConsole ws))
|
||||
caller <- consoleOwner
|
||||
case console of
|
||||
Just (owner, database) | not snapshotRead &&
|
||||
(pathInfo req /= ["api", "query"] || caller /= owner || (not (T.null chosen) && chosen /= database)) ->
|
||||
(pathInfo req `notElem` [["api", "query"], ["api", "normalization", "analyze"]] || caller /= owner || (not (T.null chosen) && chosen /= database)) ->
|
||||
apiError "conflict" "another SQL console owns this connection transaction; commit or rollback there first"
|
||||
_ -> pure ()
|
||||
current <- liftIO (sessionDatabase (wsSession ws))
|
||||
@@ -391,7 +395,8 @@ requireSession env = do
|
||||
editHeader <- fmap TL.toStrict <$> header "X-ChuSQL-Edit"
|
||||
req <- request
|
||||
case editHeader of
|
||||
Just editId | requestMethod req == "GET" && take 2 (pathInfo req) == ["api", "tables"] -> do
|
||||
Just editId | (requestMethod req == "GET" && take 2 (pathInfo req) == ["api", "tables"])
|
||||
|| (requestMethod req == "POST" && pathInfo req == ["api", "normalization", "analyze"]) -> do
|
||||
active <- liftIO (readIORef (wsEdit ws))
|
||||
database <- maybe "" (T.toLower . TL.toStrict) <$> header "X-ChuSQL-Database"
|
||||
case active of
|
||||
@@ -905,7 +910,7 @@ serverError message
|
||||
(rawCode, rest) = T.breakOn ":" message
|
||||
code = T.strip rawCode
|
||||
detail = T.strip (T.drop 1 rest)
|
||||
knownCodes = ["unauthorized", "forbidden", "not_found", "conflict", "bad_request", "no_database", "query_error", "storage_error", "too_large"]
|
||||
knownCodes = ["unauthorized", "forbidden", "not_found", "conflict", "bad_request", "no_database", "query_error", "storage_error", "too_large", "too_busy", "timeout"]
|
||||
|
||||
-- | 取错误消息里的错误码
|
||||
errorCodeOf :: Text -> Text
|
||||
@@ -920,6 +925,8 @@ statusOf code = case code of
|
||||
"conflict" -> status409
|
||||
"too_many_attempts" -> status429
|
||||
"too_large" -> status413
|
||||
"too_busy" -> status503
|
||||
"timeout" -> status503
|
||||
"storage_error" -> status500
|
||||
"internal" -> status500
|
||||
_ -> status400
|
||||
@@ -1901,3 +1908,19 @@ data QueryRequest = QueryRequest
|
||||
|
||||
instance FromJSON QueryRequest where
|
||||
parseJSON = withObject "QueryRequest" $ \o -> QueryRequest <$> o .: "sql"
|
||||
|
||||
-- 使用现有连接视图转发只读范式请求。
|
||||
normalizationH :: AppEnv -> ActionM ()
|
||||
normalizationH env = do
|
||||
ws <- requireSession env
|
||||
rawBody <- body
|
||||
case decodeNormalizationJSON rawBody of
|
||||
Left message | BL.length rawBody > 65536 -> reply413
|
||||
| otherwise -> reply400 "bad_request" message
|
||||
Right value -> case A.fromJSON value :: A.Result NormalizationRequest of
|
||||
A.Error message -> reply400 "bad_request" (T.pack message)
|
||||
A.Success payload -> do
|
||||
result <- liftIO (analyzeNormalization (wsSession ws) payload)
|
||||
case result of
|
||||
Left message -> serverError message
|
||||
Right report -> json report
|
||||
@@ -21,7 +21,7 @@ function hasValidRows(body) {
|
||||
async function request(url, init, database) {
|
||||
const headers = new Headers(init && init.headers ? init.headers : undefined);
|
||||
if (database) headers.set('X-ChuSQL-Database', database);
|
||||
if (editing && editing.database === database && url.startsWith('/api/tables') && (!init || init.method === 'GET')) {
|
||||
if (editing && editing.database === database && ((url.startsWith('/api/tables') && (!init || init.method === 'GET')) || url === '/api/normalization/analyze')) {
|
||||
headers.set('X-ChuSQL-Edit', editing.id);
|
||||
}
|
||||
let response;
|
||||
@@ -326,3 +326,23 @@ export async function loadUiSettings() {
|
||||
export async function saveUiSettings(settings) {
|
||||
await request('/api/ui-settings', jsonInit('PUT', settings));
|
||||
}
|
||||
|
||||
// 在页面已有视图上请求只读范式分析。
|
||||
export async function analyzeNormalization(database, table, declaration, expected) {
|
||||
const payload = { api_version: 1, database, table };
|
||||
if (expected) {
|
||||
payload.expected_object = expected.object;
|
||||
payload.expected_schema_fingerprint = expected.schema_fingerprint;
|
||||
}
|
||||
const init = jsonInit('POST', payload);
|
||||
if (!editing && consoleTransaction?.database === database) {
|
||||
init.headers['X-ChuSQL-Console'] = consoleTransaction.console;
|
||||
}
|
||||
// 原始声明直接嵌入,重复字段交给服务端明确拒绝。
|
||||
init.body = `${JSON.stringify(payload).slice(0, -1)},"declaration":${typeof declaration === 'string' ? declaration : JSON.stringify(declaration)}}`;
|
||||
const result = await request('/api/normalization/analyze', init, database);
|
||||
if (!isObject(result) || result.api_version !== 1 || !Array.isArray(result.findings) || !Array.isArray(result.schema)) {
|
||||
throw new Error('服务器返回的范式报告无法识别。');
|
||||
}
|
||||
return result;
|
||||
}
|
||||
@@ -64,6 +64,8 @@ const state = {
|
||||
dockHeight: '22vh',
|
||||
};
|
||||
|
||||
let normalizationTarget = null;
|
||||
|
||||
const root = document.getElementById('root');
|
||||
|
||||
// ---------------------------------------------------------------- 小工具
|
||||
@@ -210,6 +212,21 @@ function renderShell() {
|
||||
<button type="button" class="primary" data-action="create-index">创建</button>
|
||||
</footer>
|
||||
</div>
|
||||
</dialog>
|
||||
<dialog id="dlg-normalization" class="dialog">
|
||||
<div class="dialog-body">
|
||||
<header class="dialog-header"><h2 id="normalization-title">范式分析</h2></header>
|
||||
<div class="dialog-content">
|
||||
<p>只读分析,以声明为条件。PRIMARY KEY、UNIQUE 和样本不会自动成为完整业务依赖;NULL 与重复行需要另行验证。</p>
|
||||
<label class="field"><span>JSON 声明(完整性 false,原子性 null 表示未声明)</span><textarea id="normalization-declaration" rows="10"></textarea></label>
|
||||
<p class="alert" id="normalization-error"></p>
|
||||
<div id="normalization-result"></div>
|
||||
</div>
|
||||
<footer class="dialog-footer">
|
||||
<button type="button" data-action="close-dialog">关闭</button>
|
||||
<button type="button" class="primary" data-action="analyze-normalization" disabled>分析声明</button>
|
||||
</footer>
|
||||
</div>
|
||||
</dialog>`;
|
||||
renderAccount();
|
||||
applyLayout();
|
||||
@@ -282,7 +299,7 @@ function renderSidebar() {
|
||||
<button class="mini danger" data-action="drop-table" data-table="${escapeHtml(table.name)}" title="删除表">✕</button>
|
||||
</div>
|
||||
${open ? `<ul class="column-list">${columns}</ul>${indexList}
|
||||
<div class="tree-actions"><button class="mini" data-action="open-index-dialog" data-table="${escapeHtml(table.name)}">+ 索引</button></div>` : ''}
|
||||
<div class="tree-actions">${state.database !== 'system' && !table.name.startsWith('__system_') ? `<button class="mini" data-action="open-normalization" data-table="${escapeHtml(table.name)}">范式分析</button>` : ''}<button class="mini" data-action="open-index-dialog" data-table="${escapeHtml(table.name)}">+ 索引</button></div>` : ''}
|
||||
</li>`;
|
||||
}).join('');
|
||||
|
||||
@@ -1383,7 +1400,7 @@ function startSplitterDrag(kind, event) {
|
||||
|
||||
// 这些动作都要求已经选中一个库(服务没有默认库)
|
||||
const DATABASE_ACTIONS = new Set([
|
||||
'open-table', 'demo-data', 'open-table-dialog', 'create-table',
|
||||
'open-normalization', 'analyze-normalization', 'open-table', 'demo-data', 'open-table-dialog', 'create-table',
|
||||
'open-index-dialog', 'create-index', 'drop-table', 'drop-column', 'drop-index',
|
||||
'drop-database', 'commit', 'rollback', 'copy-sql',
|
||||
]);
|
||||
@@ -1413,7 +1430,14 @@ function onClick(event) {
|
||||
}
|
||||
|
||||
switch (action) {
|
||||
case 'open-normalization':
|
||||
void openNormalization(target.dataset.table);
|
||||
break;
|
||||
case 'analyze-normalization':
|
||||
void analyzeNormalizationDeclaration();
|
||||
break;
|
||||
case 'logout':
|
||||
normalizationTarget = null;
|
||||
void (async () => {
|
||||
try {
|
||||
await api.logout();
|
||||
@@ -1790,3 +1814,68 @@ async function boot() {
|
||||
}
|
||||
|
||||
void boot();
|
||||
|
||||
// 打开结构入口并绑定当前 schema 身份。
|
||||
async function openNormalization(table) {
|
||||
const declaration = { declared_keys: [], dependencies: [], dependencies_complete: false, atomic_attributes: null };
|
||||
const target = { database: state.database, table, report: null };
|
||||
normalizationTarget = target;
|
||||
region('dlg-normalization').querySelector('[data-action="analyze-normalization"]').disabled = true;
|
||||
region('normalization-title').textContent = `范式分析 · ${target.database}.${table}`;
|
||||
region('normalization-declaration').value = JSON.stringify(declaration, null, 2);
|
||||
region('normalization-result').textContent = '正在读取结构…';
|
||||
region('normalization-error').textContent = '';
|
||||
region('dlg-normalization').onclose = () => { normalizationTarget = null; };
|
||||
region('dlg-normalization').showModal();
|
||||
try {
|
||||
target.report = await api.analyzeNormalization(target.database, table, declaration);
|
||||
if (normalizationTarget === target) {
|
||||
renderNormalization(target.report);
|
||||
region('dlg-normalization').querySelector('[data-action="analyze-normalization"]').disabled = false;
|
||||
}
|
||||
} catch (error) {
|
||||
if (normalizationTarget === target) {
|
||||
region('normalization-result').textContent = '';
|
||||
region('normalization-error').textContent = error.message;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 提交显式声明并复核绑定的对象结构。
|
||||
async function analyzeNormalizationDeclaration() {
|
||||
const target = normalizationTarget;
|
||||
if (!target?.report) return;
|
||||
const button = region('dlg-normalization').querySelector('[data-action="analyze-normalization"]');
|
||||
button.disabled = true;
|
||||
region('normalization-error').textContent = '';
|
||||
try {
|
||||
const declaration = region('normalization-declaration').value;
|
||||
const report = await api.analyzeNormalization(target.database, target.table, declaration, target.report);
|
||||
if (normalizationTarget === target) {
|
||||
target.report = report;
|
||||
renderNormalization(report);
|
||||
}
|
||||
} catch (error) {
|
||||
if (normalizationTarget === target) {
|
||||
region('normalization-error').textContent = error.message;
|
||||
if (error.code === 'conflict') {
|
||||
target.report = null;
|
||||
region('normalization-result').textContent = '对象或视图已变化,请关闭后重新打开范式分析。';
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
button.disabled = !target.report;
|
||||
}
|
||||
}
|
||||
|
||||
// 展示条件结论、违反证据与一次分解。
|
||||
function renderNormalization(report) {
|
||||
const labels = { satisfied: '满足', violated: '违反', insufficient: '信息不足' };
|
||||
const schema = report.schema.map((column) => `${column.name}: ${column.ty}${column.nullable ? '(可空)' : '(非空)'}`).join(',');
|
||||
const findings = report.findings.map((finding) => `<li><strong>${escapeHtml(finding.normal_form.toUpperCase())}: ${escapeHtml(labels[finding.verdict] ?? finding.verdict)}</strong>
|
||||
<p>${escapeHtml(finding.condition)}</p><pre>${escapeHtml(JSON.stringify(finding.witnesses, null, 2))}</pre></li>`).join('');
|
||||
region('normalization-result').innerHTML = `<p>${escapeHtml(schema)}</p><p>视图:${escapeHtml(report.view)} · 结论以声明为条件</p>
|
||||
<details><summary>结构与约束</summary><pre>${escapeHtml(JSON.stringify(report.schema, null, 2))}</pre></details>
|
||||
<p>相对于声明的候选键:${escapeHtml(JSON.stringify(report.candidate_keys))}</p><ul>${findings}</ul>
|
||||
<p>一次分解建议(无损标签不证明现有 SQL 数据):</p><pre>${escapeHtml(JSON.stringify(report.suggestions, null, 2))}</pre>`;
|
||||
}
|
||||
@@ -946,6 +946,102 @@ roleSpec env = describe "roles and grants" $ do
|
||||
querySpec :: AppEnv -> Spec
|
||||
querySpec env = describe "the SQL console" $ do
|
||||
withApp (webApp env) $ do
|
||||
it "analyzes explicit declarations for SELECT users and rejects stale schemas" $ do
|
||||
admin <- adminHeaders
|
||||
ensureDatabase admin testDatabaseName
|
||||
ensureAccount admin "normalization_web_reader" "Reader-Secret-1234"
|
||||
created <- testSql admin "CREATE TABLE normalization_web (id INT, course INT, name TEXT)"
|
||||
expectStatusWith "normalization table" created 200
|
||||
let declaration = object ["declared_keys" .= ([["id", "course"]] :: [[Text]]),
|
||||
"dependencies" .= [object ["lhs" .= (["id"] :: [Text]), "rhs" .= (["name"] :: [Text])]],
|
||||
"dependencies_complete" .= True, "atomic_attributes" .= True]
|
||||
payload = object ["api_version" .= (1 :: Int), "database" .= testDatabaseName,
|
||||
"table" .= ("normalization_web" :: Text), "declaration" .= declaration]
|
||||
unauthorized <- postAs (withDb testDatabaseName []) "/api/normalization/analyze" (encode payload)
|
||||
expectStatusWith "anonymous normalization" unauthorized 401
|
||||
reader <- ordinaryHeaders "normalization_web_reader" "Reader-Secret-1234"
|
||||
denied <- postAs (withDb testDatabaseName reader) "/api/normalization/analyze" (encode payload)
|
||||
expectStatusWith "SELECT required" denied 403
|
||||
granted <- testSql admin "GRANT SELECT ON normalization_web TO normalization_web_reader"
|
||||
expectStatusWith "normalization SELECT" granted 200
|
||||
readerAgain <- ordinaryHeaders "normalization_web_reader" "Reader-Secret-1234"
|
||||
report <- postAs (withDb testDatabaseName readerAgain) "/api/normalization/analyze" (encode payload)
|
||||
expectStatusWith "conditional report" report 200
|
||||
check (at "conditional" (jsonBody report) `shouldBe` A.Bool True)
|
||||
check (map (asText . at "verdict") (items (at "findings" (jsonBody report))) `shouldBe` ["satisfied", "violated", "violated", "violated"])
|
||||
changed <- testSql admin "ALTER TABLE normalization_web ADD COLUMN extra INT"
|
||||
expectStatusWith "change normalization schema" changed 200
|
||||
let stale = object ["api_version" .= (1 :: Int), "database" .= testDatabaseName,
|
||||
"table" .= ("normalization_web" :: Text), "declaration" .= declaration,
|
||||
"expected_object" .= at "object" (jsonBody report), "expected_schema_fingerprint" .= at "schema_fingerprint" (jsonBody report)]
|
||||
conflicted <- postAs (withDb testDatabaseName readerAgain) "/api/normalization/analyze" (encode stale)
|
||||
expectStatusWith "schema changed" conflicted 409
|
||||
it "uses a matching editing snapshot without creating a second transaction" $ do
|
||||
admin <- adminHeaders
|
||||
ensureDatabase admin testDatabaseName
|
||||
created <- testSql admin "CREATE TABLE normalization_edit (id INT, value INT)"
|
||||
expectStatusWith "normalization snapshot table" created 200
|
||||
begun <- editRequest admin "/api/edit/begin" "normalization-owner" []
|
||||
expectStatusWith "normalization edit begins" begun 200
|
||||
changed <- testSql admin "ALTER TABLE normalization_edit ADD COLUMN extra INT"
|
||||
expectStatusWith "live schema changes" changed 200
|
||||
let payload = object ["api_version" .= (1 :: Int), "database" .= testDatabaseName,
|
||||
"table" .= ("normalization_edit" :: Text), "declaration" .= object []]
|
||||
snapshot <- postAs (("X-ChuSQL-Edit", "normalization-owner") : withDb testDatabaseName admin) "/api/normalization/analyze" (encode payload)
|
||||
expectStatusWith "analysis on edit connection" snapshot 200
|
||||
check (at "view" (jsonBody snapshot) `shouldBe` A.String "transaction_snapshot")
|
||||
check (length (items (at "schema" (jsonBody snapshot))) `shouldBe` 2)
|
||||
forged <- postAs (("X-ChuSQL-Edit", "another-page") : withDb testDatabaseName admin) "/api/normalization/analyze" (encode payload)
|
||||
expectStatusWith "page identity mismatch" forged 409
|
||||
rolledBack <- editRequest admin "/api/edit/rollback" "normalization-owner" []
|
||||
expectStatusWith "release edit snapshot" rolledBack 200
|
||||
it "binds normalization to the owning SQL console transaction" $ do
|
||||
admin <- adminHeaders
|
||||
ensureDatabase admin testDatabaseName
|
||||
created <- testSql admin "CREATE TABLE normalization_console (id INT)"
|
||||
expectStatusWith "console normalization table" created 200
|
||||
let owner = ("X-ChuSQL-Console", "normalization-console") : admin
|
||||
payload = object ["api_version" .= (1 :: Int), "database" .= testDatabaseName,
|
||||
"table" .= ("normalization_console" :: Text), "declaration" .= object []]
|
||||
begun <- testSql owner "BEGIN"
|
||||
expectStatusWith "console begins" begun 200
|
||||
snapshot <- postAs (withDb testDatabaseName owner) "/api/normalization/analyze" (encode payload)
|
||||
expectStatusWith "owner console normalization" snapshot 200
|
||||
check (at "view" (jsonBody snapshot) `shouldBe` A.String "transaction_snapshot")
|
||||
denied <- postAs (withDb testDatabaseName admin) "/api/normalization/analyze" (encode payload)
|
||||
expectStatusWith "unrelated console refused" denied 409
|
||||
rolledBack <- testSql owner "ROLLBACK"
|
||||
expectStatusWith "console ends" rolledBack 200
|
||||
it "rejects duplicate declarations and oversized normalization bodies" $ do
|
||||
admin <- adminHeaders
|
||||
ensureDatabase admin testDatabaseName
|
||||
duplicate <- postAs (withDb testDatabaseName admin) "/api/normalization/analyze"
|
||||
"{\"api_version\":1,\"database\":\"test\",\"table\":\"users\",\"declaration\":{\"atomic_attributes\":true,\"atomic_attributes\":false}}"
|
||||
expectStatusWith "duplicate declaration fields" duplicate 400
|
||||
oversized <- postAs (withDb testDatabaseName admin) "/api/normalization/analyze" (BL.replicate 65537 32)
|
||||
expectStatusWith "normalization request bound" oversized 413
|
||||
|
||||
it "exposes DISTINCT and self grants after grant option revocation" $ do
|
||||
admin <- adminHeaders
|
||||
ensureDatabase admin testDatabaseName
|
||||
ensureAccount admin "http_option_reader" "Reader-Secret-1234"
|
||||
created <- testSql admin "CREATE TABLE http_distinct_probe (id INT, value INT)"
|
||||
expectStatusWith "distinct table" created 200
|
||||
inserted <- testSql admin "INSERT INTO http_distinct_probe (id,value) VALUES (1,7),(2,7),(3,8)"
|
||||
expectStatusWith "distinct rows" inserted 200
|
||||
granted <- testSql admin "GRANT SELECT ON http_distinct_probe TO http_option_reader WITH GRANT OPTION"
|
||||
expectStatusWith "grant option" granted 200
|
||||
revoked <- testSql admin "REVOKE GRANT OPTION FOR SELECT ON http_distinct_probe FROM http_option_reader"
|
||||
expectStatusWith "revoke only grant option" revoked 200
|
||||
reader <- ordinaryHeaders "http_option_reader" "Reader-Secret-1234"
|
||||
result <- testSql reader "SELECT DISTINCT value FROM http_distinct_probe ORDER BY 1 DESC LIMIT 2"
|
||||
expectStatusWith "distinct output" result 200
|
||||
check (at "rows" (jsonBody result) `shouldBe` A.toJSON ([[8], [7]] :: [[Int]]))
|
||||
grants <- testSql reader "SHOW GRANTS"
|
||||
expectStatusWith "own grants" grants 200
|
||||
check (at "columns" (jsonBody grants) `shouldBe` A.toJSON (["identity", "privilege", "object", "grant_option"] :: [Text]))
|
||||
denied <- testSql reader "GRANT SELECT ON http_distinct_probe TO admin"
|
||||
expectStatusWith "no delegation" denied 403
|
||||
it "returns typed compound values through HTTP and TCP" $ do
|
||||
hs <- adminHeaders
|
||||
ensureDatabase hs testDatabaseName
|
||||
|
||||
@@ -12,7 +12,7 @@ globalThis.document = { getElementById: (id) => id === 'root' ? { addEventListen
|
||||
let source = await readFile(new URL('../static/app.js', import.meta.url), 'utf8');
|
||||
source = source.replaceAll("'./api.js'", JSON.stringify(new URL('../static/api.js', import.meta.url).href))
|
||||
.replaceAll("'./core.js'", JSON.stringify(new URL('../static/core.js', import.meta.url).href))
|
||||
.replace('void boot();', 'export { state, commitAll, rollbackAll, stageUpdate, removeStaged, ensureEdit, runSql };');
|
||||
.replace('void boot();', 'export { state, commitAll, rollbackAll, stageUpdate, removeStaged, ensureEdit, runSql, renderNormalization };');
|
||||
const app = await import(`data:text/javascript;base64,${Buffer.from(source).toString('base64')}`);
|
||||
let calls;
|
||||
let commitResponse;
|
||||
@@ -173,3 +173,52 @@ test('edits are locked while SQL releases the previous snapshot', async () => {
|
||||
assert.equal(app.state.editBusy, false);
|
||||
assert.equal(app.state.edit, null);
|
||||
});
|
||||
|
||||
// 校验分析复用编辑视图并保留严格声明的原始字段。
|
||||
test('normalization keeps snapshot ownership, exact identities and duplicate declaration fields', async () => {
|
||||
await api.beginEdit('normalization-page', 'test');
|
||||
const expected = { object: { database_id: '9007199254740993', object_id: '18446744073709551615' }, schema_fingerprint: 'schema' };
|
||||
globalThis.fetch = async (url, init) => {
|
||||
calls.push({ url, init });
|
||||
return response({ api_version: 1, findings: [], schema: [] });
|
||||
};
|
||||
await api.analyzeNormalization('test', 'users', '{"atomic_attributes":true,"atomic_attributes":false}', expected);
|
||||
const sent = calls.find((call) => call.url === '/api/normalization/analyze');
|
||||
assert.equal(new Headers(sent.init.headers).get('X-ChuSQL-Edit'), 'normalization-page');
|
||||
assert.equal(new Headers(sent.init.headers).get('X-ChuSQL-Database'), 'test');
|
||||
assert.equal((sent.init.body.match(/"atomic_attributes"/g) ?? []).length, 2);
|
||||
assert.deepEqual(JSON.parse(sent.init.body).expected_object, expected.object);
|
||||
assert.equal(calls.filter((call) => call.url === '/api/edit/begin').length, 1);
|
||||
api.forgetEdit();
|
||||
globalThis.fetch = async (url, init) => {
|
||||
calls.push({ url, init });
|
||||
return response(url === '/api/query'
|
||||
? { columns: [], rows: [], rowCount: 0, truncated: false, transaction: true }
|
||||
: { api_version: 1, findings: [], schema: [] });
|
||||
};
|
||||
await api.query('BEGIN', 'test', 'normalization-console');
|
||||
await api.analyzeNormalization('test', 'users', '{}', expected);
|
||||
const fromConsole = calls.filter((call) => call.url === '/api/normalization/analyze').at(-1);
|
||||
const begun = calls.filter((call) => call.url === '/api/query').at(-1);
|
||||
assert.equal(new Headers(fromConsole.init.headers).get('X-ChuSQL-Console'), new Headers(begun.init.headers).get('X-ChuSQL-Console'));
|
||||
assert.equal(new Headers(fromConsole.init.headers).get('X-ChuSQL-Edit'), null);
|
||||
});
|
||||
|
||||
// 校验结构、违反证据和分解中的用户文字全部转义。
|
||||
test('normalization escapes schema names, conditions, witnesses and suggestions', () => {
|
||||
const result = { innerHTML: '' };
|
||||
const original = document.getElementById;
|
||||
document.getElementById = (id) => id === 'normalization-result' ? result : original(id);
|
||||
try {
|
||||
app.renderNormalization({ schema: [{ name: '<img src=x onerror=alert(1)>', ty: 'str', nullable: true }],
|
||||
view: 'live_schema', candidate_keys: [['<script>']],
|
||||
findings: [{ normal_form: 'bcnf', verdict: 'violated', condition: '<script>alert(1)</script>', witnesses: [{ lhs: ['<img>'], rhs: ['id'] }] }],
|
||||
suggestions: [{ relations: [['<script>'], ['id']] }] });
|
||||
assert.equal(result.innerHTML.includes('<img'), false);
|
||||
assert.equal(result.innerHTML.includes('<script>'), false);
|
||||
assert.equal(result.innerHTML.includes('<img'), true);
|
||||
assert.equal(result.innerHTML.includes('结构与约束'), true);
|
||||
} finally {
|
||||
document.getElementById = original;
|
||||
}
|
||||
});
|
||||
Reference in new issue
Block a user