页面显示“Approved”,申请就已经批准了吗

采购审批页展示单据 ID 与租户,操作员按下批准按钮。页面可以呈现成功文字,也可以在校验失败时展示消息;但页面字符串不是数据库提交记录。更危险的是,浏览器可修改隐藏字段、重放视图状态,甚至伪造租户值。在第 24–26 篇落实身份与对象授权之前,审批页只能在本机隔离实验中调用已有业务用例,不能对公网开放。当前工程确有最小 approval.xhtml 与 LabApprovalPage.java,但没有完整的页面提交脚本或数据库终态证据;不能把文件存在写成 Faces 生命周期实验 PASS。

本章使用 Jakarta Faces 4.1、Servlet 6.1、Jakarta EE 11 与既有 PostgreSQL 16 隔离库。REST 创建与提交单据的顺序脚本另有 PASS 记录;页面的视图状态、验证失败、重复提交、输出编码尚未受控执行。Faces 只是另一种 Web 适配器:领域状态机与价格计算仍由 ProcurementUseCases 和 JDBC 适配器管理,不应该复制一套“页面上的审批规则”。与 JPA 09:采购审批综合应用 对照时同样只比较业务约束;JPA 实体不进入这个页面的审批服务。

页面有自己的请求处理阶段

WEB-INF/web.xml 将 FacesServlet 映射到 *.xhtml。浏览器请求 /procurement/approval.xhtml,由 Faces 恢复视图、读取请求参数并对组件应用值,然后执行转换、校验、更新页面模型、调用动作,最后渲染响应。Jakarta Faces 4.1 的生命周期描述的是一次 Faces 请求的阶段;无论页面怎么组织,useCases.approve() 才是当前业务边界。缺少 view state 或参数转换失败时,不能跳过中间阶段直接声称业务方法已调用。GET 首次显示表单与 POST 回发不是同一阶段路径,测试应分别保存实际 HTML 和回发表单字段。

现有 XHTML 用 <h:form> 生成表单,<h:inputText id="requestId" ... required="true"> 绑定 long requestId,并用 <f:validateLongRange minimum="1" /> 限制下界;tenantId 也要求有值。操作通过 <h:commandButton action="#{labApprovalPage.approve}" /> 调用页面 Bean。@Named("labApprovalPage") @RequestScoped 为每次请求创建托管实例,result 不会作为上一次成功消息自动留给新请求;若将来延长作用域,还要重新审视并发标签页和序列化。转换非法数字或必填字段为空时,通常应停留在校验/渲染路径、不执行动作;确切错误文本、HTTP 状态和 HTML 结构仍须在这个实现版本实测。

Faces 的校验不会替应用确认“租户是否有权限审批这个申请”。tenantId required=true 排除空输入,并未证明该字段来自认证主体;提交任意别人的租户文字依然可能让业务用例去查对应单据。LabApprovalPage.approve() 只检查环境开关 JAVAEE_DEMO_MODE;然后用客户端提交的 requestId 和 tenantId 调 useCases.approve(),遇 RequestConflictException 或 RequestMissingException 加一条错误消息、置 result="rejected",成功则设 approved。用例负责状态转换,DAO 按传入的租户查询;页面的“必填”检查不能阻止调用者填写别人的租户。正式页面应从容器主体获得租户并检查审批角色及对象归属;现在没有该实现。

不同阶段的失败要用不同断言。浏览器提交 requestId=abc 时,long 转换可能在模型更新之前就失败,approve() 不应被调用;空 ID 命中 required,零值命中 f:validateLongRange minimum=1。三者都应留下各自的组件消息,并让提交前后的数据库状态相同。若输入正整数但查不到申请,转换与校验都会放行,失败来自业务用例,当前 Bean 捕获 RequestMissingException 并向 Faces 消息队列加入 Approval rejected。把“不存在 ID”误判为“数字格式错误”会把应用错误投给错误的处理器;反过来,业务冲突不应修改页面组件的基本类型校验规则。现有代码没有记录任何这些分支的 HTTP 页面和数据库终态,必须在实际提交后才能确认呈现顺序。

LabApprovalPage 的 requestId 字段是 primitive long,tenantId 是 String。显示与提交期间这些值取自本次浏览器表单;请求范围内的 Bean 不会凭空记住最初 GET 所见的申请状态。若希望渲染“当前已批准”而非只打印固定 approved,必须在受信身份下再次从服务查询状态;不能简单把旧页面的隐藏状态字段赋回 Bean 当作数据库真值。页面 DTO 也不该持有 JDBC Connection 或事务对象跨多个 HTTP 请求;一次渲染结束意味着本次请求处理完毕,不意味着下次 POST 自动复用同一数据库事务。

视图状态与输出编码不能混成身份验证

Faces 组件树及视图状态帮助服务器恢复页面交互,但视图状态的实现、存放位置和保护策略取决于配置;它不是用户认证声明。即便回发的 view state 无法被随意伪造,也不能推出本次请求满足采购审批权限。多标签页同时提交同一申请时,两个页面各自显示 SUBMITTED 的旧信息;业务层的状态条件更新与数据库版本列才决定哪个提交成功,另一个应得到冲突。单页表单的“按钮变灰”不是幂等锁,网络重试、双击和并行标签页都可能重复提交。

XHTML 中 <h:outputText value="#{labApprovalPage.result}" escape="true" /> 把该输出按文本进行 HTML 转义;此处 result 目前只赋固定的 approved/rejected,仍要保留转义。若未来直接回显 SKU、申请备注或错误消息,不能改用原样 HTML 插入脚本。与输出编码不同,防止跨站请求伪造(CSRF)要求校验请求发起的身份与意图;仅有视图状态或 Cookie 的 SameSite 不能替代系统性的 CSRF 策略。没有真实登录时,所谓 CSRF 保护还没有受保护的身份前提,不能因一个表单存在便宣称通过安全验收。审计时也不能把原始 HTML 中含有表单字段解读为“服务器成功更新过申请”。

Faces 异常处理与 REST 异常映射也不是一条管线。当前 LabApprovalPage 只捕获两种业务异常;数据库断连、未知参数或转换异常可能经不同路径呈现错误页。把所有失败都变成 result="rejected" 会把系统故障伪装成业务拒绝。对于合法但已 APPROVED 的申请,业务规则应拒绝再次审批;若页面仍在旧标签上显示原来内容,应提供可重新读取的状态而非继续依据陈旧视图作决策。无论成功或失败,都需用独立连接确认表中的 status、version 和订单数,不能以页面标题或消息替代数据库证据。

当前 approve() 返回 null,表示留在相同视图的导航结果;这使 result 在本次响应中可见,也意味着用户若刷新 POST 页面,浏览器可能再次提交相同表单。能否将成功页改成 Post/Redirect/Get,取决于业务是否能在新 GET 中可靠查询已提交结果;简单在 action 后发重定向不解决“提交成功但客户端没收到重定向”的网络故障,更不解决攻击者重放请求。应先在数据库用例层定义相同审批再次到达时的状态语义,再选择页面导航策略。页面双击时两个并发 POST 可能分别经过两个 @RequestScoped Bean,单个 Bean 字段无锁并不能保证整笔审批只执行一次。

页面实验:已运行路径与缺失路径

同一 WAR 后续部署到 GlassFish 8.0.4,本地首次运行 11 脚本失败:脚本原本假设视图状态 input 的 name 后面直接跟 value,且发送 Liberty 页面接受的 approval_SUBMIT=1;GlassFish 实际返回 name、id、value,表单隐藏字段是 approval=approval。脚本改为从真实 input 解析属性并提交实际页面给出的隐藏字段后,在 GlassFish 与重启的 Liberty 上各自执行顺序审批与重复提交,独立查询均得到 APPROVED|2。失败与修正后的两份输出见 writing-plans/javaee-enterprise/verification/20261005T-glassfish-scenarios/;这个有限回归不覆盖校验、身份与并发页面提交。

早期的 20261004T062900Z-pg16-business 只请求过 REST 入口,不能作为页面证据。新增的 examples/javaee-enterprise/scenarios/11-faces.sh 在回环服务器上创建并提交新申请,保留 GET 页面得到的 Cookie 和 jakarta.faces.ViewState 后 POST 审批;首次页面返回 approved,重复提交返回 rejected,独立 PostgreSQL 连接读到最终 APPROVED|2。原始脚本输出和退出码 0 见 writing-plans/javaee-enterprise/verification/20261004T-managed-outbox/scenario-11-faces-with-db.*,构建与实际部署 WAR 的 SHA 见同目录。这只验证一个受控顺序样本;运行时必须开启仅供本机教学的 JAVAEE_DEMO_MODE=true,表单租户值仍可伪造,不能据此宣称身份认证或角色授权。

正常路径的判据是收到页面、提交已存在的 SUBMITTED 申请后状态成为 APPROVED,另起连接读 status 与 version 验证只有一次转换;不能只检查 HTML 文本。失败路径至少包括空 ID、非数字 ID、ID 为零、空租户、无权租户及已审批的旧表单重复提交。对前四种输入应确认动作方法没有执行及数据库行不变;对后两种要辨别服务端返回业务冲突还是资源不可见。再加用两标签页取得同一旧视图、先后提交的实验,要求最终状态只能是合法值,不出现重复订单。客户端请求方法、Faces 字段名、view state token、Cookie 与响应要取实际首次 GET 的表单,不应在文章伪造固定 token。页面 GET/POST、顺序重复审批及独立数据库终态已部分运行;校验失败、越权、并发双提交、输出编码与重启行为仍为 NOT_RUN。步骤索引见 11实验说明。

为避免“在浏览器里看着正常”却没有实验记录,页面测试可以按阶段保存证据。第一次 GET 保存 HTML 中表单 action、组件 name、Cookie 与视图状态的哈希;用同一 Cookie 逐项提交非法 ID,保存 Faces 消息并查询行版本;再用新建的 SUBMITTED 申请提交合法表单,独立连接核对状态和版本。失败路径需分别停在转换/校验阶段与业务调用阶段,才能证明页面错误消息是否准确。测试发起前先记录运行时的 JAVAEE_DEMO_MODE 与 WAR 摘要,运行后关闭演示模式;没有这些材料的“审批成功截图”既可能来自旧部署,也可能只是局部前端文本。对于不可信 SKU/备注,构造包含 <、&、引号的文字并用浏览器确认它只作为文本出现,而非执行脚本;当前页面没有显示该字段,所以此输出编码负例需要先加只读渲染字段,保持 NOT_RUN。

两道练习与答案

练习一: 第一次 GET 页面后,操作员把 ID 留空、填写租户并回发视图状态。approve() 应运行吗?如果表单返回一条“必填”消息而数据库变成 APPROVED,该如何定位?

答: 设计上 ID 的 required=true 应在 Faces 的转换/校验阶段拦住动作方法,数据库应保持原状态。若仍看到批准状态,先核对请求实际携带的表单字段、是否复用了别的页面会话/ID、是否有另一条 REST 审批请求、是否部署了最新 WAR;用独立 SQL 查目标行与版本。只看错误消息不足以证明动作未执行,当前实验尚未留证。

练习二: 两个标签页都显示 SUBMITTED,标签 A 批准成功;标签 B 不刷新直接再提交相同的审批。返回一条“Approved”就足以证明流程幂等吗?浏览器里的租户输入能否代替已验证的身份?

答: 都不能。标签 B 可能因状态已变化而抛冲突,页面应呈现可检查的拒绝/刷新提示;核对 SQL 中一次版本推进、合法状态与订单条数。若业务需要把相同审批重试当幂等成功,必须在用例层定义稳定操作标识及返回旧结果,不由表单视图状态自动完成。租户输入是调用方控制的文字,须由认证主体与对象权限检查代替;本章这两项仍为 NOT_RUN。

还要区分重新呈现页面与改变了申请:如果校验失败,Faces 可以继续渲染原组件输入和错误提示,但不应执行 LabApprovalPage.approve();若业务调用失败,动作可能执行过却由用例拒绝数据库写入。两条路径最终都显示 Approval rejected 也会混淆排障,需要在界面向用户区分输入修正与状态刷新,同时在服务端为两类结果留独立的请求关联记录。若在 REST 页面之间导航,不能把 REST 的 409 mapper 当作 Faces 错误消息处理器;二者共用的应是受控业务异常分类和数据库终态。当前页面没有这种统一错误呈现合同,需将它作为后续可检验的设计增量,而不是因为有 <h:messages> 就宣布完成。

对于再次提交后的“客户端没有收到页面”也须定义恢复路径。审批事务可能已提交,但连接在页面 HTML 发出前断开;操作员刷新时可能再次发相同 POST,触发状态冲突而误以为第一次审批失败。安全的恢复方式是从可信身份下重新按 ID 查询申请状态与版本,再展示实际结果;若要把相同操作视为成功重试,则必须让用例定义稳定操作键。当前没有页面级结果查询、身份认证或操作键,也没有响应丢失故障注入。它不能借 REST 顺序业务脚本的订单幂等断言自称具备页面提交恢复能力。

页面重复提交的断言还要避免“查到 APPROVED 就算两次都成功”:批准一次后的状态本来就是 APPROVED,第二次可能失败而没有造成额外写入。应在开始前记下版本和审计行数,在第二次提交后核对版本是否只推进一次、错误或幂等响应是否与既定业务合同一致。若页面有另外的通知副作用,也必须独立查通知账本;当前示例没有通知实现,因此不能用状态行替它证明通知只发过一次。

限制与官方资料

Faces 表单在示例中只展示一个未经认证的教学流程。现有 Bean 与页面可作为生命周期阅读入口,不能宣称已完成正式角色授权、CSRF、未运行的页面负例或并发审批验收。页面与 REST 同调 ProcurementUseCases,业务事务和申请状态不应在两处重写;远端 GitLab master 的新源码是否与当前工作树一致仍待推送后核验。