Compare commits
100
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
66355a7f89 | ||
|
|
41881e81f3 | ||
|
|
e3c87fdec5 | ||
|
|
871cd24d68 | ||
|
|
4f8e71b256 | ||
|
|
a6ad560f5d | ||
|
|
6a5547b323 | ||
|
|
46fccc2120 | ||
|
|
89648880bc | ||
|
|
4829be972c | ||
|
|
3b2fa3536e | ||
|
|
9a4d11f74b | ||
|
|
79284576ef | ||
|
|
20aaba0919 | ||
|
|
03a067e29d | ||
|
|
5dcff4b15a | ||
|
|
ce9d6ca285 | ||
|
|
44586fef38 | ||
|
|
e379d50101 | ||
|
|
13547728fc | ||
|
|
0b5c561ed6 | ||
|
|
5f650f18b1 | ||
|
|
b49a9b4abe | ||
|
|
96f774ed96 | ||
|
|
1f20271366 | ||
|
|
27726f4dde | ||
|
|
f85ef5f714 | ||
|
|
e04f05b20b | ||
|
|
e20457b6db | ||
|
|
b5f45b87a5 | ||
|
|
64a7468cab | ||
|
|
442ab88fd7 | ||
|
|
d38cfb61af | ||
|
|
da540bfdf6 | ||
|
|
cfd5440ac0 | ||
|
|
8ba9b231f4 | ||
|
|
efeb2d958a | ||
|
|
549099ad24 | ||
|
|
76a522f13f | ||
|
|
5088d544f2 | ||
|
|
6e001b2729 | ||
|
|
cea27ff7ef | ||
|
|
1c35155c2d | ||
|
|
babb530b99 | ||
|
|
55d9e09bda | ||
|
|
47c0844f9c | ||
|
|
88d8f77417 | ||
|
|
f7efa4a161 | ||
|
|
ef1ac60ac5 | ||
|
|
5332d67b5e | ||
|
|
5f6b3ce01a | ||
|
|
824a628733 | ||
|
|
1e69d274b8 | ||
|
|
8ee26be95a | ||
|
|
80ed9b71e8 | ||
|
|
3f2e0e5598 | ||
|
|
a45a1b5afc | ||
|
|
d27fda6be3 | ||
|
|
acf7e11114 | ||
|
|
946b064470 | ||
|
|
44c027a18f | ||
|
|
cf3692112c | ||
|
|
dbb69a7d5e | ||
|
|
3d063b9785 | ||
|
|
932ca8c7f3 | ||
|
|
9b2eb74478 | ||
|
|
17e295e99d | ||
|
|
d80e4393b4 | ||
|
|
82f57c7233 | ||
|
|
d81fc722dc | ||
|
|
abeefbbea8 | ||
|
|
b66417d80b | ||
|
|
5b56161fd7 | ||
|
|
a92b8f6be0 | ||
|
|
ec8257ba8b | ||
|
|
27999c8c85 | ||
|
|
1dc83086a0 | ||
|
|
ad55e77bda | ||
|
|
652eca7953 | ||
|
|
cb646b4974 | ||
|
|
9452debf66 | ||
|
|
4adeb1b37f | ||
|
|
2ea28c2626 | ||
|
|
7040bb61d8 | ||
|
|
e7a4be1b9b | ||
|
|
4281b06711 | ||
|
|
393f26de53 | ||
|
|
71c66a074a | ||
|
|
7905fa0b70 | ||
|
|
c4cf19fd55 | ||
|
|
de8187eb5b | ||
|
|
f3294633c2 | ||
|
|
0fbf66836b | ||
|
|
4f4a95a55e | ||
|
|
637c341b95 | ||
|
|
87591e84f9 | ||
|
|
8be6d206dd | ||
|
|
bcec012c8b | ||
|
|
2ab5baa776 | ||
|
|
cf293d2382 |
@@ -10,7 +10,8 @@ cmbuyer 是一个自动化采购系统:**采购服务**(网页端,`admin/`
|
||||
|
||||
**系统只创建待付款订单,任何情况下都不自动付款。**
|
||||
|
||||
当前状态:仓库只有文档,尚未开始编码。阶段为 Phase 0。
|
||||
当前状态:两端骨架、基础模型、登录和真机取证脚手架已落地;Phase 1 真机取证与不依赖页面判据的
|
||||
Phase 2 服务端任务并行。实时快照见 [`docs/current-state.md`](docs/current-state.md)。
|
||||
|
||||
## 必读顺序
|
||||
|
||||
@@ -33,7 +34,9 @@ cmbuyer 是一个自动化采购系统:**采购服务**(网页端,`admin/`
|
||||
系统**会**点击「提交订单」创建待付款订单,但必须满足四个前置条件(授权未消费且服务端
|
||||
提交围栏已建立、闸门二通过、闸门三通过、控件唯一),且**只点一次**、点击后无论结果
|
||||
都不重试。围栏申请失败或响应不明时不得点击;围栏建立后只能调和同一提交记录。
|
||||
**第一趟试选的代码路径不得引用任何下单函数**,必须有测试证明不可达。
|
||||
管理员点击“开始采购(只创建待付款订单)”是唯一的人类授权动作。T-103 的规格选择/读价隔离
|
||||
验证路径不得引用数量、确认页、提交或付款函数;后续能力必须按真机取证任务逐段开放,并有静态
|
||||
调用链测试证明未获准能力不可达。
|
||||
|
||||
### 2. 安全边界只能收紧
|
||||
|
||||
@@ -141,8 +144,8 @@ python scripts/validate_agent_context.py
|
||||
|
||||
**跨端契约改动必跑完整门禁**——两端会同时坏。
|
||||
|
||||
代码尚未初始化,上述命令在 T-001 / T-002 完成前不可运行;届时由对应任务替换为真实命令
|
||||
并同步文档。
|
||||
两端已初始化;跨端契约改动还需在仓库根运行 `./init.ps1`(或 `./init.sh`)完成安装、测试、
|
||||
vet/build、compileall 和上下文门禁。
|
||||
|
||||
## 风格
|
||||
|
||||
|
||||
@@ -8,13 +8,14 @@
|
||||
## 它做什么
|
||||
|
||||
一笔外部订单进来,采购人员需要去拼多多找到同款、选对颜色尺码、下单、把订单号抄回系统。
|
||||
cmbuyer 把这个过程自动化,人只在两个点介入:**机器选对了吗**和**付不付款**。
|
||||
cmbuyer 把这个过程自动化:管理员明确“买什么、买多少、最多多少钱”并点击开始采购,系统只
|
||||
创建待付款订单,**是否付款始终由人决定**。
|
||||
|
||||
```text
|
||||
手工填链接(MVP) / Excel · ERP(V2)
|
||||
│
|
||||
v
|
||||
采购服务(admin/,Go) 建单 · 试选确认 · 下单授权 · 审计
|
||||
采购服务(admin/,Go) 建单 · 开始采购授权 · 围栏 · 审计
|
||||
│ HTTP
|
||||
v
|
||||
采购工具(client/,Python) 领任务 · 跑流程 · 回传
|
||||
@@ -23,31 +24,22 @@ cmbuyer 把这个过程自动化,人只在两个点介入:**机器选对了
|
||||
Android 手机(拼多多 App)
|
||||
```
|
||||
|
||||
## 两趟执行
|
||||
## 单趟执行
|
||||
|
||||
MVP 只做**任务自带商品链接**的情形,分两趟跑完:
|
||||
MVP 只做**任务自带商品链接**的情形:管理员先把任务保存为 `DRAFT`,再在表格中勾选并点击
|
||||
“开始采购(只创建待付款订单)”。该点击创建一次性授权,锁定商品、颜色、尺码、数量和最高总价。
|
||||
|
||||
| 趟次 | 做什么 |
|
||||
| --- | --- |
|
||||
| **第一趟 · 试选** | 开商品 → 精确勾选颜色分类和尺码 → 读单价 → 截图 → **退出释放手机** → 回传 |
|
||||
| **人工确认** | 人在网页端看「机器选对了吗」→ 确认并**锁定单价** |
|
||||
| **第二趟 · 下单** | 重新开商品 → 重新选同一规格 → **三道价格闸门** → 提交订单一次 → 转「待付款」 |
|
||||
|
||||
为什么分两趟:一台手机是瓶颈,不能停在规格面板上等人。代价是走两遍,换来手机不空闲,
|
||||
且第二趟能抓住价格变动。
|
||||
|
||||
**三道价格闸门**:① 第一趟规格面板读价 ② 第二趟重读必须与授权价一致
|
||||
③ 订单确认页「实付款」不超上限。任一道读不到或不通过即停,转人工。
|
||||
采购工具领取后在同一次设备会话中完成:打开商品 → 精确选择规格 → 闸门一读 SKU 单价并校验
|
||||
上限 → 设置并复核数量 → 闸门二重读规格与同价 → 进入确认页 → 闸门三校验应付总额 → 服务端
|
||||
原子建立提交围栏 → 精确点击一次“提交订单” → 转待付款。中间不再回网页端等“机器选对了吗”。
|
||||
|
||||
价格**只在规格面板和订单确认页读**——别处的价格文本被拆成多个节点、带券后前缀、
|
||||
实付价与原价混在一起,不可靠。
|
||||
|
||||
## 现在处于什么阶段
|
||||
|
||||
**Phase 0 · 地基。仓库目前只有文档,尚未开始编码。**
|
||||
|
||||
下一步:T-005(网页端 MVP 原型)与 T-006(桌面端 MVP 原型)的文件和自动检查已完成,
|
||||
请先人工确认 6 个 HTML 原型,再进入 T-001 / T-002 骨架和生产实现。
|
||||
**Phase 1 真机取证与 Phase 2 的安全服务端工作并行。** 两端骨架、基础模型、登录和真机基线已
|
||||
落地;T-111 正在冻结单趟采购契约,T-103 随后继续规格精确选择与读价,T-202 等待主审合入。
|
||||
详见 [`docs/current-state.md`](docs/current-state.md)。
|
||||
|
||||
**生死线是 M2**:真机能按链接打开商品、精确勾选颜色分类和尺码、**读到该 SKU 单价**。
|
||||
@@ -74,7 +66,7 @@ MVP 只做**任务自带商品链接**的情形,分两趟跑完:
|
||||
4. 规格按维度精确匹配,防前缀碰撞,找不到即停
|
||||
5. 数量设置后必须读回复核
|
||||
6. **三道价格闸门**,任一道读不到或不通过即停
|
||||
7. **第一趟绝不下单**——试选路径不得引用下单函数
|
||||
7. **能力分层**——T-103 规格验证路径不得引用数量、确认页或下单函数;后续能力逐段取证
|
||||
8. 检测到外部支付交接立即停止,不读取不保存凭据
|
||||
9. 检测到验证码 / 风控 / 人脸 / 短信校验立即停止,不绕过
|
||||
10. 只读非敏感摘要,不提取收货地址原文、手机号、支付凭据
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
# 采购服务
|
||||
|
||||
启动前必须显式设置下列环境变量;服务不提供默认管理员账号、密码或会话密钥。
|
||||
|
||||
| 变量 | 要求 |
|
||||
| --- | --- |
|
||||
| `CMBUYER_ADMIN_USERNAME` | 非空管理员账号。 |
|
||||
| `CMBUYER_ADMIN_PASSWORD_BCRYPT` | 非空 bcrypt 密码哈希,不接受明文密码。 |
|
||||
| `CMBUYER_SESSION_SECRET` | 至少 32 字节的会话签名密钥。 |
|
||||
| `CMBUYER_COOKIE_SECURE` | 可选;存在时只能精确为 `true` 或 `false`。HTTPS 部署应设为 `true`。 |
|
||||
| `CMBUYER_DATABASE_SOURCE` | 已迁移 SQLite 的显式 data source。 |
|
||||
| `CMBUYER_AUTHORIZATION_TTL` | 一次性授权的正 Go duration,例如 `10m`。 |
|
||||
| `CMBUYER_MAX_TASK_QUANTITY` | 每条任务允许的正整数数量上限。 |
|
||||
| `CMBUYER_MAX_TOTAL_PRICE` | 每条任务允许的规范正数总价上限,例如 `999.99`。 |
|
||||
| `CMBUYER_EVIDENCE_DIR` | 内部原始截图的绝对私有目录;不得指向仓库或公开静态目录。 |
|
||||
|
||||
示例仅展示变量名,不提供可运行凭据:
|
||||
|
||||
```powershell
|
||||
$env:CMBUYER_ADMIN_USERNAME = '<管理员账号>'
|
||||
$env:CMBUYER_ADMIN_PASSWORD_BCRYPT = '<bcrypt 密码哈希>'
|
||||
$env:CMBUYER_SESSION_SECRET = '<至少 32 字节的随机密钥>'
|
||||
$env:CMBUYER_COOKIE_SECURE = 'true'
|
||||
$env:CMBUYER_DATABASE_SOURCE = '<SQLite data source>'
|
||||
$env:CMBUYER_AUTHORIZATION_TTL = '10m'
|
||||
$env:CMBUYER_MAX_TASK_QUANTITY = '99'
|
||||
$env:CMBUYER_MAX_TOTAL_PRICE = '999.99'
|
||||
$env:CMBUYER_EVIDENCE_DIR = '<内部截图绝对目录>'
|
||||
go run ./cmd/migrate -database $env:CMBUYER_DATABASE_SOURCE up
|
||||
go run ./cmd/server
|
||||
```
|
||||
|
||||
MVP 服务固定绑定 IPv4 回环 `127.0.0.1:8080`,只供同一运营电脑上的采购服务和采购工具使用。不要把监听地址
|
||||
改成 `0.0.0.0` 或局域网地址;未来若需要非回环访问,必须先单独建立并验收 HTTPS/TLS 终止与代理
|
||||
信任边界,设备 Bearer 不得经过明文局域网。
|
||||
|
||||
数据库迁移完成后,用同一个显式 SQLite data source 管理设备凭据:
|
||||
|
||||
```powershell
|
||||
# 签发:token 只在本次成功输出中显示一次,请立即放入采购工具的受控本机配置。
|
||||
go run ./cmd/device-credentials -database $env:CMBUYER_DATABASE_SOURCE issue -name '<非秘密设备名称>'
|
||||
|
||||
# 仅显示设备 id、名称、状态和时间,不显示 token/hash。
|
||||
go run ./cmd/device-credentials -database $env:CMBUYER_DATABASE_SOURCE list
|
||||
|
||||
# 撤销立即影响之后开始的每次设备请求;重复执行保持 REVOKED,不恢复旧 token。
|
||||
go run ./cmd/device-credentials -database $env:CMBUYER_DATABASE_SOURCE revoke -device-id '<签发时的设备UUID>'
|
||||
```
|
||||
|
||||
该 CLI 只接受已存在、已迁移的文件型 SQLite 普通路径或 `file:` URI,并强制以 `mode=rw` 打开;
|
||||
路径拼错或文件缺失时 SQLite 原子拒绝且不会留下空数据库,也不接受内存、只读或可创建模式。CLI
|
||||
不自动迁移。签发 token 是 32 字节加密随机值的 64 位小写十六进制表示;
|
||||
SQLite 只保存原始 token 的 32 字节 SHA-256 BLOB。签发输出以外的 list/revoke、日志、错误和 HTTP
|
||||
响应都不会显示 token 或 hash。
|
||||
|
||||
采购服务会话仅保存在当前进程内;进程重启后既有登录会话会安全失效。
|
||||
管理员的“开始采购(只创建待付款订单)”只签发一次性授权并创建待付款订单的资格;服务不会自动付款,也不包含任何支付操作。
|
||||
|
||||
`GET /tasks/{id}` 直接访问时渲染完整详情页,任务列表以同一 URL 加载详情抽屉。内部截图只通过
|
||||
`GET /evidence/{asset_id}` 向有效管理员会话提供,并始终返回 `no-store`;文件不在静态目录中。
|
||||
|
||||
`POST /api/v1/tasks/{id}/evidence` 使用 `Authorization: Bearer <token>` 和
|
||||
`X-CMBuyer-Device-ID: <小写UUIDv4>` 逐请求查库认证;管理员会话不能代替设备身份。凭据错误统一空
|
||||
401,SQLite 认证故障为空 503,且两者都发生在上传 body 被读取之前。
|
||||
@@ -0,0 +1,200 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"cmbuyer/admin/internal/deviceauth"
|
||||
"cmbuyer/admin/internal/storage/sqlite"
|
||||
)
|
||||
|
||||
func main() {
|
||||
if err := run(context.Background(), os.Args[1:], os.Stdout, os.Stderr); err != nil {
|
||||
log.Print(err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func run(ctx context.Context, args []string, stdout, stderr io.Writer) error {
|
||||
flags := flag.NewFlagSet("device-credentials", flag.ContinueOnError)
|
||||
flags.SetOutput(stderr)
|
||||
databaseSource := flags.String("database", "", "explicit migrated SQLite data source")
|
||||
if err := flags.Parse(args); err != nil {
|
||||
return err
|
||||
}
|
||||
if *databaseSource == "" {
|
||||
return errors.New("-database is required")
|
||||
}
|
||||
if flags.NArg() < 1 {
|
||||
return errors.New("usage: device-credentials -database <sqlite-data-source> <issue|list|revoke> [options]")
|
||||
}
|
||||
command := flags.Arg(0)
|
||||
commandArgs := flags.Args()[1:]
|
||||
var issueName, revokeDeviceID string
|
||||
switch command {
|
||||
case "issue":
|
||||
commandFlags := flag.NewFlagSet("issue", flag.ContinueOnError)
|
||||
commandFlags.SetOutput(stderr)
|
||||
commandFlags.StringVar(&issueName, "name", "", "non-secret device display name")
|
||||
if err := commandFlags.Parse(commandArgs); err != nil {
|
||||
return err
|
||||
}
|
||||
if issueName == "" || commandFlags.NArg() != 0 {
|
||||
return errors.New("usage: device-credentials -database <sqlite-data-source> issue -name <display-name>")
|
||||
}
|
||||
case "list":
|
||||
if len(commandArgs) != 0 {
|
||||
return errors.New("usage: device-credentials -database <sqlite-data-source> list")
|
||||
}
|
||||
case "revoke":
|
||||
commandFlags := flag.NewFlagSet("revoke", flag.ContinueOnError)
|
||||
commandFlags.SetOutput(stderr)
|
||||
commandFlags.StringVar(&revokeDeviceID, "device-id", "", "canonical device UUID")
|
||||
if err := commandFlags.Parse(commandArgs); err != nil {
|
||||
return err
|
||||
}
|
||||
if revokeDeviceID == "" || commandFlags.NArg() != 0 {
|
||||
return errors.New("usage: device-credentials -database <sqlite-data-source> revoke -device-id <uuid>")
|
||||
}
|
||||
default:
|
||||
return fmt.Errorf("unsupported device credential command %q", command)
|
||||
}
|
||||
if command == "issue" && !deviceauth.ValidDisplayName(issueName) {
|
||||
return deviceauth.ErrInvalidCredential
|
||||
}
|
||||
if command == "revoke" && !deviceauth.ValidDeviceID(revokeDeviceID) {
|
||||
return deviceauth.ErrInvalidCredential
|
||||
}
|
||||
|
||||
existingSource, err := existingSQLiteDataSource(*databaseSource)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
database, err := sqlite.Open(existingSource)
|
||||
if err != nil {
|
||||
return fmt.Errorf("open SQLite database: %w", err)
|
||||
}
|
||||
defer database.Close()
|
||||
store, err := deviceauth.NewCredentialStore(database)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
switch command {
|
||||
case "issue":
|
||||
issued, err := store.Issue(ctx, issueName)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// The token has json:"-" and is printed only by this explicit post-commit path. Generic
|
||||
// serialization, list, revoke, errors, and server responses therefore cannot disclose it.
|
||||
if _, err := fmt.Fprintf(stdout, "device_id=%s\ndisplay_name=%s\ntoken=%s\ncreated_at=%s\n",
|
||||
issued.DeviceID, issued.DisplayName, issued.Token, issued.CreatedAt.Format(time.RFC3339Nano)); err != nil {
|
||||
return errors.New("write issued device credential")
|
||||
}
|
||||
return nil
|
||||
case "list":
|
||||
credentials, err := store.List(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return writeJSON(stdout, credentials)
|
||||
case "revoke":
|
||||
credential, changed, err := store.Revoke(ctx, revokeDeviceID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return writeJSON(stdout, struct {
|
||||
Credential deviceauth.Credential `json:"credential"`
|
||||
RevokedNow bool `json:"revoked_now"`
|
||||
}{Credential: credential, RevokedNow: changed})
|
||||
}
|
||||
return errors.New("unreachable device credential command")
|
||||
}
|
||||
|
||||
func existingSQLiteDataSource(value string) (string, error) {
|
||||
if value == "" || strings.TrimSpace(value) != value {
|
||||
return "", errors.New("-database must name an existing file-backed SQLite database")
|
||||
}
|
||||
|
||||
var parsed *url.URL
|
||||
var query url.Values
|
||||
if strings.HasPrefix(strings.ToLower(value), "file:") {
|
||||
var err error
|
||||
parsed, err = url.Parse(value)
|
||||
if err != nil || !strings.EqualFold(parsed.Scheme, "file") || parsed.User != nil || parsed.Host != "" || parsed.Fragment != "" {
|
||||
return "", errors.New("-database file URI is invalid")
|
||||
}
|
||||
// go-sqlite3 recognizes URI filenames only with the exact lowercase file: prefix.
|
||||
// Canonicalize accepted scheme casing before mode=rw reaches the driver, otherwise a
|
||||
// mixed-case input could be treated as a plain filename and recreate a missing database.
|
||||
parsed.Scheme = "file"
|
||||
query, err = url.ParseQuery(parsed.RawQuery)
|
||||
if err != nil {
|
||||
return "", errors.New("-database query parameters are invalid")
|
||||
}
|
||||
fileName := parsed.Path
|
||||
if parsed.Opaque != "" {
|
||||
fileName = parsed.Opaque
|
||||
}
|
||||
decodedName, err := url.PathUnescape(fileName)
|
||||
if err != nil || fileName == "" || strings.EqualFold(decodedName, ":memory:") {
|
||||
return "", errors.New("-database must name an existing file-backed SQLite database")
|
||||
}
|
||||
} else {
|
||||
pathPart, rawQuery, hasQuery := strings.Cut(value, "?")
|
||||
if pathPart == "" || strings.EqualFold(pathPart, ":memory:") || strings.Contains(pathPart, "://") {
|
||||
return "", errors.New("-database must name an existing file-backed SQLite database")
|
||||
}
|
||||
var err error
|
||||
query, err = url.ParseQuery(rawQuery)
|
||||
if err != nil {
|
||||
return "", errors.New("-database query parameters are invalid")
|
||||
}
|
||||
normalizedPath := filepath.ToSlash(pathPart)
|
||||
if filepath.VolumeName(pathPart) != "" && !strings.HasPrefix(normalizedPath, "/") {
|
||||
normalizedPath = "/" + normalizedPath
|
||||
}
|
||||
parsed = &url.URL{Scheme: "file", Path: normalizedPath}
|
||||
if !hasQuery {
|
||||
query = make(url.Values)
|
||||
}
|
||||
}
|
||||
|
||||
modes := query["mode"]
|
||||
if len(modes) > 1 || len(modes) == 1 && modes[0] != "rw" {
|
||||
return "", errors.New("-database only permits SQLite mode=rw")
|
||||
}
|
||||
if len(modes) == 0 {
|
||||
query.Set("mode", "rw")
|
||||
}
|
||||
for _, name := range []string{"immutable", "_query_only"} {
|
||||
for _, setting := range query[name] {
|
||||
if setting != "0" && !strings.EqualFold(setting, "false") {
|
||||
return "", errors.New("-database contains a read-only SQLite option")
|
||||
}
|
||||
}
|
||||
}
|
||||
parsed.RawQuery = query.Encode()
|
||||
parsed.ForceQuery = false
|
||||
return parsed.String(), nil
|
||||
}
|
||||
|
||||
func writeJSON(writer io.Writer, value any) error {
|
||||
encoder := json.NewEncoder(writer)
|
||||
encoder.SetEscapeHTML(true)
|
||||
if err := encoder.Encode(value); err != nil {
|
||||
return errors.New("write device credential metadata")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,293 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"io"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"cmbuyer/admin/internal/deviceauth"
|
||||
"cmbuyer/admin/internal/migrations"
|
||||
"cmbuyer/admin/internal/storage/sqlite"
|
||||
)
|
||||
|
||||
func TestIssueListAndIdempotentRevokeNeverRediscloseSecret(t *testing.T) {
|
||||
databaseSource := migratedDatabase(t)
|
||||
var issued bytes.Buffer
|
||||
if err := run(context.Background(), []string{"-database", databaseSource, "issue", "-name", "采购工具一号"}, &issued, io.Discard); err != nil {
|
||||
t.Fatalf("issue: %v", err)
|
||||
}
|
||||
fields := outputFields(t, issued.String())
|
||||
deviceID, token := fields["device_id"], fields["token"]
|
||||
if len(token) != 64 || strings.Count(issued.String(), token) != 1 {
|
||||
t.Fatalf("issue token occurrence/length = %d/%d", strings.Count(issued.String(), token), len(token))
|
||||
}
|
||||
|
||||
var listed bytes.Buffer
|
||||
if err := run(context.Background(), []string{"-database", databaseSource, "list"}, &listed, io.Discard); err != nil {
|
||||
t.Fatalf("list: %v", err)
|
||||
}
|
||||
assertNoSecretMetadata(t, listed.String(), token)
|
||||
if !strings.Contains(listed.String(), deviceID) || !strings.Contains(listed.String(), "采购工具一号") {
|
||||
t.Fatalf("list omitted safe metadata: %s", listed.String())
|
||||
}
|
||||
|
||||
var revoked bytes.Buffer
|
||||
if err := run(context.Background(), []string{"-database", databaseSource, "revoke", "-device-id", deviceID}, &revoked, io.Discard); err != nil {
|
||||
t.Fatalf("revoke: %v", err)
|
||||
}
|
||||
assertNoSecretMetadata(t, revoked.String(), token)
|
||||
if !strings.Contains(revoked.String(), `"revoked_now":true`) {
|
||||
t.Fatalf("first revoke output = %s", revoked.String())
|
||||
}
|
||||
var repeated bytes.Buffer
|
||||
if err := run(context.Background(), []string{"-database", databaseSource, "revoke", "-device-id", deviceID}, &repeated, io.Discard); err != nil {
|
||||
t.Fatalf("repeat revoke: %v", err)
|
||||
}
|
||||
assertNoSecretMetadata(t, repeated.String(), token)
|
||||
if !strings.Contains(repeated.String(), `"revoked_now":false`) {
|
||||
t.Fatalf("repeat revoke output = %s", repeated.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestIssueOutputFailureLeavesCommittedCredentialWithoutSecretInError(t *testing.T) {
|
||||
databaseSource := migratedDatabase(t)
|
||||
writer := &recordingFailureWriter{}
|
||||
err := run(context.Background(), []string{"-database", databaseSource, "issue", "-name", "output failure"}, writer, io.Discard)
|
||||
if err == nil || err.Error() != "write issued device credential" {
|
||||
t.Fatalf("issue output failure error = %v", err)
|
||||
}
|
||||
fields := outputFields(t, writer.contents.String())
|
||||
if strings.Contains(err.Error(), fields["token"]) {
|
||||
t.Fatal("output error disclosed token")
|
||||
}
|
||||
database, err := sql.Open("sqlite3", databaseSource)
|
||||
if err != nil {
|
||||
t.Fatalf("open database: %v", err)
|
||||
}
|
||||
defer database.Close()
|
||||
var count int
|
||||
if err := database.QueryRow(`SELECT COUNT(*) FROM device_credentials`).Scan(&count); err != nil || count != 1 {
|
||||
t.Fatalf("committed credential count = %d, err=%v", count, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCLIRequiresPreMigratedExplicitDatabase(t *testing.T) {
|
||||
if err := run(context.Background(), []string{"list"}, io.Discard, io.Discard); err == nil {
|
||||
t.Fatal("command without -database succeeded")
|
||||
}
|
||||
missing := filepath.Join(t.TempDir(), "missing.db")
|
||||
if err := run(context.Background(), []string{"-database", missing, "list"}, io.Discard, io.Discard); err == nil {
|
||||
t.Fatal("list opened a missing database")
|
||||
}
|
||||
if _, err := os.Stat(missing); !errors.Is(err, os.ErrNotExist) {
|
||||
t.Fatalf("missing database was created: %v", err)
|
||||
}
|
||||
|
||||
unmigrated := filepath.Join(t.TempDir(), "unmigrated.db")
|
||||
unmigratedDatabase, err := sqlite.Open(unmigrated)
|
||||
if err != nil {
|
||||
t.Fatalf("create unmigrated database: %v", err)
|
||||
}
|
||||
if _, err := unmigratedDatabase.Exec(`CREATE TABLE unrelated (id INTEGER)`); err != nil {
|
||||
_ = unmigratedDatabase.Close()
|
||||
t.Fatalf("initialize unmigrated database: %v", err)
|
||||
}
|
||||
if err := unmigratedDatabase.Close(); err != nil {
|
||||
t.Fatalf("close unmigrated database: %v", err)
|
||||
}
|
||||
if err := run(context.Background(), []string{"-database", unmigrated, "list"}, io.Discard, io.Discard); err == nil {
|
||||
t.Fatal("list accepted an unmigrated database")
|
||||
}
|
||||
database, err := sql.Open("sqlite3", unmigrated)
|
||||
if err != nil {
|
||||
t.Fatalf("open unmigrated database: %v", err)
|
||||
}
|
||||
defer database.Close()
|
||||
var count int
|
||||
if err := database.QueryRow(`SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='device_credentials'`).Scan(&count); err != nil || count != 0 {
|
||||
t.Fatalf("device_credentials table count = %d, err=%v", count, err)
|
||||
}
|
||||
|
||||
undeclared := filepath.Join(t.TempDir(), "undeclared.db")
|
||||
if err := run(context.Background(), []string{"-database", undeclared, "rotate"}, io.Discard, io.Discard); err == nil {
|
||||
t.Fatal("undeclared command succeeded")
|
||||
}
|
||||
if _, err := os.Stat(undeclared); !errors.Is(err, os.ErrNotExist) {
|
||||
t.Fatalf("undeclared command opened database: %v", err)
|
||||
}
|
||||
|
||||
invalidIssue := filepath.Join(t.TempDir(), "invalid-issue.db")
|
||||
if err := run(context.Background(), []string{"-database", invalidIssue, "issue", "-name", " padded"}, io.Discard, io.Discard); !errors.Is(err, deviceauth.ErrInvalidCredential) {
|
||||
t.Fatalf("invalid issue error = %v", err)
|
||||
}
|
||||
if _, err := os.Stat(invalidIssue); !errors.Is(err, os.ErrNotExist) {
|
||||
t.Fatalf("invalid issue opened database: %v", err)
|
||||
}
|
||||
|
||||
invalidRevoke := filepath.Join(t.TempDir(), "invalid-revoke.db")
|
||||
if err := run(context.Background(), []string{"-database", invalidRevoke, "revoke", "-device-id", "not-a-uuid"}, io.Discard, io.Discard); !errors.Is(err, deviceauth.ErrInvalidCredential) {
|
||||
t.Fatalf("invalid revoke error = %v", err)
|
||||
}
|
||||
if _, err := os.Stat(invalidRevoke); !errors.Is(err, os.ErrNotExist) {
|
||||
t.Fatalf("invalid revoke opened database: %v", err)
|
||||
}
|
||||
|
||||
migrated := migratedDatabase(t)
|
||||
unknownID := "13c9f507-7473-4fa6-8d71-8786c34c6301"
|
||||
var unknownOutput bytes.Buffer
|
||||
err = run(context.Background(), []string{"-database", migrated, "revoke", "-device-id", unknownID}, &unknownOutput, io.Discard)
|
||||
if !errors.Is(err, deviceauth.ErrCredentialNotFound) || unknownOutput.Len() != 0 || strings.Contains(err.Error(), unknownID) {
|
||||
t.Fatalf("unknown revoke = output %q, error %v", unknownOutput.String(), err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExistingSQLiteDataSourcePreservesSafeOptionsAndRejectsCreationModes(t *testing.T) {
|
||||
databaseSource := migratedDatabase(t)
|
||||
fileURI := (&url.URL{
|
||||
Scheme: "file",
|
||||
Path: sqliteURIPath(databaseSource),
|
||||
RawQuery: "_busy_timeout=5000&cache=shared",
|
||||
}).String()
|
||||
normalized, err := existingSQLiteDataSource(fileURI)
|
||||
if err != nil {
|
||||
t.Fatalf("normalize file URI: %v", err)
|
||||
}
|
||||
parsed, err := url.Parse(normalized)
|
||||
if err != nil {
|
||||
t.Fatalf("parse normalized URI: %v", err)
|
||||
}
|
||||
query := parsed.Query()
|
||||
if query.Get("mode") != "rw" || query.Get("_busy_timeout") != "5000" || query.Get("cache") != "shared" {
|
||||
t.Fatalf("normalized query = %v", query)
|
||||
}
|
||||
if err := run(context.Background(), []string{"-database", fileURI, "list"}, io.Discard, io.Discard); err != nil {
|
||||
t.Fatalf("list existing file URI: %v", err)
|
||||
}
|
||||
|
||||
plainNormalized, err := existingSQLiteDataSource(databaseSource + "?_foreign_keys=on")
|
||||
if err != nil {
|
||||
t.Fatalf("normalize ordinary path: %v", err)
|
||||
}
|
||||
plainURI, err := url.Parse(plainNormalized)
|
||||
if err != nil || plainURI.Scheme != "file" || plainURI.Query().Get("mode") != "rw" || plainURI.Query().Get("_foreign_keys") != "on" {
|
||||
t.Fatalf("ordinary path normalization = %q, err=%v", plainNormalized, err)
|
||||
}
|
||||
|
||||
missing := filepath.Join(t.TempDir(), "missing-uri.db")
|
||||
missingURI := (&url.URL{Scheme: "file", Path: sqliteURIPath(missing)}).String()
|
||||
if err := run(context.Background(), []string{"-database", missingURI, "list"}, io.Discard, io.Discard); err == nil {
|
||||
t.Fatal("missing file URI succeeded")
|
||||
}
|
||||
if _, err := os.Stat(missing); !errors.Is(err, os.ErrNotExist) {
|
||||
t.Fatalf("missing file URI created a file: %v", err)
|
||||
}
|
||||
for _, scheme := range []string{"FILE", "File"} {
|
||||
mixedMissing := filepath.Join(t.TempDir(), strings.ToLower(scheme)+"-missing.db")
|
||||
canonical := (&url.URL{Scheme: "file", Path: sqliteURIPath(mixedMissing)}).String()
|
||||
mixedURI := scheme + canonical[len("file"):]
|
||||
normalized, err := existingSQLiteDataSource(mixedURI)
|
||||
if err != nil || !strings.HasPrefix(normalized, "file:") {
|
||||
t.Fatalf("normalize %s URI = %q, err=%v", scheme, normalized, err)
|
||||
}
|
||||
if err := run(context.Background(), []string{"-database", mixedURI, "list"}, io.Discard, io.Discard); err == nil {
|
||||
t.Fatalf("missing %s URI succeeded", scheme)
|
||||
}
|
||||
if _, err := os.Stat(mixedMissing); !errors.Is(err, os.ErrNotExist) {
|
||||
t.Fatalf("missing %s URI created a file: %v", scheme, err)
|
||||
}
|
||||
}
|
||||
|
||||
for name, source := range map[string]string{
|
||||
"plain memory": ":memory:",
|
||||
"URI memory": "file::memory:?cache=shared",
|
||||
"memory mode": fileURI + "&mode=memory",
|
||||
"read only mode": fileURI + "&mode=ro",
|
||||
"create mode": fileURI + "&mode=rwc",
|
||||
"duplicate mode": fileURI + "&mode=rw&mode=rw",
|
||||
"immutable": fileURI + "&immutable=1",
|
||||
"query only": fileURI + "&_query_only=1",
|
||||
"remote authority": "file://server/share/database.db?mode=rw",
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
if _, err := existingSQLiteDataSource(source); err == nil {
|
||||
t.Fatalf("unsafe source accepted: %q", source)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func sqliteURIPath(path string) string {
|
||||
normalized := filepath.ToSlash(path)
|
||||
if filepath.VolumeName(path) != "" && !strings.HasPrefix(normalized, "/") {
|
||||
return "/" + normalized
|
||||
}
|
||||
return normalized
|
||||
}
|
||||
|
||||
type recordingFailureWriter struct {
|
||||
contents bytes.Buffer
|
||||
}
|
||||
|
||||
func (writer *recordingFailureWriter) Write(value []byte) (int, error) {
|
||||
_, _ = writer.contents.Write(value)
|
||||
return 0, errors.New("injected stdout failure")
|
||||
}
|
||||
|
||||
func assertNoSecretMetadata(t *testing.T, output, token string) {
|
||||
t.Helper()
|
||||
if strings.Contains(output, token) || strings.Contains(output, "token") || strings.Contains(output, "hash") || strings.Contains(output, "sha256") {
|
||||
t.Fatalf("metadata output disclosed secret material: %s", output)
|
||||
}
|
||||
}
|
||||
|
||||
func outputFields(t *testing.T, output string) map[string]string {
|
||||
t.Helper()
|
||||
fields := make(map[string]string)
|
||||
for _, line := range strings.Split(strings.TrimSpace(output), "\n") {
|
||||
name, value, found := strings.Cut(line, "=")
|
||||
if !found || name == "" || value == "" {
|
||||
t.Fatalf("invalid issue output line %q", line)
|
||||
}
|
||||
fields[name] = value
|
||||
}
|
||||
for _, required := range []string{"device_id", "display_name", "token", "created_at"} {
|
||||
if fields[required] == "" {
|
||||
t.Fatalf("issue output missing %s: %q", required, output)
|
||||
}
|
||||
}
|
||||
return fields
|
||||
}
|
||||
|
||||
func migratedDatabase(t *testing.T) string {
|
||||
t.Helper()
|
||||
databaseSource := filepath.Join(t.TempDir(), "credentials.db")
|
||||
database, err := sqlite.Open(databaseSource)
|
||||
if err != nil {
|
||||
t.Fatalf("open database: %v", err)
|
||||
}
|
||||
if err := migrations.Up(context.Background(), database, commandMigrationDirectory(t)); err != nil {
|
||||
_ = database.Close()
|
||||
t.Fatalf("migrate database: %v", err)
|
||||
}
|
||||
if err := database.Close(); err != nil {
|
||||
t.Fatalf("close migrated database: %v", err)
|
||||
}
|
||||
return databaseSource
|
||||
}
|
||||
|
||||
func commandMigrationDirectory(t *testing.T) string {
|
||||
t.Helper()
|
||||
_, file, _, ok := runtime.Caller(0)
|
||||
if !ok {
|
||||
t.Fatal("locate migrations")
|
||||
}
|
||||
return filepath.Join(filepath.Dir(file), "..", "..", "migrations")
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"os"
|
||||
|
||||
"cmbuyer/admin/internal/migrations"
|
||||
"cmbuyer/admin/internal/storage/sqlite"
|
||||
)
|
||||
|
||||
func main() {
|
||||
if err := run(context.Background(), os.Args[1:], os.Stderr); err != nil {
|
||||
log.Print(err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func run(ctx context.Context, args []string, stderr io.Writer) error {
|
||||
flags := flag.NewFlagSet("migrate", flag.ContinueOnError)
|
||||
flags.SetOutput(stderr)
|
||||
databaseSource := flags.String("database", "", "SQLite data source")
|
||||
migrationDirectory := flags.String("dir", "migrations", "migration directory")
|
||||
|
||||
if err := flags.Parse(args); err != nil {
|
||||
return err
|
||||
}
|
||||
if *databaseSource == "" {
|
||||
return errors.New("-database is required")
|
||||
}
|
||||
if flags.NArg() != 1 {
|
||||
return fmt.Errorf("usage: migrate -database <sqlite-data-source> [-dir <migration-directory>] <up|down|status>")
|
||||
}
|
||||
command := flags.Arg(0)
|
||||
if command != "up" && command != "down" && command != "status" {
|
||||
return fmt.Errorf("unsupported migration command %q", command)
|
||||
}
|
||||
|
||||
database, err := sqlite.Open(*databaseSource)
|
||||
if err != nil {
|
||||
return fmt.Errorf("open SQLite database: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
if err := database.Close(); err != nil {
|
||||
log.Printf("close SQLite database: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
if err := migrations.Run(ctx, database, *migrationDirectory, command); err != nil {
|
||||
return fmt.Errorf("run migrations: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestRunUp(t *testing.T) {
|
||||
databaseSource := filepath.Join(t.TempDir(), "migrate.db")
|
||||
if err := run(context.Background(), []string{
|
||||
"-database", databaseSource,
|
||||
"-dir", migrationDirectory(t),
|
||||
"up",
|
||||
}, io.Discard); err != nil {
|
||||
t.Fatalf("run up migration command: %v", err)
|
||||
}
|
||||
|
||||
database, err := sql.Open("sqlite3", databaseSource)
|
||||
if err != nil {
|
||||
t.Fatalf("open migrated database: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
if err := database.Close(); err != nil {
|
||||
t.Errorf("close migrated database: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
var count int
|
||||
if err := database.QueryRow(`SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = 'tasks'`).Scan(&count); err != nil {
|
||||
t.Fatalf("look up tasks table: %v", err)
|
||||
}
|
||||
if count != 1 {
|
||||
t.Fatalf("tasks table count = %d, want 1", count)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunRequiresDatabase(t *testing.T) {
|
||||
if err := run(context.Background(), []string{"up"}, io.Discard); err == nil {
|
||||
t.Fatal("run without database source succeeded")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunRejectsUndeclaredCommand(t *testing.T) {
|
||||
databaseSource := filepath.Join(t.TempDir(), "migrate.db")
|
||||
err := run(context.Background(), []string{"-database", databaseSource, "reset"}, io.Discard)
|
||||
if err == nil {
|
||||
t.Fatal("run with undeclared command succeeded")
|
||||
}
|
||||
if _, err := os.Stat(databaseSource); !errors.Is(err, os.ErrNotExist) {
|
||||
t.Fatalf("undeclared command opened database source: stat error = %v, want not exist", err)
|
||||
}
|
||||
}
|
||||
|
||||
func migrationDirectory(t *testing.T) string {
|
||||
t.Helper()
|
||||
_, file, _, ok := runtime.Caller(0)
|
||||
if !ok {
|
||||
t.Fatal("locate migration command test source")
|
||||
}
|
||||
|
||||
return filepath.Join(filepath.Dir(file), "..", "..", "migrations")
|
||||
}
|
||||
@@ -5,10 +5,19 @@ import (
|
||||
"log"
|
||||
"net/http"
|
||||
|
||||
"cmbuyer/admin/internal/auth"
|
||||
"cmbuyer/admin/internal/config"
|
||||
"cmbuyer/admin/internal/deviceauth"
|
||||
"cmbuyer/admin/internal/server"
|
||||
evidencestorage "cmbuyer/admin/internal/storage/evidence"
|
||||
"cmbuyer/admin/internal/storage/sqlite"
|
||||
"cmbuyer/admin/internal/taskdetail"
|
||||
"cmbuyer/admin/internal/tasks"
|
||||
)
|
||||
|
||||
const listenAddress = ":8080"
|
||||
// Device Bearer credentials must not cross a plaintext LAN. The MVP is a same-computer
|
||||
// deployment, so widening this address requires a separately reviewed TLS boundary first.
|
||||
const listenAddress = "127.0.0.1:8080"
|
||||
|
||||
func main() {
|
||||
if err := run(); err != nil {
|
||||
@@ -17,7 +26,47 @@ func main() {
|
||||
}
|
||||
|
||||
func run() error {
|
||||
err := http.ListenAndServe(listenAddress, server.NewRouter())
|
||||
configuration, err := config.LoadFromEnv()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
database, err := sqlite.Open(configuration.DatabaseSource)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer database.Close()
|
||||
taskStore, err := tasks.NewSQLiteStore(database)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
taskStore.SetStartPolicy(tasks.StartPolicy{AuthorizationTTL: configuration.AuthorizationTTL, MaxQuantity: configuration.MaxTaskQuantity, MaxTotalPrice: configuration.MaxTotalPrice})
|
||||
detailStore, err := taskdetail.NewSQLiteStore(database)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
evidenceStore, err := evidencestorage.NewStore(database, configuration.EvidenceDirectory)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
deviceAuthenticator, err := deviceauth.NewSQLiteAuthenticator(database)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
router, err := server.NewRouter(server.Options{
|
||||
AdminUsername: configuration.AdminUsername,
|
||||
AdminPasswordBcrypt: configuration.AdminPasswordBcrypt,
|
||||
Sessions: auth.NewManager(configuration.SessionSecret, configuration.CookieSecure),
|
||||
Tasks: taskStore,
|
||||
TaskDetails: detailStore,
|
||||
Evidence: evidenceStore,
|
||||
DeviceAuthenticator: deviceAuthenticator,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = http.ListenAndServe(listenAddress, router)
|
||||
if errors.Is(err, http.ErrServerClosed) {
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
package main
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestListenAddressIsIPv4LoopbackOnly(t *testing.T) {
|
||||
if listenAddress != "127.0.0.1:8080" {
|
||||
t.Fatalf("listenAddress = %q, want loopback-only endpoint", listenAddress)
|
||||
}
|
||||
}
|
||||
+5
-1
@@ -5,6 +5,8 @@ go 1.23.0
|
||||
require (
|
||||
github.com/gin-gonic/gin v1.11.0
|
||||
github.com/mattn/go-sqlite3 v1.14.49
|
||||
github.com/pressly/goose/v3 v3.24.0
|
||||
golang.org/x/crypto v0.40.0
|
||||
)
|
||||
|
||||
require (
|
||||
@@ -22,16 +24,18 @@ require (
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
|
||||
github.com/leodido/go-urn v1.4.0 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/mfridman/interpolate v0.0.2 // indirect
|
||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 // indirect
|
||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
|
||||
github.com/quic-go/qpack v0.5.1 // indirect
|
||||
github.com/quic-go/quic-go v0.54.0 // indirect
|
||||
github.com/sethvargo/go-retry v0.3.0 // indirect
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||
github.com/ugorji/go/codec v1.3.0 // indirect
|
||||
go.uber.org/mock v0.5.0 // indirect
|
||||
go.uber.org/multierr v1.11.0 // indirect
|
||||
golang.org/x/arch v0.20.0 // indirect
|
||||
golang.org/x/crypto v0.40.0 // indirect
|
||||
golang.org/x/mod v0.25.0 // indirect
|
||||
golang.org/x/net v0.42.0 // indirect
|
||||
golang.org/x/sync v0.16.0 // indirect
|
||||
|
||||
@@ -7,6 +7,8 @@ github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gE
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM=
|
||||
github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8=
|
||||
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
|
||||
@@ -28,6 +30,10 @@ github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7Lk
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
||||
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
|
||||
@@ -38,18 +44,28 @@ github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWE
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-sqlite3 v1.14.49 h1:B8jBHC3xhxZgxztrgruTuLucebnULQnx4W7cF7SAE9w=
|
||||
github.com/mattn/go-sqlite3 v1.14.49/go.mod h1:6JTjA44L93a0QCyJef5YvlPoKXntQPjzWv5gtm9sB6w=
|
||||
github.com/mfridman/interpolate v0.0.2 h1:pnuTK7MQIxxFz1Gr+rjSIx9u7qVjf5VOoM/u6BbAxPY=
|
||||
github.com/mfridman/interpolate v0.0.2/go.mod h1:p+7uk6oE07mpE/Ik1b8EckO0O4ZXiGAfshKBWLUM9Xg=
|
||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 h1:ZqeYNhU3OHLH3mGKHDcjJRFFRrJa6eAM5H+CtDdOsPc=
|
||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
|
||||
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
|
||||
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/pressly/goose/v3 v3.24.0 h1:sFbNms7Bd++2VMq6HSgDHDLWa7kHz1qXzPb3ZIU72VU=
|
||||
github.com/pressly/goose/v3 v3.24.0/go.mod h1:rEWreU9uVtt0DHCyLzF9gRcWiiTF/V+528DV+4DORug=
|
||||
github.com/quic-go/qpack v0.5.1 h1:giqksBPnT/HDtZ6VhtFKgoLOWmlyo9Ei6u9PqzIMbhI=
|
||||
github.com/quic-go/qpack v0.5.1/go.mod h1:+PC4XFrEskIVkcLzpEkbLqq1uCoxPhQuvK5rH1ZgaEg=
|
||||
github.com/quic-go/quic-go v0.54.0 h1:6s1YB9QotYI6Ospeiguknbp2Znb/jZYjZLRXn9kMQBg=
|
||||
github.com/quic-go/quic-go v0.54.0/go.mod h1:e68ZEaCdyviluZmy44P6Iey98v/Wfz6HCjQEm+l8zTY=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
github.com/sethvargo/go-retry v0.3.0 h1:EEt31A35QhrcRZtrYFDTBg91cqZVnFL2navjDrah2SE=
|
||||
github.com/sethvargo/go-retry v0.3.0/go.mod h1:mNX17F0C/HguQMyMyJxcnU471gOZGxCLyYaFyAZraas=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||
@@ -65,6 +81,8 @@ github.com/ugorji/go/codec v1.3.0 h1:Qd2W2sQawAfG8XSvzwhBeoGq71zXOC/Q1E9y/wUcsUA
|
||||
github.com/ugorji/go/codec v1.3.0/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
|
||||
go.uber.org/mock v0.5.0 h1:KAMbZvZPyBPWgD14IrIQ38QCyjwpvVVV6K/bHl1IwQU=
|
||||
go.uber.org/mock v0.5.0/go.mod h1:ge71pBPLYDk7QIi1LupWxdAykm7KIEFchiOqd6z7qMM=
|
||||
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
|
||||
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
|
||||
golang.org/x/arch v0.20.0 h1:dx1zTU0MAE98U+TQ8BLl7XsJbgze2WnNKF/8tGp/Q6c=
|
||||
golang.org/x/arch v0.20.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk=
|
||||
golang.org/x/crypto v0.40.0 h1:r4x+VvoG5Fm+eJcxMaY8CQM7Lb0l1lsmjGBQ6s8BfKM=
|
||||
@@ -88,3 +106,17 @@ gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6 h1:5D53IMaUuA5InSeMu9eJtlQXS2NxAhyWQvkKEgXZhHI=
|
||||
modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6/go.mod h1:Qz0X07sNOR1jWYCrJMEnbW/X55x206Q7Vt4mz6/wHp4=
|
||||
modernc.org/libc v1.55.3 h1:AzcW1mhlPNrRtjS5sS+eW2ISCgSOLLNyFzRh/V3Qj/U=
|
||||
modernc.org/libc v1.55.3/go.mod h1:qFXepLhz+JjFThQ4kzwzOjA/y/artDeg+pcYnY+Q83w=
|
||||
modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4=
|
||||
modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo=
|
||||
modernc.org/memory v1.8.0 h1:IqGTL6eFMaDZZhEWwcREgeMXYwmW83LYW8cROZYkg+E=
|
||||
modernc.org/memory v1.8.0/go.mod h1:XPZ936zp5OMKGWPqbD3JShgd/ZoQ7899TUuQqxY+peU=
|
||||
modernc.org/sqlite v1.34.1 h1:u3Yi6M0N8t9yKRDwhXcyp1eS5/ErhPTBggxWFuR6Hfk=
|
||||
modernc.org/sqlite v1.34.1/go.mod h1:pXV2xHxhzXZsgT/RtTFAPY6JJDEvOTcTdwADQCCWD4k=
|
||||
modernc.org/strutil v1.2.0 h1:agBi9dp1I+eOnxXeiZawM8F4LawKv4NzGWSaLfyeNZA=
|
||||
modernc.org/strutil v1.2.0/go.mod h1:/mdcBmfOibveCTBxUl5B5l6W+TTH1FXPLHZE6bTosX0=
|
||||
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
||||
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
// Package auth 提供内存会话与 CSRF 防护。会话不落库,服务重启会安全地使所有登录失效。
|
||||
package auth
|
||||
|
||||
import (
|
||||
"crypto/hmac"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"crypto/subtle"
|
||||
"encoding/base64"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
CookieName = "cmbuyer_session"
|
||||
SessionLifetime = 8 * time.Hour
|
||||
csrfTokenByteSize = 32
|
||||
)
|
||||
|
||||
type session struct {
|
||||
csrfToken string
|
||||
authenticated bool
|
||||
expiresAt time.Time
|
||||
}
|
||||
|
||||
// Manager 签发、验证并撤销进程内会话。cookie 仅承载经过 HMAC 签名的随机 session ID。
|
||||
type Manager struct {
|
||||
secret []byte
|
||||
cookieSecure bool
|
||||
now func() time.Time
|
||||
random io.Reader
|
||||
|
||||
mu sync.Mutex
|
||||
sessions map[string]session
|
||||
}
|
||||
|
||||
// NewManager 创建会话管理器。secret 在启动时已由 config 验证为足够长度。
|
||||
func NewManager(secret []byte, cookieSecure bool) *Manager {
|
||||
return &Manager{
|
||||
secret: append([]byte(nil), secret...),
|
||||
cookieSecure: cookieSecure,
|
||||
now: time.Now,
|
||||
random: rand.Reader,
|
||||
sessions: make(map[string]session),
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure 返回当前有效会话;不存在或过期时签发匿名会话,以保护登录表单本身的 POST。
|
||||
func (manager *Manager) Ensure(writer http.ResponseWriter, request *http.Request) (csrfToken string, authenticated bool) {
|
||||
if id, current, ok := manager.current(request); ok {
|
||||
return current.csrfToken, current.authenticated
|
||||
} else if id != "" {
|
||||
manager.delete(id)
|
||||
}
|
||||
|
||||
id, current := manager.create(false)
|
||||
manager.writeCookie(writer, id, current.expiresAt)
|
||||
return current.csrfToken, false
|
||||
}
|
||||
|
||||
// IsAuthenticated 只读检查当前请求是否持有有效管理会话;它不会像 Ensure 一样创建匿名会话。
|
||||
func (manager *Manager) IsAuthenticated(request *http.Request) bool {
|
||||
_, current, found := manager.current(request)
|
||||
return found && current.authenticated
|
||||
}
|
||||
|
||||
// VerifyCSRF 只接受当前未过期会话中以恒定时间比较匹配的 token。
|
||||
func (manager *Manager) VerifyCSRF(request *http.Request, token string) (authenticated bool, ok bool) {
|
||||
_, current, found := manager.current(request)
|
||||
if !found || token == "" {
|
||||
return false, false
|
||||
}
|
||||
|
||||
if subtle.ConstantTimeCompare([]byte(current.csrfToken), []byte(token)) != 1 {
|
||||
return false, false
|
||||
}
|
||||
|
||||
return current.authenticated, true
|
||||
}
|
||||
|
||||
// RotateAuthenticated 在登录成功后撤销旧会话并签发全新认证会话,避免 session fixation 与 CSRF 复用。
|
||||
func (manager *Manager) RotateAuthenticated(writer http.ResponseWriter, request *http.Request) string {
|
||||
if id, _, ok := manager.current(request); ok {
|
||||
manager.delete(id)
|
||||
}
|
||||
|
||||
id, current := manager.create(true)
|
||||
manager.writeCookie(writer, id, current.expiresAt)
|
||||
return current.csrfToken
|
||||
}
|
||||
|
||||
// Logout 撤销当前会话并立即清除浏览器 cookie。
|
||||
func (manager *Manager) Logout(writer http.ResponseWriter, request *http.Request) {
|
||||
if id, _, ok := manager.current(request); ok {
|
||||
manager.delete(id)
|
||||
}
|
||||
http.SetCookie(writer, &http.Cookie{
|
||||
Name: CookieName,
|
||||
Value: "",
|
||||
Path: "/",
|
||||
MaxAge: -1,
|
||||
HttpOnly: true,
|
||||
Secure: manager.cookieSecure,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
})
|
||||
}
|
||||
|
||||
func (manager *Manager) current(request *http.Request) (string, session, bool) {
|
||||
cookie, err := request.Cookie(CookieName)
|
||||
if err != nil {
|
||||
return "", session{}, false
|
||||
}
|
||||
|
||||
id, expiresAt, ok := manager.verifyCookie(cookie.Value)
|
||||
if !ok || !manager.now().Before(expiresAt) {
|
||||
return id, session{}, false
|
||||
}
|
||||
|
||||
manager.mu.Lock()
|
||||
defer manager.mu.Unlock()
|
||||
current, found := manager.sessions[id]
|
||||
if !found || !manager.now().Before(current.expiresAt) {
|
||||
return id, session{}, false
|
||||
}
|
||||
|
||||
return id, current, true
|
||||
}
|
||||
|
||||
func (manager *Manager) create(authenticated bool) (string, session) {
|
||||
id := manager.randomToken()
|
||||
current := session{
|
||||
csrfToken: manager.randomToken(),
|
||||
authenticated: authenticated,
|
||||
expiresAt: manager.now().Add(SessionLifetime),
|
||||
}
|
||||
|
||||
manager.mu.Lock()
|
||||
manager.sessions[id] = current
|
||||
manager.mu.Unlock()
|
||||
return id, current
|
||||
}
|
||||
|
||||
func (manager *Manager) delete(id string) {
|
||||
manager.mu.Lock()
|
||||
delete(manager.sessions, id)
|
||||
manager.mu.Unlock()
|
||||
}
|
||||
|
||||
func (manager *Manager) randomToken() string {
|
||||
bytes := make([]byte, csrfTokenByteSize)
|
||||
if _, err := io.ReadFull(manager.random, bytes); err != nil {
|
||||
panic("crypto/rand failed while creating a session token")
|
||||
}
|
||||
return base64.RawURLEncoding.EncodeToString(bytes)
|
||||
}
|
||||
|
||||
func (manager *Manager) writeCookie(writer http.ResponseWriter, id string, expiresAt time.Time) {
|
||||
http.SetCookie(writer, &http.Cookie{
|
||||
Name: CookieName,
|
||||
Value: manager.signCookie(id, expiresAt),
|
||||
Path: "/",
|
||||
MaxAge: int(expiresAt.Sub(manager.now()).Seconds()),
|
||||
Expires: expiresAt,
|
||||
HttpOnly: true,
|
||||
Secure: manager.cookieSecure,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
})
|
||||
}
|
||||
|
||||
func (manager *Manager) signCookie(id string, expiresAt time.Time) string {
|
||||
payload := id + "." + strconv.FormatInt(expiresAt.Unix(), 10)
|
||||
mac := hmac.New(sha256.New, manager.secret)
|
||||
_, _ = mac.Write([]byte(payload))
|
||||
return payload + "." + base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
|
||||
}
|
||||
|
||||
func (manager *Manager) verifyCookie(value string) (string, time.Time, bool) {
|
||||
parts := strings.Split(value, ".")
|
||||
if len(parts) != 3 || parts[0] == "" {
|
||||
return "", time.Time{}, false
|
||||
}
|
||||
|
||||
expiresUnix, err := strconv.ParseInt(parts[1], 10, 64)
|
||||
if err != nil {
|
||||
return "", time.Time{}, false
|
||||
}
|
||||
provided, err := base64.RawURLEncoding.DecodeString(parts[2])
|
||||
if err != nil {
|
||||
return "", time.Time{}, false
|
||||
}
|
||||
|
||||
payload := parts[0] + "." + parts[1]
|
||||
mac := hmac.New(sha256.New, manager.secret)
|
||||
_, _ = mac.Write([]byte(payload))
|
||||
if !hmac.Equal(provided, mac.Sum(nil)) {
|
||||
return "", time.Time{}, false
|
||||
}
|
||||
|
||||
return parts[0], time.Unix(expiresUnix, 0), true
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestManagerRejectsTamperedAndExpiredCookies(t *testing.T) {
|
||||
manager := NewManager([]byte(strings.Repeat("s", 32)), true)
|
||||
request := httptest.NewRequest(http.MethodGet, "/login", nil)
|
||||
response := httptest.NewRecorder()
|
||||
csrf, authenticated := manager.Ensure(response, request)
|
||||
if csrf == "" || authenticated {
|
||||
t.Fatalf("Ensure = (%q, %t), want anonymous CSRF session", csrf, authenticated)
|
||||
}
|
||||
cookie := response.Result().Cookies()[0]
|
||||
if !cookie.HttpOnly || !cookie.Secure || cookie.SameSite != http.SameSiteLaxMode || cookie.Path != "/" {
|
||||
t.Fatalf("session cookie is missing security attributes: %#v", cookie)
|
||||
}
|
||||
|
||||
tampered := *cookie
|
||||
tampered.Value = flipCookieValue(t, cookie.Value)
|
||||
tamperedRequest := httptest.NewRequest(http.MethodPost, "/login", nil)
|
||||
tamperedRequest.AddCookie(&tampered)
|
||||
if _, ok := manager.VerifyCSRF(tamperedRequest, csrf); ok {
|
||||
t.Fatal("tampered signed cookie passed CSRF verification")
|
||||
}
|
||||
|
||||
manager.now = func() time.Time { return time.Now().Add(9 * time.Hour) }
|
||||
expiredRequest := httptest.NewRequest(http.MethodPost, "/login", nil)
|
||||
expiredRequest.AddCookie(cookie)
|
||||
if _, ok := manager.VerifyCSRF(expiredRequest, csrf); ok {
|
||||
t.Fatal("expired cookie passed CSRF verification")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsAuthenticatedDoesNotCreateOrDependOnCSRFValidation(t *testing.T) {
|
||||
manager := NewManager([]byte(strings.Repeat("s", 32)), false)
|
||||
missingSession := httptest.NewRequest(http.MethodPost, "/tasks/start-purchases", nil)
|
||||
if manager.IsAuthenticated(missingSession) {
|
||||
t.Fatal("missing session was treated as authenticated")
|
||||
}
|
||||
if len(manager.sessions) != 0 {
|
||||
t.Fatalf("read-only authentication check created %d sessions", len(manager.sessions))
|
||||
}
|
||||
anonymousRequest := httptest.NewRequest(http.MethodGet, "/login", nil)
|
||||
anonymousResponse := httptest.NewRecorder()
|
||||
manager.Ensure(anonymousResponse, anonymousRequest)
|
||||
anonymousCookie := anonymousResponse.Result().Cookies()[0]
|
||||
anonymousCheck := httptest.NewRequest(http.MethodPost, "/tasks/start-purchases", nil)
|
||||
anonymousCheck.AddCookie(anonymousCookie)
|
||||
if manager.IsAuthenticated(anonymousCheck) {
|
||||
t.Fatal("anonymous CSRF session was treated as authenticated")
|
||||
}
|
||||
|
||||
loginRequest := httptest.NewRequest(http.MethodPost, "/login", nil)
|
||||
loginRequest.AddCookie(anonymousCookie)
|
||||
authenticatedResponse := httptest.NewRecorder()
|
||||
csrf := manager.RotateAuthenticated(authenticatedResponse, loginRequest)
|
||||
authenticatedCookie := authenticatedResponse.Result().Cookies()[0]
|
||||
|
||||
authenticatedCheck := httptest.NewRequest(http.MethodPost, "/tasks/start-purchases", nil)
|
||||
authenticatedCheck.AddCookie(authenticatedCookie)
|
||||
if !manager.IsAuthenticated(authenticatedCheck) {
|
||||
t.Fatal("valid authenticated session was not recognized")
|
||||
}
|
||||
if authenticated, csrfOK := manager.VerifyCSRF(authenticatedCheck, "wrong-token"); authenticated || csrfOK {
|
||||
t.Fatalf("wrong token result = (%t, %t), want (false, false)", authenticated, csrfOK)
|
||||
}
|
||||
|
||||
validRequest := httptest.NewRequest(http.MethodPost, "/tasks/start-purchases", nil)
|
||||
validRequest.AddCookie(authenticatedCookie)
|
||||
if authenticated, csrfOK := manager.VerifyCSRF(validRequest, csrf); !authenticated || !csrfOK {
|
||||
t.Fatalf("valid token result = (%t, %t), want (true, true)", authenticated, csrfOK)
|
||||
}
|
||||
|
||||
if authenticated, csrfOK := manager.VerifyCSRF(httptest.NewRequest(http.MethodPost, "/tasks/start-purchases", nil), csrf); authenticated || csrfOK {
|
||||
t.Fatalf("missing session result = (%t, %t), want (false, false)", authenticated, csrfOK)
|
||||
}
|
||||
}
|
||||
|
||||
func flipCookieValue(t *testing.T, value string) string {
|
||||
t.Helper()
|
||||
if value == "" {
|
||||
t.Fatal("cannot tamper with an empty cookie")
|
||||
}
|
||||
if value[0] == 'A' {
|
||||
return "B" + value[1:]
|
||||
}
|
||||
return "A" + value[1:]
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
// Package config 读取采购服务的启动配置。凭据只允许来自显式环境变量,避免把秘密写入代码或仓库。
|
||||
package config
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
const (
|
||||
adminUsernameEnv = "CMBUYER_ADMIN_USERNAME"
|
||||
adminPasswordBcryptEnv = "CMBUYER_ADMIN_PASSWORD_BCRYPT"
|
||||
sessionSecretEnv = "CMBUYER_SESSION_SECRET"
|
||||
cookieSecureEnv = "CMBUYER_COOKIE_SECURE"
|
||||
databaseSourceEnv = "CMBUYER_DATABASE_SOURCE"
|
||||
authorizationTTLEnv = "CMBUYER_AUTHORIZATION_TTL"
|
||||
maxTaskQuantityEnv = "CMBUYER_MAX_TASK_QUANTITY"
|
||||
maxTotalPriceEnv = "CMBUYER_MAX_TOTAL_PRICE"
|
||||
evidenceDirectoryEnv = "CMBUYER_EVIDENCE_DIR"
|
||||
minimumSecretLength = 32
|
||||
)
|
||||
|
||||
// Config 是启动采购服务所需的最小安全配置。
|
||||
type Config struct {
|
||||
AdminUsername string
|
||||
AdminPasswordBcrypt string
|
||||
SessionSecret []byte
|
||||
CookieSecure bool
|
||||
DatabaseSource string
|
||||
AuthorizationTTL time.Duration
|
||||
MaxTaskQuantity int
|
||||
MaxTotalPrice string
|
||||
EvidenceDirectory string
|
||||
}
|
||||
|
||||
// LoadFromEnv 从进程环境读取配置。错误只指出缺失或非法的变量名,绝不回显秘密。
|
||||
func LoadFromEnv() (Config, error) {
|
||||
return Load(os.LookupEnv)
|
||||
}
|
||||
|
||||
// Load 使用 lookup 读取配置,以便在不污染进程环境的情况下测试启动边界。
|
||||
func Load(lookup func(string) (string, bool)) (Config, error) {
|
||||
username, err := required(lookup, adminUsernameEnv)
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
|
||||
passwordHash, err := required(lookup, adminPasswordBcryptEnv)
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
if _, err := bcrypt.Cost([]byte(passwordHash)); err != nil {
|
||||
return Config{}, fmt.Errorf("%s is not a valid bcrypt hash", adminPasswordBcryptEnv)
|
||||
}
|
||||
|
||||
secret, err := required(lookup, sessionSecretEnv)
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
if len([]byte(secret)) < minimumSecretLength {
|
||||
return Config{}, fmt.Errorf("%s must be at least %d bytes", sessionSecretEnv, minimumSecretLength)
|
||||
}
|
||||
|
||||
cookieSecure := false
|
||||
if value, present := lookup(cookieSecureEnv); present {
|
||||
switch value {
|
||||
case "true":
|
||||
cookieSecure = true
|
||||
case "false":
|
||||
cookieSecure = false
|
||||
default:
|
||||
return Config{}, fmt.Errorf("%s must be exactly true or false", cookieSecureEnv)
|
||||
}
|
||||
}
|
||||
databaseSource, err := required(lookup, databaseSourceEnv)
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
ttlText, err := required(lookup, authorizationTTLEnv)
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
ttl, err := time.ParseDuration(ttlText)
|
||||
if err != nil || ttl <= 0 {
|
||||
return Config{}, fmt.Errorf("%s must be a positive duration", authorizationTTLEnv)
|
||||
}
|
||||
quantityText, err := required(lookup, maxTaskQuantityEnv)
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
maxQuantity, err := strconv.Atoi(quantityText)
|
||||
if err != nil || maxQuantity < 1 {
|
||||
return Config{}, fmt.Errorf("%s must be a positive integer", maxTaskQuantityEnv)
|
||||
}
|
||||
maxPrice, err := required(lookup, maxTotalPriceEnv)
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
if !canonicalMoney(maxPrice) {
|
||||
return Config{}, fmt.Errorf("%s must be a canonical positive decimal", maxTotalPriceEnv)
|
||||
}
|
||||
evidenceDirectory, err := required(lookup, evidenceDirectoryEnv)
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
if strings.TrimSpace(evidenceDirectory) != evidenceDirectory || !filepath.IsAbs(evidenceDirectory) {
|
||||
return Config{}, fmt.Errorf("%s must be an absolute path without surrounding whitespace", evidenceDirectoryEnv)
|
||||
}
|
||||
|
||||
return Config{
|
||||
AdminUsername: username,
|
||||
AdminPasswordBcrypt: passwordHash,
|
||||
SessionSecret: []byte(secret),
|
||||
CookieSecure: cookieSecure,
|
||||
DatabaseSource: databaseSource,
|
||||
AuthorizationTTL: ttl, MaxTaskQuantity: maxQuantity, MaxTotalPrice: maxPrice,
|
||||
EvidenceDirectory: evidenceDirectory,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func canonicalMoney(value string) bool {
|
||||
parts := strings.Split(value, ".")
|
||||
if len(parts) != 2 || len(parts[0]) == 0 || len(parts[1]) != 2 || (len(parts[0]) > 1 && parts[0][0] == '0') {
|
||||
return false
|
||||
}
|
||||
for _, part := range parts {
|
||||
for _, ch := range part {
|
||||
if ch < '0' || ch > '9' {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
return strings.Trim(parts[0]+parts[1], "0") != ""
|
||||
}
|
||||
|
||||
func required(lookup func(string) (string, bool), name string) (string, error) {
|
||||
value, present := lookup(name)
|
||||
if !present || strings.TrimSpace(value) == "" {
|
||||
return "", errors.New(name + " must be set")
|
||||
}
|
||||
|
||||
return value, nil
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
package config_test
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"cmbuyer/admin/internal/config"
|
||||
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
func TestLoad(t *testing.T) {
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte("test-password"), bcrypt.MinCost)
|
||||
if err != nil {
|
||||
t.Fatalf("generate bcrypt hash: %v", err)
|
||||
}
|
||||
|
||||
values := map[string]string{
|
||||
"CMBUYER_ADMIN_USERNAME": "admin",
|
||||
"CMBUYER_ADMIN_PASSWORD_BCRYPT": string(hash),
|
||||
"CMBUYER_SESSION_SECRET": strings.Repeat("s", 32),
|
||||
"CMBUYER_COOKIE_SECURE": "true",
|
||||
"CMBUYER_DATABASE_SOURCE": ":memory:",
|
||||
"CMBUYER_AUTHORIZATION_TTL": "10m",
|
||||
"CMBUYER_MAX_TASK_QUANTITY": "99",
|
||||
"CMBUYER_MAX_TOTAL_PRICE": "999.99",
|
||||
"CMBUYER_EVIDENCE_DIR": t.TempDir(),
|
||||
}
|
||||
|
||||
got, err := config.Load(lookup(values))
|
||||
if err != nil {
|
||||
t.Fatalf("Load: %v", err)
|
||||
}
|
||||
if got.AdminUsername != "admin" || !got.CookieSecure {
|
||||
t.Fatalf("Load returned unexpected public configuration: %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadRejectsMissingOrInvalidConfiguration(t *testing.T) {
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte("test-password"), bcrypt.MinCost)
|
||||
if err != nil {
|
||||
t.Fatalf("generate bcrypt hash: %v", err)
|
||||
}
|
||||
|
||||
base := map[string]string{
|
||||
"CMBUYER_ADMIN_USERNAME": "admin",
|
||||
"CMBUYER_ADMIN_PASSWORD_BCRYPT": string(hash),
|
||||
"CMBUYER_SESSION_SECRET": strings.Repeat("s", 32),
|
||||
"CMBUYER_DATABASE_SOURCE": ":memory:",
|
||||
"CMBUYER_AUTHORIZATION_TTL": "10m",
|
||||
"CMBUYER_MAX_TASK_QUANTITY": "99",
|
||||
"CMBUYER_MAX_TOTAL_PRICE": "999.99",
|
||||
"CMBUYER_EVIDENCE_DIR": t.TempDir(),
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
mutate func(map[string]string)
|
||||
want string
|
||||
}{
|
||||
{"missing username", func(values map[string]string) { delete(values, "CMBUYER_ADMIN_USERNAME") }, "CMBUYER_ADMIN_USERNAME"},
|
||||
{"invalid bcrypt", func(values map[string]string) { values["CMBUYER_ADMIN_PASSWORD_BCRYPT"] = "not-a-bcrypt-hash" }, "CMBUYER_ADMIN_PASSWORD_BCRYPT"},
|
||||
{"short secret", func(values map[string]string) { values["CMBUYER_SESSION_SECRET"] = "short" }, "CMBUYER_SESSION_SECRET"},
|
||||
{"invalid secure flag", func(values map[string]string) { values["CMBUYER_COOKIE_SECURE"] = "1" }, "CMBUYER_COOKIE_SECURE"},
|
||||
{"missing database", func(values map[string]string) { delete(values, "CMBUYER_DATABASE_SOURCE") }, "CMBUYER_DATABASE_SOURCE"},
|
||||
{"invalid authorization ttl", func(values map[string]string) { values["CMBUYER_AUTHORIZATION_TTL"] = "0s" }, "CMBUYER_AUTHORIZATION_TTL"},
|
||||
{"invalid maximum quantity", func(values map[string]string) { values["CMBUYER_MAX_TASK_QUANTITY"] = "0" }, "CMBUYER_MAX_TASK_QUANTITY"},
|
||||
{"invalid maximum total price", func(values map[string]string) { values["CMBUYER_MAX_TOTAL_PRICE"] = "1" }, "CMBUYER_MAX_TOTAL_PRICE"},
|
||||
{"missing evidence directory", func(values map[string]string) { delete(values, "CMBUYER_EVIDENCE_DIR") }, "CMBUYER_EVIDENCE_DIR"},
|
||||
{"relative evidence directory", func(values map[string]string) { values["CMBUYER_EVIDENCE_DIR"] = "evidence" }, "CMBUYER_EVIDENCE_DIR"},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
values := copyValues(base)
|
||||
test.mutate(values)
|
||||
_, err := config.Load(lookup(values))
|
||||
if err == nil || !strings.Contains(err.Error(), test.want) {
|
||||
t.Fatalf("Load error = %v, want mention of %s", err, test.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func lookup(values map[string]string) func(string) (string, bool) {
|
||||
return func(key string) (string, bool) {
|
||||
value, ok := values[key]
|
||||
return value, ok
|
||||
}
|
||||
}
|
||||
|
||||
func copyValues(values map[string]string) map[string]string {
|
||||
copy := make(map[string]string, len(values))
|
||||
for key, value := range values {
|
||||
copy[key] = value
|
||||
}
|
||||
return copy
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
package deviceauth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"database/sql"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
"unicode"
|
||||
)
|
||||
|
||||
const (
|
||||
StatusActive = "ACTIVE"
|
||||
StatusRevoked = "REVOKED"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrInvalidCredential = errors.New("invalid device credential input")
|
||||
ErrCredentialNotFound = errors.New("device credential not found")
|
||||
)
|
||||
|
||||
type Credential struct {
|
||||
DeviceID string `json:"device_id"`
|
||||
DisplayName string `json:"display_name"`
|
||||
Status string `json:"status"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
RevokedAt *time.Time `json:"revoked_at,omitempty"`
|
||||
}
|
||||
|
||||
// IssuedCredential is the only value that can carry the plaintext token. It is returned only
|
||||
// after SQLite has committed the hash and is intended for the management CLI's one stdout write.
|
||||
type IssuedCredential struct {
|
||||
Credential
|
||||
Token string `json:"-"`
|
||||
}
|
||||
|
||||
type CredentialStore struct {
|
||||
database *sql.DB
|
||||
now func() time.Time
|
||||
random io.Reader
|
||||
randomMu sync.Mutex
|
||||
}
|
||||
|
||||
func NewCredentialStore(database *sql.DB) (*CredentialStore, error) {
|
||||
if database == nil {
|
||||
return nil, errors.New("device credential database is required")
|
||||
}
|
||||
if _, err := database.Exec("SELECT device_id FROM device_credentials LIMIT 1"); err != nil {
|
||||
return nil, errors.New("device credential migration is not available")
|
||||
}
|
||||
return &CredentialStore{database: database, now: time.Now, random: rand.Reader}, nil
|
||||
}
|
||||
|
||||
func (store *CredentialStore) Issue(ctx context.Context, displayName string) (IssuedCredential, error) {
|
||||
if !ValidDisplayName(displayName) {
|
||||
return IssuedCredential{}, ErrInvalidCredential
|
||||
}
|
||||
randomBytes := make([]byte, 16+32)
|
||||
store.randomMu.Lock()
|
||||
_, randomErr := io.ReadFull(store.random, randomBytes)
|
||||
store.randomMu.Unlock()
|
||||
if randomErr != nil {
|
||||
return IssuedCredential{}, fmt.Errorf("generate device credential: %w", randomErr)
|
||||
}
|
||||
deviceID := formatUUIDv4(randomBytes[:16])
|
||||
token := hex.EncodeToString(randomBytes[16:])
|
||||
tokenHash := sha256.Sum256(randomBytes[16:])
|
||||
createdAt := store.now().UTC()
|
||||
if createdAt.IsZero() {
|
||||
return IssuedCredential{}, errors.New("device credential clock is invalid")
|
||||
}
|
||||
_, err := store.database.ExecContext(ctx, `INSERT INTO device_credentials
|
||||
(device_id, display_name, token_sha256, status, created_at, revoked_at)
|
||||
VALUES (?, ?, ?, ?, ?, NULL)`,
|
||||
deviceID, displayName, tokenHash[:], StatusActive, createdAt.Format(time.RFC3339Nano))
|
||||
if err != nil {
|
||||
return IssuedCredential{}, fmt.Errorf("persist device credential: %w", err)
|
||||
}
|
||||
return IssuedCredential{Credential: Credential{
|
||||
DeviceID: deviceID, DisplayName: displayName, Status: StatusActive, CreatedAt: createdAt,
|
||||
}, Token: token}, nil
|
||||
}
|
||||
|
||||
func (store *CredentialStore) List(ctx context.Context) ([]Credential, error) {
|
||||
rows, err := store.database.QueryContext(ctx, `SELECT device_id, display_name, status, created_at, revoked_at
|
||||
FROM device_credentials ORDER BY created_at, device_id`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list device credentials: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
credentials := make([]Credential, 0)
|
||||
for rows.Next() {
|
||||
credential, err := scanCredential(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
credentials = append(credentials, credential)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("list device credentials: %w", err)
|
||||
}
|
||||
return credentials, nil
|
||||
}
|
||||
|
||||
func (store *CredentialStore) Revoke(ctx context.Context, deviceID string) (Credential, bool, error) {
|
||||
if !ValidDeviceID(deviceID) {
|
||||
return Credential{}, false, ErrInvalidCredential
|
||||
}
|
||||
revokedAt := store.now().UTC()
|
||||
if revokedAt.IsZero() {
|
||||
return Credential{}, false, errors.New("device credential clock is invalid")
|
||||
}
|
||||
transaction, err := store.database.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return Credential{}, false, fmt.Errorf("begin device credential revocation: %w", err)
|
||||
}
|
||||
defer transaction.Rollback()
|
||||
result, err := transaction.ExecContext(ctx, `UPDATE device_credentials
|
||||
SET status = ?, revoked_at = ? WHERE device_id = ? AND status = ?`,
|
||||
StatusRevoked, revokedAt.Format(time.RFC3339Nano), deviceID, StatusActive)
|
||||
if err != nil {
|
||||
return Credential{}, false, fmt.Errorf("revoke device credential: %w", err)
|
||||
}
|
||||
changedRows, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return Credential{}, false, fmt.Errorf("inspect device credential revocation: %w", err)
|
||||
}
|
||||
credential, err := scanCredential(transaction.QueryRowContext(ctx, `SELECT device_id, display_name, status, created_at, revoked_at
|
||||
FROM device_credentials WHERE device_id = ?`, deviceID))
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return Credential{}, false, ErrCredentialNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return Credential{}, false, err
|
||||
}
|
||||
if err := transaction.Commit(); err != nil {
|
||||
return Credential{}, false, fmt.Errorf("commit device credential revocation: %w", err)
|
||||
}
|
||||
return credential, changedRows == 1, nil
|
||||
}
|
||||
|
||||
type rowScanner interface {
|
||||
Scan(...any) error
|
||||
}
|
||||
|
||||
func scanCredential(row rowScanner) (Credential, error) {
|
||||
var credential Credential
|
||||
var created string
|
||||
var revoked sql.NullString
|
||||
if err := row.Scan(&credential.DeviceID, &credential.DisplayName, &credential.Status, &created, &revoked); err != nil {
|
||||
return Credential{}, err
|
||||
}
|
||||
if !ValidDeviceID(credential.DeviceID) || !ValidDisplayName(credential.DisplayName) || (credential.Status != StatusActive && credential.Status != StatusRevoked) {
|
||||
return Credential{}, errors.New("stored device credential metadata is invalid")
|
||||
}
|
||||
createdAt, err := parseStoredTime(created)
|
||||
if err != nil {
|
||||
return Credential{}, err
|
||||
}
|
||||
credential.CreatedAt = createdAt
|
||||
if revoked.Valid {
|
||||
revokedAt, err := parseStoredTime(revoked.String)
|
||||
if err != nil {
|
||||
return Credential{}, err
|
||||
}
|
||||
if revokedAt.Before(createdAt) {
|
||||
return Credential{}, errors.New("stored device credential status is invalid")
|
||||
}
|
||||
credential.RevokedAt = &revokedAt
|
||||
}
|
||||
if (credential.Status == StatusActive) != (credential.RevokedAt == nil) {
|
||||
return Credential{}, errors.New("stored device credential status is invalid")
|
||||
}
|
||||
return credential, nil
|
||||
}
|
||||
|
||||
func ValidDisplayName(value string) bool {
|
||||
if value == "" || len([]rune(value)) > 128 || strings.TrimSpace(value) != value {
|
||||
return false
|
||||
}
|
||||
for _, character := range value {
|
||||
if unicode.IsControl(character) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func parseStoredTime(value string) (time.Time, error) {
|
||||
if strings.TrimSpace(value) != value || !strings.HasSuffix(value, "Z") {
|
||||
return time.Time{}, errors.New("stored device credential time is invalid")
|
||||
}
|
||||
parsed, err := time.Parse(time.RFC3339Nano, value)
|
||||
if err != nil || parsed.Location() != time.UTC {
|
||||
return time.Time{}, errors.New("stored device credential time is invalid")
|
||||
}
|
||||
return parsed, nil
|
||||
}
|
||||
|
||||
func formatUUIDv4(bytes []byte) string {
|
||||
copyBytes := append([]byte(nil), bytes...)
|
||||
copyBytes[6] = (copyBytes[6] & 0x0f) | 0x40
|
||||
copyBytes[8] = (copyBytes[8] & 0x3f) | 0x80
|
||||
encoded := hex.EncodeToString(copyBytes)
|
||||
return encoded[:8] + "-" + encoded[8:12] + "-" + encoded[12:16] + "-" + encoded[16:20] + "-" + encoded[20:]
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
// Package deviceauth owns the machine identity boundary shared by all device routes.
|
||||
package deviceauth
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"crypto/subtle"
|
||||
"database/sql"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
AuthorizationHeader = "Authorization"
|
||||
DeviceIDHeader = "X-CMBuyer-Device-ID"
|
||||
tokenHexLength = 64
|
||||
)
|
||||
|
||||
var (
|
||||
// ErrUnauthenticated deliberately covers every credential defect. Callers must not reveal
|
||||
// whether a device exists, is revoked, or supplied a mismatched token.
|
||||
ErrUnauthenticated = errors.New("device authentication failed")
|
||||
// ErrUnavailable is distinct so a storage outage is not disguised as a bad credential.
|
||||
// HTTP callers still return no diagnostic body because database details are server-only.
|
||||
ErrUnavailable = errors.New("device authentication unavailable")
|
||||
)
|
||||
|
||||
type Principal struct {
|
||||
ID string
|
||||
}
|
||||
|
||||
type Authenticator interface {
|
||||
Authenticate(*http.Request) (Principal, error)
|
||||
}
|
||||
|
||||
// RejectAllAuthenticator is useful for tests and for fail-closed wiring where no credential
|
||||
// store is available. Production startup uses SQLiteAuthenticator.
|
||||
type RejectAllAuthenticator struct{}
|
||||
|
||||
func (RejectAllAuthenticator) Authenticate(*http.Request) (Principal, error) {
|
||||
return Principal{}, ErrUnauthenticated
|
||||
}
|
||||
|
||||
type SQLiteAuthenticator struct {
|
||||
database *sql.DB
|
||||
}
|
||||
|
||||
func NewSQLiteAuthenticator(database *sql.DB) (*SQLiteAuthenticator, error) {
|
||||
if database == nil {
|
||||
return nil, errors.New("device credential database is required")
|
||||
}
|
||||
if _, err := database.Exec("SELECT device_id FROM device_credentials LIMIT 1"); err != nil {
|
||||
return nil, errors.New("device credential migration is not available")
|
||||
}
|
||||
return &SQLiteAuthenticator{database: database}, nil
|
||||
}
|
||||
|
||||
func (authenticator *SQLiteAuthenticator) Authenticate(request *http.Request) (Principal, error) {
|
||||
if request == nil {
|
||||
return Principal{}, ErrUnauthenticated
|
||||
}
|
||||
deviceID, token, ok := requestCredentials(request)
|
||||
if !ok {
|
||||
return Principal{}, ErrUnauthenticated
|
||||
}
|
||||
|
||||
candidateHash := sha256.Sum256(token)
|
||||
var storedHash []byte
|
||||
var hashType string
|
||||
var hashLength sql.NullInt64
|
||||
var status sql.NullString
|
||||
var revokedAt sql.NullString
|
||||
var found bool
|
||||
err := authenticator.database.QueryRowContext(
|
||||
request.Context(),
|
||||
`SELECT CASE WHEN credentials.device_id IS NULL THEN zeroblob(32) ELSE credentials.token_sha256 END,
|
||||
typeof(credentials.token_sha256),
|
||||
length(credentials.token_sha256),
|
||||
credentials.status,
|
||||
credentials.revoked_at,
|
||||
credentials.device_id IS NOT NULL
|
||||
FROM (SELECT 1) AS singleton
|
||||
LEFT JOIN device_credentials AS credentials ON credentials.device_id = ?`,
|
||||
deviceID,
|
||||
).Scan(&storedHash, &hashType, &hashLength, &status, &revokedAt, &found)
|
||||
if err != nil {
|
||||
return Principal{}, ErrUnavailable
|
||||
}
|
||||
if len(storedHash) != sha256.Size {
|
||||
return Principal{}, ErrUnavailable
|
||||
}
|
||||
matched := subtle.ConstantTimeCompare(candidateHash[:], storedHash) == 1
|
||||
if !found {
|
||||
// The LEFT JOIN supplies a 32-byte dummy hash, so unknown ids take the same compare path
|
||||
// as known credentials without requiring a plaintext token lookup.
|
||||
return Principal{}, ErrUnauthenticated
|
||||
}
|
||||
if hashType != "blob" || !hashLength.Valid || hashLength.Int64 != sha256.Size || len(storedHash) != sha256.Size || !status.Valid {
|
||||
return Principal{}, ErrUnavailable
|
||||
}
|
||||
switch status.String {
|
||||
case StatusActive:
|
||||
if revokedAt.Valid {
|
||||
return Principal{}, ErrUnavailable
|
||||
}
|
||||
case StatusRevoked:
|
||||
if !revokedAt.Valid {
|
||||
return Principal{}, ErrUnavailable
|
||||
}
|
||||
if _, err := parseStoredTime(revokedAt.String); err != nil {
|
||||
return Principal{}, ErrUnavailable
|
||||
}
|
||||
default:
|
||||
return Principal{}, ErrUnavailable
|
||||
}
|
||||
if !matched || status.String == StatusRevoked {
|
||||
return Principal{}, ErrUnauthenticated
|
||||
}
|
||||
return Principal{ID: deviceID}, nil
|
||||
}
|
||||
|
||||
func requestCredentials(request *http.Request) (string, []byte, bool) {
|
||||
authorizations := request.Header.Values(AuthorizationHeader)
|
||||
deviceIDs := request.Header.Values(DeviceIDHeader)
|
||||
if len(authorizations) != 1 || len(deviceIDs) != 1 {
|
||||
return "", nil, false
|
||||
}
|
||||
authorization := authorizations[0]
|
||||
if len(authorization) != len("Bearer ")+tokenHexLength || !strings.EqualFold(authorization[:len("Bearer")], "Bearer") || authorization[len("Bearer")] != ' ' {
|
||||
return "", nil, false
|
||||
}
|
||||
tokenHex := authorization[len("Bearer "):]
|
||||
if !validLowerHex(tokenHex, tokenHexLength) || !ValidDeviceID(deviceIDs[0]) {
|
||||
return "", nil, false
|
||||
}
|
||||
token, err := hex.DecodeString(tokenHex)
|
||||
if err != nil {
|
||||
return "", nil, false
|
||||
}
|
||||
return deviceIDs[0], token, true
|
||||
}
|
||||
|
||||
func ValidDeviceID(value string) bool {
|
||||
if len(value) != 36 {
|
||||
return false
|
||||
}
|
||||
for index, character := range value {
|
||||
if index == 8 || index == 13 || index == 18 || index == 23 {
|
||||
if character != '-' {
|
||||
return false
|
||||
}
|
||||
continue
|
||||
}
|
||||
if !(character >= '0' && character <= '9' || character >= 'a' && character <= 'f') {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return value[14] == '4' && (value[19] == '8' || value[19] == '9' || value[19] == 'a' || value[19] == 'b')
|
||||
}
|
||||
|
||||
func validLowerHex(value string, length int) bool {
|
||||
if len(value) != length {
|
||||
return false
|
||||
}
|
||||
decoded, err := hex.DecodeString(value)
|
||||
return err == nil && hex.EncodeToString(decoded) == value
|
||||
}
|
||||
@@ -0,0 +1,407 @@
|
||||
package deviceauth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"database/sql"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"cmbuyer/admin/internal/migrations"
|
||||
"cmbuyer/admin/internal/storage/sqlite"
|
||||
)
|
||||
|
||||
func TestIssueStoresOnlyRawTokenHashAndGenericJSONOmitsSecret(t *testing.T) {
|
||||
database, store := newCredentialStore(t)
|
||||
first, err := store.Issue(context.Background(), "采购工具一号")
|
||||
if err != nil {
|
||||
t.Fatalf("Issue first: %v", err)
|
||||
}
|
||||
second, err := store.Issue(context.Background(), "采购工具二号")
|
||||
if err != nil {
|
||||
t.Fatalf("Issue second: %v", err)
|
||||
}
|
||||
if first.DeviceID == second.DeviceID || first.Token == second.Token || !ValidDeviceID(first.DeviceID) || !validLowerHex(first.Token, tokenHexLength) {
|
||||
t.Fatalf("issued identifiers are not independent canonical values")
|
||||
}
|
||||
|
||||
rawToken, err := hex.DecodeString(first.Token)
|
||||
if err != nil {
|
||||
t.Fatalf("decode issued token: %v", err)
|
||||
}
|
||||
wantHash := sha256.Sum256(rawToken)
|
||||
var storedHash []byte
|
||||
var storageType string
|
||||
if err := database.QueryRow(`SELECT token_sha256, typeof(token_sha256) FROM device_credentials WHERE device_id = ?`, first.DeviceID).Scan(&storedHash, &storageType); err != nil {
|
||||
t.Fatalf("read stored hash: %v", err)
|
||||
}
|
||||
if storageType != "blob" || len(storedHash) != sha256.Size || !equalBytes(storedHash, wantHash[:]) {
|
||||
t.Fatalf("stored hash type/length/value = %q/%d/%t", storageType, len(storedHash), equalBytes(storedHash, wantHash[:]))
|
||||
}
|
||||
var leakedCopies int
|
||||
if err := database.QueryRow(`SELECT COUNT(*) FROM device_credentials WHERE CAST(token_sha256 AS TEXT) IN (?, ?)`, first.Token, hex.EncodeToString(wantHash[:])).Scan(&leakedCopies); err != nil {
|
||||
t.Fatalf("search token copies: %v", err)
|
||||
}
|
||||
if leakedCopies != 0 {
|
||||
t.Fatal("database stored a plaintext or hex-encoded token/hash copy")
|
||||
}
|
||||
encoded, err := json.Marshal(first)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal issued credential: %v", err)
|
||||
}
|
||||
if strings.Contains(string(encoded), first.Token) || strings.Contains(string(encoded), "token") {
|
||||
t.Fatalf("generic serialization disclosed token field: %s", encoded)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthenticateStrictHeaderMatrixAndBinding(t *testing.T) {
|
||||
_, store := newCredentialStore(t)
|
||||
first, err := store.Issue(context.Background(), "one")
|
||||
if err != nil {
|
||||
t.Fatalf("issue first: %v", err)
|
||||
}
|
||||
second, err := store.Issue(context.Background(), "two")
|
||||
if err != nil {
|
||||
t.Fatalf("issue second: %v", err)
|
||||
}
|
||||
authenticator := authenticatorForStore(t, store)
|
||||
|
||||
for _, scheme := range []string{"Bearer", "bearer", "BEARER"} {
|
||||
request := credentialRequest(first.DeviceID, scheme+" "+first.Token)
|
||||
principal, err := authenticator.Authenticate(request)
|
||||
if err != nil || principal.ID != first.DeviceID {
|
||||
t.Fatalf("scheme %q Authenticate = (%q, %v)", scheme, principal.ID, err)
|
||||
}
|
||||
}
|
||||
|
||||
unknownID := newRuntimeUUID(t)
|
||||
wrongToken := newRuntimeToken(t)
|
||||
tests := []struct {
|
||||
name string
|
||||
mutate func(*http.Request)
|
||||
}{
|
||||
{"missing authorization", func(request *http.Request) { request.Header.Del(AuthorizationHeader) }},
|
||||
{"missing device", func(request *http.Request) { request.Header.Del(DeviceIDHeader) }},
|
||||
{"empty authorization", func(request *http.Request) { request.Header.Set(AuthorizationHeader, "") }},
|
||||
{"empty device", func(request *http.Request) { request.Header.Set(DeviceIDHeader, "") }},
|
||||
{"duplicate authorization", func(request *http.Request) { request.Header.Add(AuthorizationHeader, "Bearer "+first.Token) }},
|
||||
{"duplicate device", func(request *http.Request) { request.Header.Add(DeviceIDHeader, first.DeviceID) }},
|
||||
{"combined authorization", func(request *http.Request) {
|
||||
request.Header.Set(AuthorizationHeader, "Bearer "+first.Token+", Bearer "+first.Token)
|
||||
}},
|
||||
{"combined device", func(request *http.Request) { request.Header.Set(DeviceIDHeader, first.DeviceID+", "+first.DeviceID) }},
|
||||
{"extra separator", func(request *http.Request) { request.Header.Set(AuthorizationHeader, "Bearer "+first.Token) }},
|
||||
{"tab separator", func(request *http.Request) { request.Header.Set(AuthorizationHeader, "Bearer\t"+first.Token) }},
|
||||
{"uppercase token", func(request *http.Request) {
|
||||
request.Header.Set(AuthorizationHeader, "Bearer "+strings.ToUpper(first.Token))
|
||||
}},
|
||||
{"short token", func(request *http.Request) { request.Header.Set(AuthorizationHeader, "Bearer "+first.Token[:62]) }},
|
||||
{"long token", func(request *http.Request) { request.Header.Set(AuthorizationHeader, "Bearer "+first.Token+"00") }},
|
||||
{"non hex token", func(request *http.Request) { request.Header.Set(AuthorizationHeader, "Bearer "+first.Token[:63]+"g") }},
|
||||
{"token separator", func(request *http.Request) {
|
||||
request.Header.Set(AuthorizationHeader, "Bearer "+first.Token[:32]+"-"+first.Token[33:])
|
||||
}},
|
||||
{"uppercase device", func(request *http.Request) { request.Header.Set(DeviceIDHeader, strings.ToUpper(first.DeviceID)) }},
|
||||
{"padded device", func(request *http.Request) { request.Header.Set(DeviceIDHeader, " "+first.DeviceID) }},
|
||||
{"wrong uuid version", func(request *http.Request) {
|
||||
request.Header.Set(DeviceIDHeader, first.DeviceID[:14]+"3"+first.DeviceID[15:])
|
||||
}},
|
||||
{"wrong uuid variant", func(request *http.Request) {
|
||||
request.Header.Set(DeviceIDHeader, first.DeviceID[:19]+"7"+first.DeviceID[20:])
|
||||
}},
|
||||
{"unknown device", func(request *http.Request) { request.Header.Set(DeviceIDHeader, unknownID) }},
|
||||
{"wrong token", func(request *http.Request) { request.Header.Set(AuthorizationHeader, "Bearer "+wrongToken) }},
|
||||
{"token device mismatch", func(request *http.Request) { request.Header.Set(DeviceIDHeader, second.DeviceID) }},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
request := credentialRequest(first.DeviceID, "Bearer "+first.Token)
|
||||
test.mutate(request)
|
||||
principal, err := authenticator.Authenticate(request)
|
||||
if !errors.Is(err, ErrUnauthenticated) || principal != (Principal{}) {
|
||||
t.Fatalf("Authenticate = (%#v, %v), want empty unauthenticated", principal, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
if principal, err := authenticator.Authenticate(nil); !errors.Is(err, ErrUnauthenticated) || principal != (Principal{}) {
|
||||
t.Fatalf("Authenticate(nil) = (%#v, %v)", principal, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRevokeIsImmediateAndIdempotent(t *testing.T) {
|
||||
_, store := newCredentialStore(t)
|
||||
issued, err := store.Issue(context.Background(), "device")
|
||||
if err != nil {
|
||||
t.Fatalf("Issue: %v", err)
|
||||
}
|
||||
authenticator := authenticatorForStore(t, store)
|
||||
request := credentialRequest(issued.DeviceID, "Bearer "+issued.Token)
|
||||
if _, err := authenticator.Authenticate(request); err != nil {
|
||||
t.Fatalf("Authenticate before revoke: %v", err)
|
||||
}
|
||||
|
||||
first, changed, err := store.Revoke(context.Background(), issued.DeviceID)
|
||||
if err != nil || !changed || first.Status != StatusRevoked || first.RevokedAt == nil {
|
||||
t.Fatalf("first Revoke = (%#v, %t, %v)", first, changed, err)
|
||||
}
|
||||
if principal, err := authenticator.Authenticate(request); !errors.Is(err, ErrUnauthenticated) || principal != (Principal{}) {
|
||||
t.Fatalf("Authenticate after committed revoke = (%#v, %v)", principal, err)
|
||||
}
|
||||
second, changed, err := store.Revoke(context.Background(), issued.DeviceID)
|
||||
if err != nil || changed || second.RevokedAt == nil || !second.RevokedAt.Equal(*first.RevokedAt) {
|
||||
t.Fatalf("second Revoke = (%#v, %t, %v)", second, changed, err)
|
||||
}
|
||||
listed, err := store.List(context.Background())
|
||||
if err != nil || len(listed) != 1 || listed[0].Status != StatusRevoked {
|
||||
t.Fatalf("List = (%#v, %v)", listed, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConcurrentAuthenticationAndRevocation(t *testing.T) {
|
||||
_, store := newCredentialStore(t)
|
||||
issued, err := store.Issue(context.Background(), "concurrent")
|
||||
if err != nil {
|
||||
t.Fatalf("Issue: %v", err)
|
||||
}
|
||||
authenticator := authenticatorForStore(t, store)
|
||||
request := func() *http.Request { return credentialRequest(issued.DeviceID, "Bearer "+issued.Token) }
|
||||
start := make(chan struct{})
|
||||
results := make(chan error, 16)
|
||||
var wait sync.WaitGroup
|
||||
for index := 0; index < 16; index++ {
|
||||
wait.Add(1)
|
||||
go func() {
|
||||
defer wait.Done()
|
||||
<-start
|
||||
_, err := authenticator.Authenticate(request())
|
||||
results <- err
|
||||
}()
|
||||
}
|
||||
close(start)
|
||||
if _, _, err := store.Revoke(context.Background(), issued.DeviceID); err != nil {
|
||||
t.Fatalf("Revoke: %v", err)
|
||||
}
|
||||
wait.Wait()
|
||||
close(results)
|
||||
for err := range results {
|
||||
if err != nil && !errors.Is(err, ErrUnauthenticated) {
|
||||
t.Fatalf("concurrent Authenticate error = %v", err)
|
||||
}
|
||||
}
|
||||
for index := 0; index < 16; index++ {
|
||||
if _, err := authenticator.Authenticate(request()); !errors.Is(err, ErrUnauthenticated) {
|
||||
t.Fatalf("post-commit Authenticate %d error = %v", index, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthenticationDatabaseFaultAndCorruptionAreUnavailable(t *testing.T) {
|
||||
database, store := newCredentialStore(t)
|
||||
issued, err := store.Issue(context.Background(), "device")
|
||||
if err != nil {
|
||||
t.Fatalf("Issue: %v", err)
|
||||
}
|
||||
authenticator := authenticatorForStore(t, store)
|
||||
if err := database.Close(); err != nil {
|
||||
t.Fatalf("close database: %v", err)
|
||||
}
|
||||
if _, err := authenticator.Authenticate(credentialRequest(issued.DeviceID, "Bearer "+issued.Token)); !errors.Is(err, ErrUnavailable) {
|
||||
t.Fatalf("closed database Authenticate error = %v", err)
|
||||
}
|
||||
|
||||
corruptDB, err := sqlite.Open(filepath.Join(t.TempDir(), "corrupt.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("open corrupt database: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = corruptDB.Close() })
|
||||
if _, err := corruptDB.Exec(`CREATE TABLE device_credentials (device_id TEXT PRIMARY KEY, token_sha256 BLOB, status TEXT, revoked_at TEXT)`); err != nil {
|
||||
t.Fatalf("create corrupt table: %v", err)
|
||||
}
|
||||
corruptAuthenticator, err := NewSQLiteAuthenticator(corruptDB)
|
||||
if err != nil {
|
||||
t.Fatalf("new corrupt authenticator: %v", err)
|
||||
}
|
||||
tests := []struct {
|
||||
name string
|
||||
hashValue func([sha256.Size]byte) any
|
||||
status string
|
||||
revokedAt any
|
||||
}{
|
||||
{name: "null hash", hashValue: func([sha256.Size]byte) any { return nil }, status: StatusActive},
|
||||
{name: "matching text hash", hashValue: func(hash [sha256.Size]byte) any { return string(hash[:]) }, status: StatusActive},
|
||||
{name: "unknown status", hashValue: func(hash [sha256.Size]byte) any { return hash[:] }, status: "BROKEN"},
|
||||
{name: "active with revoked time", hashValue: func(hash [sha256.Size]byte) any { return hash[:] }, status: StatusActive, revokedAt: "2026-08-04T00:00:00Z"},
|
||||
{name: "revoked without time", hashValue: func(hash [sha256.Size]byte) any { return hash[:] }, status: StatusRevoked},
|
||||
{name: "revoked with invalid time", hashValue: func(hash [sha256.Size]byte) any { return hash[:] }, status: StatusRevoked, revokedAt: "not-a-time"},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
rawToken, token := newRuntimeTokenPair(t)
|
||||
hash := sha256.Sum256(rawToken)
|
||||
deviceID := newRuntimeUUID(t)
|
||||
if _, err := corruptDB.Exec(`INSERT INTO device_credentials VALUES (?, ?, ?, ?)`, deviceID, test.hashValue(hash), test.status, test.revokedAt); err != nil {
|
||||
t.Fatalf("insert corrupt row: %v", err)
|
||||
}
|
||||
principal, err := corruptAuthenticator.Authenticate(credentialRequest(deviceID, "Bearer "+token))
|
||||
if !errors.Is(err, ErrUnavailable) || principal != (Principal{}) {
|
||||
t.Fatalf("corrupt Authenticate = (%#v, %v), want unavailable", principal, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCredentialAuthenticationSurvivesDatabaseReopen(t *testing.T) {
|
||||
databaseSource := filepath.Join(t.TempDir(), "reopen.db")
|
||||
database, err := sqlite.Open(databaseSource)
|
||||
if err != nil {
|
||||
t.Fatalf("open database: %v", err)
|
||||
}
|
||||
if err := migrations.Up(context.Background(), database, deviceMigrationDirectory(t)); err != nil {
|
||||
_ = database.Close()
|
||||
t.Fatalf("migrate database: %v", err)
|
||||
}
|
||||
store, err := NewCredentialStore(database)
|
||||
if err != nil {
|
||||
_ = database.Close()
|
||||
t.Fatalf("new store: %v", err)
|
||||
}
|
||||
issued, err := store.Issue(context.Background(), "reopen")
|
||||
if err != nil {
|
||||
_ = database.Close()
|
||||
t.Fatalf("issue: %v", err)
|
||||
}
|
||||
if err := database.Close(); err != nil {
|
||||
t.Fatalf("close database: %v", err)
|
||||
}
|
||||
|
||||
reopened, err := sqlite.Open(databaseSource)
|
||||
if err != nil {
|
||||
t.Fatalf("reopen database: %v", err)
|
||||
}
|
||||
defer reopened.Close()
|
||||
authenticator, err := NewSQLiteAuthenticator(reopened)
|
||||
if err != nil {
|
||||
t.Fatalf("new reopened authenticator: %v", err)
|
||||
}
|
||||
principal, err := authenticator.Authenticate(credentialRequest(issued.DeviceID, "Bearer "+issued.Token))
|
||||
if err != nil || principal.ID != issued.DeviceID {
|
||||
t.Fatalf("Authenticate after reopen = (%#v, %v)", principal, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCredentialInputAndMigrationAreRequired(t *testing.T) {
|
||||
database, err := sqlite.Open(filepath.Join(t.TempDir(), "unmigrated.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("open database: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = database.Close() })
|
||||
if _, err := NewCredentialStore(database); err == nil {
|
||||
t.Fatal("NewCredentialStore accepted an unmigrated database")
|
||||
}
|
||||
if _, err := NewSQLiteAuthenticator(database); err == nil {
|
||||
t.Fatal("NewSQLiteAuthenticator accepted an unmigrated database")
|
||||
}
|
||||
|
||||
_, store := newCredentialStore(t)
|
||||
for _, name := range []string{"", " leading", "trailing ", "line\nbreak", strings.Repeat("名", 129)} {
|
||||
if _, err := store.Issue(context.Background(), name); !errors.Is(err, ErrInvalidCredential) {
|
||||
t.Fatalf("Issue(%q) error = %v", name, err)
|
||||
}
|
||||
}
|
||||
if _, _, err := store.Revoke(context.Background(), "not-a-uuid"); !errors.Is(err, ErrInvalidCredential) {
|
||||
t.Fatalf("Revoke invalid id error = %v", err)
|
||||
}
|
||||
if _, _, err := store.Revoke(context.Background(), newRuntimeUUID(t)); !errors.Is(err, ErrCredentialNotFound) {
|
||||
t.Fatalf("Revoke unknown id error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func newCredentialStore(t *testing.T) (*sql.DB, *CredentialStore) {
|
||||
t.Helper()
|
||||
databaseSource := filepath.Join(t.TempDir(), "device-auth.db") + "?_busy_timeout=5000&_journal_mode=WAL"
|
||||
database, err := sqlite.Open(databaseSource)
|
||||
if err != nil {
|
||||
t.Fatalf("open database: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = database.Close() })
|
||||
if err := migrations.Up(context.Background(), database, deviceMigrationDirectory(t)); err != nil {
|
||||
t.Fatalf("migrate database: %v", err)
|
||||
}
|
||||
store, err := NewCredentialStore(database)
|
||||
if err != nil {
|
||||
t.Fatalf("NewCredentialStore: %v", err)
|
||||
}
|
||||
store.now = func() time.Time { return time.Date(2026, 8, 4, 12, 0, 0, 123, time.UTC) }
|
||||
return database, store
|
||||
}
|
||||
|
||||
func authenticatorForStore(t *testing.T, store *CredentialStore) *SQLiteAuthenticator {
|
||||
t.Helper()
|
||||
authenticator, err := NewSQLiteAuthenticator(store.database)
|
||||
if err != nil {
|
||||
t.Fatalf("NewSQLiteAuthenticator: %v", err)
|
||||
}
|
||||
return authenticator
|
||||
}
|
||||
|
||||
func credentialRequest(deviceID, authorization string) *http.Request {
|
||||
request := httptest.NewRequest(http.MethodPost, "/api/v1/tasks/id/evidence", nil)
|
||||
request.Header.Set(DeviceIDHeader, deviceID)
|
||||
request.Header.Set(AuthorizationHeader, authorization)
|
||||
return request
|
||||
}
|
||||
|
||||
func newRuntimeToken(t *testing.T) string {
|
||||
t.Helper()
|
||||
_, token := newRuntimeTokenPair(t)
|
||||
return token
|
||||
}
|
||||
|
||||
func newRuntimeTokenPair(t *testing.T) ([]byte, string) {
|
||||
t.Helper()
|
||||
raw := make([]byte, 32)
|
||||
if _, err := rand.Read(raw); err != nil {
|
||||
t.Fatalf("generate runtime token: %v", err)
|
||||
}
|
||||
return raw, hex.EncodeToString(raw)
|
||||
}
|
||||
|
||||
func newRuntimeUUID(t *testing.T) string {
|
||||
t.Helper()
|
||||
raw := make([]byte, 16)
|
||||
if _, err := rand.Read(raw); err != nil {
|
||||
t.Fatalf("generate runtime UUID: %v", err)
|
||||
}
|
||||
return formatUUIDv4(raw)
|
||||
}
|
||||
|
||||
func equalBytes(left, right []byte) bool {
|
||||
if len(left) != len(right) {
|
||||
return false
|
||||
}
|
||||
for index := range left {
|
||||
if left[index] != right[index] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func deviceMigrationDirectory(t *testing.T) string {
|
||||
t.Helper()
|
||||
_, file, _, ok := runtime.Caller(0)
|
||||
if !ok {
|
||||
t.Fatal("locate migrations")
|
||||
}
|
||||
return filepath.Join(filepath.Dir(file), "..", "..", "migrations")
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
package domain
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"time"
|
||||
)
|
||||
|
||||
var ErrInvalidAuthorizationTransition = errors.New("invalid authorization status transition")
|
||||
|
||||
type AuthorizationStatus string
|
||||
|
||||
const (
|
||||
AuthorizationStatusActive AuthorizationStatus = "ACTIVE"
|
||||
AuthorizationStatusClaimed AuthorizationStatus = "CLAIMED"
|
||||
AuthorizationStatusFenced AuthorizationStatus = "FENCED"
|
||||
AuthorizationStatusConsumed AuthorizationStatus = "CONSUMED"
|
||||
AuthorizationStatusExpired AuthorizationStatus = "EXPIRED"
|
||||
AuthorizationStatusAbandoned AuthorizationStatus = "ABANDONED"
|
||||
)
|
||||
|
||||
type OrderAuthorization struct {
|
||||
ID string
|
||||
TaskID string
|
||||
TaskVersion int
|
||||
StartKey string
|
||||
GoodsID string
|
||||
SKUColor string
|
||||
SKUSize string
|
||||
Quantity int
|
||||
TotalPriceCap string
|
||||
Status AuthorizationStatus
|
||||
CreatedBy string
|
||||
CreatedAt time.Time
|
||||
ExpiresAt time.Time
|
||||
}
|
||||
|
||||
// CanTransitionTo 围栏后的授权只能消费,不能回到可领取或可过期状态,以防重复采购。
|
||||
func (status AuthorizationStatus) CanTransitionTo(next AuthorizationStatus) bool {
|
||||
_, allowed := authorizationTransitions[status][next]
|
||||
return allowed
|
||||
}
|
||||
|
||||
// TransitionAuthorization 返回下一状态;未定义的授权状态转移一律失败。
|
||||
func TransitionAuthorization(current, next AuthorizationStatus) (AuthorizationStatus, error) {
|
||||
if !current.CanTransitionTo(next) {
|
||||
return current, ErrInvalidAuthorizationTransition
|
||||
}
|
||||
|
||||
return next, nil
|
||||
}
|
||||
|
||||
var authorizationTransitions = map[AuthorizationStatus]map[AuthorizationStatus]struct{}{
|
||||
AuthorizationStatusActive: {
|
||||
AuthorizationStatusClaimed: {},
|
||||
AuthorizationStatusExpired: {},
|
||||
AuthorizationStatusAbandoned: {},
|
||||
},
|
||||
AuthorizationStatusClaimed: {
|
||||
AuthorizationStatusFenced: {},
|
||||
AuthorizationStatusExpired: {},
|
||||
AuthorizationStatusAbandoned: {},
|
||||
},
|
||||
AuthorizationStatusFenced: {
|
||||
AuthorizationStatusConsumed: {},
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package domain_test
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"cmbuyer/admin/internal/domain"
|
||||
)
|
||||
|
||||
func TestAuthorizationTransitions(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
current domain.AuthorizationStatus
|
||||
next domain.AuthorizationStatus
|
||||
allowed bool
|
||||
}{
|
||||
{"claim", domain.AuthorizationStatusActive, domain.AuthorizationStatusClaimed, true},
|
||||
{"fence", domain.AuthorizationStatusClaimed, domain.AuthorizationStatusFenced, true},
|
||||
{"consume fenced authorization", domain.AuthorizationStatusFenced, domain.AuthorizationStatusConsumed, true},
|
||||
{"expire active", domain.AuthorizationStatusActive, domain.AuthorizationStatusExpired, true},
|
||||
{"abandon active", domain.AuthorizationStatusActive, domain.AuthorizationStatusAbandoned, true},
|
||||
{"expire claimed before fence", domain.AuthorizationStatusClaimed, domain.AuthorizationStatusExpired, true},
|
||||
{"abandon claimed before fence", domain.AuthorizationStatusClaimed, domain.AuthorizationStatusAbandoned, true},
|
||||
{"fenced authorization cannot expire", domain.AuthorizationStatusFenced, domain.AuthorizationStatusExpired, false},
|
||||
{"fenced authorization cannot be abandoned", domain.AuthorizationStatusFenced, domain.AuthorizationStatusAbandoned, false},
|
||||
{"fenced authorization cannot be claimed again", domain.AuthorizationStatusFenced, domain.AuthorizationStatusClaimed, false},
|
||||
{"consumed authorization cannot restart", domain.AuthorizationStatusConsumed, domain.AuthorizationStatusClaimed, false},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
if got := test.current.CanTransitionTo(test.next); got != test.allowed {
|
||||
t.Fatalf("CanTransitionTo(%s, %s) = %t, want %t", test.current, test.next, got, test.allowed)
|
||||
}
|
||||
|
||||
result, err := domain.TransitionAuthorization(test.current, test.next)
|
||||
if test.allowed {
|
||||
if err != nil {
|
||||
t.Fatalf("TransitionAuthorization(%s, %s): %v", test.current, test.next, err)
|
||||
}
|
||||
if result != test.next {
|
||||
t.Fatalf("TransitionAuthorization(%s, %s) = %s, want %s", test.current, test.next, result, test.next)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if !errors.Is(err, domain.ErrInvalidAuthorizationTransition) {
|
||||
t.Fatalf("TransitionAuthorization(%s, %s) error = %v, want ErrInvalidAuthorizationTransition", test.current, test.next, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package domain
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"time"
|
||||
)
|
||||
|
||||
var ErrInvalidAttemptTransition = errors.New("invalid purchase attempt status transition")
|
||||
|
||||
// AttemptStatus 只描述单趟领取的可恢复执行。真实提交结果独立由唯一围栏记录调和。
|
||||
type AttemptStatus string
|
||||
|
||||
const (
|
||||
AttemptStatusClaimed AttemptStatus = "CLAIMED"
|
||||
AttemptStatusOrdering AttemptStatus = "ORDERING"
|
||||
AttemptStatusFailed AttemptStatus = "FAILED"
|
||||
AttemptStatusFenced AttemptStatus = "FENCED"
|
||||
AttemptStatusAbandoned AttemptStatus = "ABANDONED"
|
||||
)
|
||||
|
||||
// AttemptFailureCode 是服务端可审计的固定失败摘要,不能承载页面正文或其他自由文本。
|
||||
type AttemptFailureCode string
|
||||
|
||||
const (
|
||||
AttemptFailureAuthorizationExpired AttemptFailureCode = "AUTHORIZATION_EXPIRED"
|
||||
AttemptFailureLeaseLost AttemptFailureCode = "LEASE_LOST"
|
||||
AttemptFailureGate1Rejected AttemptFailureCode = "GATE_1_REJECTED"
|
||||
AttemptFailureQuantityMismatch AttemptFailureCode = "QUANTITY_MISMATCH"
|
||||
AttemptFailureGate2Rejected AttemptFailureCode = "GATE_2_REJECTED"
|
||||
AttemptFailureGate3Rejected AttemptFailureCode = "GATE_3_REJECTED"
|
||||
AttemptFailureFenceRejected AttemptFailureCode = "FENCE_REJECTED"
|
||||
AttemptFailureSafeAborted AttemptFailureCode = "SAFE_ABORTED"
|
||||
)
|
||||
|
||||
type PurchaseAttempt struct {
|
||||
ID string
|
||||
TaskID string
|
||||
AuthorizationID string
|
||||
ClaimGeneration int
|
||||
Status AttemptStatus
|
||||
Gate1UnitPrice *string
|
||||
Gate2UnitPrice *string
|
||||
QuantityRead *int
|
||||
ConfirmAmount *string
|
||||
FailureCode *AttemptFailureCode
|
||||
StartedAt time.Time
|
||||
FinishedAt *time.Time
|
||||
}
|
||||
|
||||
// CanTransitionTo 只允许围栏前的领取恢复为安全失败;围栏后不再提供回退或重试路径。
|
||||
func (status AttemptStatus) CanTransitionTo(next AttemptStatus) bool {
|
||||
_, allowed := attemptTransitions[status][next]
|
||||
return allowed
|
||||
}
|
||||
|
||||
func TransitionAttempt(current, next AttemptStatus) (AttemptStatus, error) {
|
||||
if !current.CanTransitionTo(next) {
|
||||
return current, ErrInvalidAttemptTransition
|
||||
}
|
||||
return next, nil
|
||||
}
|
||||
|
||||
var attemptTransitions = map[AttemptStatus]map[AttemptStatus]struct{}{
|
||||
AttemptStatusClaimed: {
|
||||
AttemptStatusOrdering: {},
|
||||
AttemptStatusFailed: {},
|
||||
AttemptStatusAbandoned: {},
|
||||
},
|
||||
AttemptStatusOrdering: {
|
||||
AttemptStatusFenced: {},
|
||||
AttemptStatusFailed: {},
|
||||
AttemptStatusAbandoned: {},
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package domain_test
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"cmbuyer/admin/internal/domain"
|
||||
)
|
||||
|
||||
func TestPurchaseAttemptTransitions(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
current domain.AttemptStatus
|
||||
next domain.AttemptStatus
|
||||
allowed bool
|
||||
}{
|
||||
{domain.AttemptStatusClaimed, domain.AttemptStatusOrdering, true},
|
||||
{domain.AttemptStatusOrdering, domain.AttemptStatusFenced, true},
|
||||
{domain.AttemptStatusOrdering, domain.AttemptStatusFailed, true},
|
||||
{domain.AttemptStatusFenced, domain.AttemptStatusOrdering, false},
|
||||
{domain.AttemptStatusFenced, domain.AttemptStatusAbandoned, false},
|
||||
{domain.AttemptStatus("UNKNOWN"), domain.AttemptStatusOrdering, false},
|
||||
} {
|
||||
got, err := domain.TransitionAttempt(test.current, test.next)
|
||||
if test.allowed {
|
||||
if err != nil || got != test.next {
|
||||
t.Fatalf("TransitionAttempt(%s, %s) = (%s, %v)", test.current, test.next, got, err)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if !errors.Is(err, domain.ErrInvalidAttemptTransition) || got != test.current {
|
||||
t.Fatalf("invalid TransitionAttempt(%s, %s) = (%s, %v)", test.current, test.next, got, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package domain
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"time"
|
||||
)
|
||||
|
||||
var ErrInvalidSubmissionTransition = errors.New("invalid submission status transition")
|
||||
|
||||
type SubmissionStatus string
|
||||
|
||||
const (
|
||||
SubmissionStatusFenced SubmissionStatus = "FENCED"
|
||||
SubmissionStatusSubmitted SubmissionStatus = "SUBMITTED"
|
||||
SubmissionStatusReconciliationRequired SubmissionStatus = "RECONCILIATION_REQUIRED"
|
||||
SubmissionStatusManualResolved SubmissionStatus = "MANUAL_RESOLVED"
|
||||
)
|
||||
|
||||
type OrderSubmission struct {
|
||||
ID string
|
||||
TaskID string
|
||||
AuthorizationID string
|
||||
AttemptID string
|
||||
Status SubmissionStatus
|
||||
Gate1UnitPrice string
|
||||
Gate2UnitPrice string
|
||||
QuantityRead int
|
||||
ConfirmAmount string
|
||||
CreatedAt time.Time
|
||||
ResolvedAt *time.Time
|
||||
}
|
||||
|
||||
// CanTransitionTo 只允许围栏记录向最终观察结果调和,拒绝回退以防触发第二次真实动作。
|
||||
func (status SubmissionStatus) CanTransitionTo(next SubmissionStatus) bool {
|
||||
_, allowed := submissionTransitions[status][next]
|
||||
return allowed
|
||||
}
|
||||
|
||||
// TransitionSubmission 返回下一状态;未定义的提交记录状态转移一律失败。
|
||||
func TransitionSubmission(current, next SubmissionStatus) (SubmissionStatus, error) {
|
||||
if !current.CanTransitionTo(next) {
|
||||
return current, ErrInvalidSubmissionTransition
|
||||
}
|
||||
|
||||
return next, nil
|
||||
}
|
||||
|
||||
var submissionTransitions = map[SubmissionStatus]map[SubmissionStatus]struct{}{
|
||||
SubmissionStatusFenced: {
|
||||
SubmissionStatusSubmitted: {},
|
||||
SubmissionStatusReconciliationRequired: {},
|
||||
},
|
||||
SubmissionStatusReconciliationRequired: {
|
||||
SubmissionStatusManualResolved: {},
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package domain_test
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"cmbuyer/admin/internal/domain"
|
||||
)
|
||||
|
||||
func TestSubmissionTransitions(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
current domain.SubmissionStatus
|
||||
next domain.SubmissionStatus
|
||||
allowed bool
|
||||
}{
|
||||
{"submitted", domain.SubmissionStatusFenced, domain.SubmissionStatusSubmitted, true},
|
||||
{"uncertain requires reconciliation", domain.SubmissionStatusFenced, domain.SubmissionStatusReconciliationRequired, true},
|
||||
{"manual review resolves reconciliation", domain.SubmissionStatusReconciliationRequired, domain.SubmissionStatusManualResolved, true},
|
||||
{"cannot reopen fenced submission", domain.SubmissionStatusSubmitted, domain.SubmissionStatusFenced, false},
|
||||
{"submitted cannot require reconciliation", domain.SubmissionStatusSubmitted, domain.SubmissionStatusReconciliationRequired, false},
|
||||
{"cannot skip reconciliation", domain.SubmissionStatusFenced, domain.SubmissionStatusManualResolved, false},
|
||||
{"manual resolution cannot create a second submission", domain.SubmissionStatusManualResolved, domain.SubmissionStatusFenced, false},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
if got := test.current.CanTransitionTo(test.next); got != test.allowed {
|
||||
t.Fatalf("CanTransitionTo(%s, %s) = %t, want %t", test.current, test.next, got, test.allowed)
|
||||
}
|
||||
|
||||
result, err := domain.TransitionSubmission(test.current, test.next)
|
||||
if test.allowed {
|
||||
if err != nil {
|
||||
t.Fatalf("TransitionSubmission(%s, %s): %v", test.current, test.next, err)
|
||||
}
|
||||
if result != test.next {
|
||||
t.Fatalf("TransitionSubmission(%s, %s) = %s, want %s", test.current, test.next, result, test.next)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if !errors.Is(err, domain.ErrInvalidSubmissionTransition) {
|
||||
t.Fatalf("TransitionSubmission(%s, %s) error = %v, want ErrInvalidSubmissionTransition", test.current, test.next, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
// Package domain 定义采购服务的业务实体与不依赖外部系统的状态规则。
|
||||
package domain
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"time"
|
||||
)
|
||||
|
||||
var ErrInvalidTaskTransition = errors.New("invalid task status transition")
|
||||
|
||||
type TaskStatus string
|
||||
|
||||
const (
|
||||
TaskStatusDraft TaskStatus = "DRAFT"
|
||||
TaskStatusPending TaskStatus = "PENDING"
|
||||
TaskStatusClaimed TaskStatus = "CLAIMED"
|
||||
TaskStatusOrdering TaskStatus = "ORDERING"
|
||||
TaskStatusWaitingPayment TaskStatus = "WAITING_PAYMENT"
|
||||
TaskStatusReconciliationRequired TaskStatus = "RECONCILIATION_REQUIRED"
|
||||
TaskStatusNeedsManual TaskStatus = "NEEDS_MANUAL"
|
||||
TaskStatusSucceeded TaskStatus = "SUCCEEDED"
|
||||
TaskStatusFailed TaskStatus = "FAILED"
|
||||
TaskStatusCanceled TaskStatus = "CANCELED"
|
||||
)
|
||||
|
||||
type Source string
|
||||
|
||||
const (
|
||||
SourceManual Source = "MANUAL"
|
||||
SourceExcel Source = "EXCEL"
|
||||
SourceERP Source = "ERP"
|
||||
)
|
||||
|
||||
type Task struct {
|
||||
ID string
|
||||
Source Source
|
||||
SourceRef *string
|
||||
Title string
|
||||
GoodsID string
|
||||
SKUColor string
|
||||
SKUSize string
|
||||
Quantity int
|
||||
MaxTotalPrice string
|
||||
ReferenceAssetID *string
|
||||
Status TaskStatus
|
||||
Version int
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
// CanTransitionTo 只接受架构已定义的任务状态转移,未知状态或未列出的动作均拒绝。
|
||||
func (status TaskStatus) CanTransitionTo(next TaskStatus) bool {
|
||||
_, allowed := taskTransitions[status][next]
|
||||
return allowed
|
||||
}
|
||||
|
||||
// TransitionTask 返回下一状态;非法转移必须在写库前失败,不能由调用方猜测补救路径。
|
||||
func TransitionTask(current, next TaskStatus) (TaskStatus, error) {
|
||||
if !current.CanTransitionTo(next) {
|
||||
return current, ErrInvalidTaskTransition
|
||||
}
|
||||
|
||||
return next, nil
|
||||
}
|
||||
|
||||
var taskTransitions = map[TaskStatus]map[TaskStatus]struct{}{
|
||||
TaskStatusDraft: {
|
||||
TaskStatusPending: {},
|
||||
TaskStatusCanceled: {},
|
||||
},
|
||||
TaskStatusPending: {
|
||||
TaskStatusClaimed: {},
|
||||
TaskStatusDraft: {},
|
||||
TaskStatusCanceled: {},
|
||||
},
|
||||
TaskStatusClaimed: {
|
||||
TaskStatusOrdering: {},
|
||||
TaskStatusDraft: {},
|
||||
},
|
||||
TaskStatusOrdering: {
|
||||
TaskStatusNeedsManual: {},
|
||||
TaskStatusWaitingPayment: {},
|
||||
TaskStatusReconciliationRequired: {},
|
||||
},
|
||||
TaskStatusNeedsManual: {
|
||||
TaskStatusDraft: {},
|
||||
TaskStatusCanceled: {},
|
||||
},
|
||||
TaskStatusWaitingPayment: {
|
||||
TaskStatusSucceeded: {},
|
||||
},
|
||||
TaskStatusReconciliationRequired: {
|
||||
TaskStatusWaitingPayment: {},
|
||||
TaskStatusFailed: {},
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package domain_test
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"cmbuyer/admin/internal/domain"
|
||||
)
|
||||
|
||||
func TestTaskTransitions(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
current domain.TaskStatus
|
||||
next domain.TaskStatus
|
||||
allowed bool
|
||||
}{
|
||||
{"start purchase", domain.TaskStatusDraft, domain.TaskStatusPending, true},
|
||||
{"cancel draft before fence", domain.TaskStatusDraft, domain.TaskStatusCanceled, true},
|
||||
{"claim purchase", domain.TaskStatusPending, domain.TaskStatusClaimed, true},
|
||||
{"release expired authorization", domain.TaskStatusPending, domain.TaskStatusDraft, true},
|
||||
{"start ordering", domain.TaskStatusClaimed, domain.TaskStatusOrdering, true},
|
||||
{"release unstarted claim", domain.TaskStatusClaimed, domain.TaskStatusDraft, true},
|
||||
{"ordering needs manual review", domain.TaskStatusOrdering, domain.TaskStatusNeedsManual, true},
|
||||
{"order reaches payment", domain.TaskStatusOrdering, domain.TaskStatusWaitingPayment, true},
|
||||
{"order needs manual review before fence", domain.TaskStatusOrdering, domain.TaskStatusNeedsManual, true},
|
||||
{"order needs reconciliation", domain.TaskStatusOrdering, domain.TaskStatusReconciliationRequired, true},
|
||||
{"manual review resets draft", domain.TaskStatusNeedsManual, domain.TaskStatusDraft, true},
|
||||
{"manual review cancels before fence", domain.TaskStatusNeedsManual, domain.TaskStatusCanceled, true},
|
||||
{"payment verified", domain.TaskStatusWaitingPayment, domain.TaskStatusSucceeded, true},
|
||||
{"reconcile confirms waiting payment", domain.TaskStatusReconciliationRequired, domain.TaskStatusWaitingPayment, true},
|
||||
{"reconcile confirms failed", domain.TaskStatusReconciliationRequired, domain.TaskStatusFailed, true},
|
||||
{"cannot skip authorization", domain.TaskStatusDraft, domain.TaskStatusOrdering, false},
|
||||
{"ordering cannot return pending", domain.TaskStatusOrdering, domain.TaskStatusPending, false},
|
||||
{"ordering cannot bypass manual review to draft", domain.TaskStatusOrdering, domain.TaskStatusDraft, false},
|
||||
{"terminal task cannot restart", domain.TaskStatusSucceeded, domain.TaskStatusPending, false},
|
||||
{"unknown status is rejected", domain.TaskStatus("UNKNOWN"), domain.TaskStatusPending, false},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
if got := test.current.CanTransitionTo(test.next); got != test.allowed {
|
||||
t.Fatalf("CanTransitionTo(%s, %s) = %t, want %t", test.current, test.next, got, test.allowed)
|
||||
}
|
||||
|
||||
result, err := domain.TransitionTask(test.current, test.next)
|
||||
if test.allowed {
|
||||
if err != nil {
|
||||
t.Fatalf("TransitionTask(%s, %s): %v", test.current, test.next, err)
|
||||
}
|
||||
if result != test.next {
|
||||
t.Fatalf("TransitionTask(%s, %s) = %s, want %s", test.current, test.next, result, test.next)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if !errors.Is(err, domain.ErrInvalidTaskTransition) {
|
||||
t.Fatalf("TransitionTask(%s, %s) error = %v, want ErrInvalidTaskTransition", test.current, test.next, err)
|
||||
}
|
||||
if result != test.current {
|
||||
t.Fatalf("TransitionTask(%s, %s) result = %s, want unchanged status", test.current, test.next, result)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
// Package evidence defines the narrow internal screenshot contract shared by HTTP and storage.
|
||||
package evidence
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"time"
|
||||
|
||||
"cmbuyer/admin/internal/deviceauth"
|
||||
)
|
||||
|
||||
const (
|
||||
KindSKUPanelGate1 = "SKU_PANEL_GATE_1"
|
||||
PrivacyInternalRaw = "INTERNAL_RAW"
|
||||
PNGContentType = "image/png"
|
||||
MaxFileBytes int64 = 10 << 20
|
||||
MaxImageSide = 8192
|
||||
MaxImagePixels = 16_777_216
|
||||
)
|
||||
|
||||
var (
|
||||
ErrInvalid = errors.New("invalid evidence")
|
||||
ErrConflict = errors.New("evidence upload key conflict")
|
||||
ErrNotFound = errors.New("evidence not found")
|
||||
ErrTooLarge = errors.New("evidence file too large")
|
||||
)
|
||||
|
||||
type UploadMetadata struct {
|
||||
UploadKey string
|
||||
TaskID string
|
||||
AttemptID string
|
||||
Kind string
|
||||
PrivacyTier string
|
||||
SHA256 string
|
||||
CapturedAt time.Time
|
||||
}
|
||||
|
||||
// StagedFile contains only server-generated state. Multipart filenames and client paths never enter this type.
|
||||
type StagedFile struct {
|
||||
Path string
|
||||
SHA256 string
|
||||
ByteSize int64
|
||||
ContentType string
|
||||
Width int
|
||||
Height int
|
||||
}
|
||||
|
||||
type Asset struct {
|
||||
ID string `json:"asset_id"`
|
||||
TaskID string `json:"task_id"`
|
||||
AttemptID string `json:"attempt_id"`
|
||||
Kind string `json:"kind"`
|
||||
PrivacyTier string `json:"privacy_tier"`
|
||||
SHA256 string `json:"sha256"`
|
||||
ByteSize int64 `json:"byte_size"`
|
||||
ContentType string `json:"content_type"`
|
||||
Width int `json:"width_px"`
|
||||
Height int `json:"height_px"`
|
||||
CapturedAt time.Time `json:"captured_at"`
|
||||
UploadedByDeviceID string `json:"-"`
|
||||
StorageKey string `json:"-"`
|
||||
CreatedAt time.Time `json:"-"`
|
||||
}
|
||||
|
||||
// Store separates bounded multipart staging from metadata commit so field order cannot weaken validation.
|
||||
type Store interface {
|
||||
Stage(io.Reader, string) (StagedFile, error)
|
||||
Discard(StagedFile)
|
||||
Commit(context.Context, deviceauth.Principal, UploadMetadata, StagedFile) (Asset, bool, error)
|
||||
Open(context.Context, string) (Asset, io.ReadSeekCloser, error)
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
// Package migrations 通过 goose 执行采购服务的版本化数据库迁移。
|
||||
package migrations
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
|
||||
"github.com/pressly/goose/v3"
|
||||
)
|
||||
|
||||
// Run 执行指定的 goose 命令。迁移目录由调用方显式传入,避免把运行目录当作隐式配置。
|
||||
func Run(ctx context.Context, database *sql.DB, directory, command string) error {
|
||||
if err := goose.SetDialect("sqlite3"); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return goose.RunContext(ctx, command, database, directory)
|
||||
}
|
||||
|
||||
// Up 将数据库迁移到当前版本。
|
||||
func Up(ctx context.Context, database *sql.DB, directory string) error {
|
||||
return Run(ctx, database, directory, "up")
|
||||
}
|
||||
|
||||
// Down 回退一个已应用的迁移版本。
|
||||
func Down(ctx context.Context, database *sql.DB, directory string) error {
|
||||
return Run(ctx, database, directory, "down")
|
||||
}
|
||||
@@ -0,0 +1,548 @@
|
||||
package migrations_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"cmbuyer/admin/internal/migrations"
|
||||
"cmbuyer/admin/internal/storage/sqlite"
|
||||
|
||||
"github.com/pressly/goose/v3"
|
||||
)
|
||||
|
||||
const migrationTime = "2026-08-04T00:00:00Z"
|
||||
|
||||
func TestUpDownAndIdempotence(t *testing.T) {
|
||||
database := openTestDatabase(t)
|
||||
directory := migrationDirectory(t)
|
||||
context := context.Background()
|
||||
|
||||
if err := migrations.Up(context, database, directory); err != nil {
|
||||
t.Fatalf("apply migrations: %v", err)
|
||||
}
|
||||
assertVersion(t, database, 4)
|
||||
assertTableExists(t, database, "tasks", true)
|
||||
assertTableExists(t, database, "spec_trials", false)
|
||||
assertTableExists(t, database, "order_authorizations", true)
|
||||
assertTableExists(t, database, "purchase_attempts", true)
|
||||
assertTableExists(t, database, "order_submissions", true)
|
||||
assertTableExists(t, database, "evidence_assets", true)
|
||||
assertTableExists(t, database, "device_credentials", true)
|
||||
assertTableExists(t, database, "single_pass_upgrade_guard", false)
|
||||
|
||||
if err := migrations.Up(context, database, directory); err != nil {
|
||||
t.Fatalf("reapply migrations: %v", err)
|
||||
}
|
||||
assertVersion(t, database, 4)
|
||||
|
||||
if err := migrations.Down(context, database, directory); err != nil {
|
||||
t.Fatalf("roll back device credential migration: %v", err)
|
||||
}
|
||||
assertVersion(t, database, 3)
|
||||
assertTableExists(t, database, "device_credentials", false)
|
||||
assertTableExists(t, database, "evidence_assets", true)
|
||||
|
||||
if err := migrations.Down(context, database, directory); err != nil {
|
||||
t.Fatalf("roll back evidence migration: %v", err)
|
||||
}
|
||||
assertVersion(t, database, 2)
|
||||
assertTableExists(t, database, "evidence_assets", false)
|
||||
|
||||
if err := migrations.Down(context, database, directory); err != nil {
|
||||
t.Fatalf("roll back v2 migration: %v", err)
|
||||
}
|
||||
assertVersion(t, database, 1)
|
||||
assertTableExists(t, database, "spec_trials", true)
|
||||
assertTableExists(t, database, "purchase_attempts", false)
|
||||
assertTableExists(t, database, "single_pass_downgrade_guard", false)
|
||||
|
||||
if err := migrations.Up(context, database, directory); err != nil {
|
||||
t.Fatalf("reapply v2 after rollback: %v", err)
|
||||
}
|
||||
assertVersion(t, database, 4)
|
||||
}
|
||||
|
||||
func TestUpgradePreservesManualDraftLosslessly(t *testing.T) {
|
||||
database := openTestDatabase(t)
|
||||
migrateToV1(t, database)
|
||||
if _, err := database.Exec(`
|
||||
INSERT INTO tasks (
|
||||
id, source, source_ref, title, goods_id, sku_color, sku_size, quantity, max_total_price,
|
||||
reference_asset_id, status, version, created_at, updated_at
|
||||
) VALUES ('draft-one', 'MANUAL', 'source-ref', 'title', 'goods', 'white', 'XL', 2, '80.50',
|
||||
'asset-id', 'DRAFT', 7, '2026-08-03T00:00:00Z', '2026-08-03T01:00:00Z')
|
||||
`); err != nil {
|
||||
t.Fatalf("insert v1 draft: %v", err)
|
||||
}
|
||||
|
||||
if err := migrations.Up(context.Background(), database, migrationDirectory(t)); err != nil {
|
||||
t.Fatalf("upgrade v1 draft: %v", err)
|
||||
}
|
||||
assertVersion(t, database, 4)
|
||||
var got struct {
|
||||
id, source, sourceRef, title, goodsID, color, size, maxPrice, assetID, status, created, updated string
|
||||
quantity, version int
|
||||
}
|
||||
if err := database.QueryRow(`SELECT id, source, source_ref, title, goods_id, sku_color, sku_size, quantity, max_total_price, reference_asset_id, status, version, created_at, updated_at FROM tasks WHERE id = 'draft-one'`).Scan(
|
||||
&got.id, &got.source, &got.sourceRef, &got.title, &got.goodsID, &got.color, &got.size, &got.quantity, &got.maxPrice, &got.assetID, &got.status, &got.version, &got.created, &got.updated,
|
||||
); err != nil {
|
||||
t.Fatalf("read upgraded draft: %v", err)
|
||||
}
|
||||
if got != (struct {
|
||||
id, source, sourceRef, title, goodsID, color, size, maxPrice, assetID, status, created, updated string
|
||||
quantity, version int
|
||||
}{"draft-one", "MANUAL", "source-ref", "title", "goods", "white", "XL", "80.50", "asset-id", "DRAFT", "2026-08-03T00:00:00Z", "2026-08-03T01:00:00Z", 2, 7}) {
|
||||
t.Fatalf("upgraded draft changed: %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpgradeRejectsLegacyExecutionDataAtomically(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
setup func(*testing.T, *sql.DB)
|
||||
}{
|
||||
{"non-draft task", func(t *testing.T, database *sql.DB) {
|
||||
insertV1Task(t, database, "pending", "MANUAL", "PENDING", "1.00")
|
||||
}},
|
||||
{"non-manual task", func(t *testing.T, database *sql.DB) { insertV1Task(t, database, "excel", "EXCEL", "DRAFT", "1.00") }},
|
||||
{"invalid v2 money", func(t *testing.T, database *sql.DB) { insertV1Task(t, database, "zero", "MANUAL", "DRAFT", "0.00") }},
|
||||
{"third decimal place", func(t *testing.T, database *sql.DB) {
|
||||
insertV1Task(t, database, "third-decimal", "MANUAL", "DRAFT", "1.234")
|
||||
}},
|
||||
{"spec trial", func(t *testing.T, database *sql.DB) {
|
||||
insertV1Task(t, database, "task", "MANUAL", "DRAFT", "1.00")
|
||||
insertV1SpecTrial(t, database, "trial", "task")
|
||||
}},
|
||||
{"authorization", func(t *testing.T, database *sql.DB) {
|
||||
insertV1Task(t, database, "task", "MANUAL", "DRAFT", "1.00")
|
||||
insertV1SpecTrial(t, database, "trial", "task")
|
||||
insertV1Authorization(t, database, "auth", "task", "trial")
|
||||
}},
|
||||
{"submission", func(t *testing.T, database *sql.DB) {
|
||||
insertV1Task(t, database, "task", "MANUAL", "DRAFT", "1.00")
|
||||
insertV1SpecTrial(t, database, "trial", "task")
|
||||
insertV1Authorization(t, database, "auth", "task", "trial")
|
||||
if _, err := database.Exec(`INSERT INTO order_submissions (id, task_id, authorization_id, command_id, dry_run_id, status, verified_unit_price, quantity_read, confirm_page_amount, created_at) VALUES ('submission', 'task', 'auth', 'command', 'dry-run', 'FENCED', '1.00', 1, '1.00', ? )`, migrationTime); err != nil {
|
||||
t.Fatalf("insert v1 submission: %v", err)
|
||||
}
|
||||
}},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
database := openTestDatabase(t)
|
||||
migrateToV1(t, database)
|
||||
test.setup(t, database)
|
||||
before := v1RowCount(t, database)
|
||||
if err := migrations.Up(context.Background(), database, migrationDirectory(t)); err == nil {
|
||||
t.Fatal("unsafe legacy data upgraded successfully")
|
||||
}
|
||||
assertVersion(t, database, 1)
|
||||
assertTableExists(t, database, "spec_trials", true)
|
||||
assertTableExists(t, database, "purchase_attempts", false)
|
||||
assertTableExists(t, database, "single_pass_upgrade_guard", false)
|
||||
if after := v1RowCount(t, database); after != before {
|
||||
t.Fatalf("v1 data changed after rejection: before=%d after=%d", before, after)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestV2SchemaConstraintsAndRelationships(t *testing.T) {
|
||||
database := openTestDatabase(t)
|
||||
if err := migrations.Up(context.Background(), database, migrationDirectory(t)); err != nil {
|
||||
t.Fatalf("apply migrations: %v", err)
|
||||
}
|
||||
for _, column := range []struct{ table, name string }{
|
||||
{"tasks", "max_total_price"},
|
||||
{"order_authorizations", "total_price_cap"},
|
||||
{"purchase_attempts", "gate1_unit_price"},
|
||||
{"purchase_attempts", "gate2_unit_price"},
|
||||
{"purchase_attempts", "confirm_amount"},
|
||||
{"order_submissions", "gate1_unit_price"},
|
||||
{"order_submissions", "gate2_unit_price"},
|
||||
{"order_submissions", "confirm_amount"},
|
||||
} {
|
||||
assertColumnType(t, database, column.table, column.name, "TEXT")
|
||||
}
|
||||
for _, legacy := range []string{"spec_trials", "authorized_unit_price", "spec_trial_id", "command_id", "dry_run_id"} {
|
||||
var count int
|
||||
if err := database.QueryRow(`SELECT COUNT(*) FROM sqlite_master WHERE sql LIKE '%' || ? || '%'`, legacy).Scan(&count); err != nil {
|
||||
t.Fatalf("search schema for %s: %v", legacy, err)
|
||||
}
|
||||
if count != 0 {
|
||||
t.Fatalf("legacy identifier %q remains in v2 schema", legacy)
|
||||
}
|
||||
}
|
||||
|
||||
insertV2Task(t, database, "task-one", "MANUAL", "DRAFT")
|
||||
insertV2Task(t, database, "task-two", "MANUAL", "DRAFT")
|
||||
for index, value := range []string{"", "0", "0.00", "-1.00", "1e2", "1.", "1.234", " 1.00", "one"} {
|
||||
if _, err := database.Exec(`INSERT INTO tasks (id, source, title, goods_id, sku_color, sku_size, quantity, max_total_price, status, created_at, updated_at) VALUES (?, 'MANUAL', 'title', 'goods', 'white', 'XL', 1, ?, 'DRAFT', ?, ?)`, "bad-price-"+strconv.Itoa(index), value, migrationTime, migrationTime); err == nil {
|
||||
t.Fatalf("invalid total price %q succeeded", value)
|
||||
}
|
||||
}
|
||||
if _, err := database.Exec(`INSERT INTO tasks (id, source, title, goods_id, sku_color, sku_size, quantity, max_total_price, status, created_at, updated_at) VALUES ('bad-status', 'MANUAL', 'title', 'goods', 'white', 'XL', 1, '1.00', 'UNKNOWN', ?, ?)`, migrationTime, migrationTime); err == nil {
|
||||
t.Fatal("unknown task status succeeded")
|
||||
}
|
||||
insertV2Authorization(t, database, "auth-one", "task-one", 1, "start-one")
|
||||
insertV2Authorization(t, database, "auth-two", "task-two", 1, "start-two")
|
||||
if _, err := database.Exec(`INSERT INTO order_authorizations (id, task_id, task_version, start_key, goods_id, sku_color, sku_size, quantity, total_price_cap, status, created_by, created_at, expires_at) VALUES ('bad-auth-price', 'task-one', 2, 'bad-price', 'goods', 'white', 'XL', 1, '1.234', 'ACTIVE', 'admin', ?, ?)`, migrationTime, migrationTime); err == nil {
|
||||
t.Fatal("third decimal authorization cap succeeded")
|
||||
}
|
||||
if _, err := database.Exec(`INSERT INTO order_authorizations (id, task_id, task_version, start_key, goods_id, sku_color, sku_size, quantity, total_price_cap, status, created_by, created_at, expires_at) VALUES ('bad-auth-status', 'task-one', 2, 'bad-status', 'goods', 'white', 'XL', 1, '1.00', 'UNKNOWN', 'admin', ?, ?)`, migrationTime, migrationTime); err == nil {
|
||||
t.Fatal("unknown authorization status succeeded")
|
||||
}
|
||||
insertV2Authorization(t, database, "auth-one-b", "task-one", 2, "start-one-b")
|
||||
if _, err := database.Exec(`INSERT INTO order_authorizations (id, task_id, task_version, start_key, goods_id, sku_color, sku_size, quantity, total_price_cap, status, created_by, created_at, expires_at) VALUES ('duplicate-version', 'task-one', 1, 'different-start', 'goods', 'white', 'XL', 1, '1.00', 'ACTIVE', 'admin', ?, ?)`, migrationTime, migrationTime); err == nil {
|
||||
t.Fatal("duplicate task version authorization succeeded")
|
||||
}
|
||||
if _, err := database.Exec(`INSERT INTO purchase_attempts (id, task_id, authorization_id, claim_generation, status, started_at) VALUES ('cross-attempt', 'task-one', 'auth-two', 1, 'CLAIMED', ?)`, migrationTime); err == nil {
|
||||
t.Fatal("attempt using another task authorization succeeded")
|
||||
}
|
||||
insertV2Attempt(t, database, "attempt-one", "task-one", "auth-one", 1)
|
||||
if _, err := database.Exec(`INSERT INTO purchase_attempts (id, task_id, authorization_id, claim_generation, status, gate1_unit_price, started_at) VALUES ('bad-attempt-price', 'task-one', 'auth-one', 2, 'ORDERING', '1.234', ?)`, migrationTime); err == nil {
|
||||
t.Fatal("third decimal gate price succeeded")
|
||||
}
|
||||
if _, err := database.Exec(`INSERT INTO purchase_attempts (id, task_id, authorization_id, claim_generation, status, started_at) VALUES ('bad-attempt-status', 'task-one', 'auth-one', 2, 'UNKNOWN', ?)`, migrationTime); err == nil {
|
||||
t.Fatal("unknown attempt status succeeded")
|
||||
}
|
||||
if _, err := database.Exec(`INSERT INTO purchase_attempts (id, task_id, authorization_id, claim_generation, status, failure_code, started_at) VALUES ('bad-code', 'task-one', 'auth-one', 2, 'FAILED', 'FREE_TEXT', ?)`, migrationTime); err == nil {
|
||||
t.Fatal("unknown failure code succeeded")
|
||||
}
|
||||
if _, err := database.Exec(`INSERT INTO order_submissions (id, task_id, authorization_id, attempt_id, status, gate1_unit_price, gate2_unit_price, quantity_read, confirm_amount, created_at) VALUES ('cross-submission', 'task-one', 'auth-two', 'attempt-one', 'FENCED', '1.00', '1.00', 1, '1.00', ?)`, migrationTime); err == nil {
|
||||
t.Fatal("submission using another task authorization succeeded")
|
||||
}
|
||||
if _, err := database.Exec(`INSERT INTO order_submissions (id, task_id, authorization_id, attempt_id, status, gate1_unit_price, gate2_unit_price, quantity_read, confirm_amount, created_at) VALUES ('cross-authorization-submission', 'task-one', 'auth-one-b', 'attempt-one', 'FENCED', '1.00', '1.00', 1, '1.00', ?)`, migrationTime); err == nil {
|
||||
t.Fatal("submission combining another same-task authorization and attempt succeeded")
|
||||
}
|
||||
if _, err := database.Exec(`INSERT INTO order_submissions (id, task_id, authorization_id, attempt_id, status, gate1_unit_price, gate2_unit_price, quantity_read, confirm_amount, created_at) VALUES ('bad-submission-status', 'task-one', 'auth-one', 'attempt-one', 'UNKNOWN', '1.00', '1.00', 1, '1.00', ?)`, migrationTime); err == nil {
|
||||
t.Fatal("unknown submission status succeeded")
|
||||
}
|
||||
if _, err := database.Exec(`INSERT INTO order_submissions (id, task_id, authorization_id, attempt_id, status, gate1_unit_price, gate2_unit_price, quantity_read, confirm_amount, created_at) VALUES ('bad-submission-price', 'task-one', 'auth-one', 'attempt-one', 'FENCED', '1.234', '1.00', 1, '1.00', ?)`, migrationTime); err == nil {
|
||||
t.Fatal("third decimal submission price succeeded")
|
||||
}
|
||||
insertV2Submission(t, database, "submission-one", "task-one", "auth-one", "attempt-one")
|
||||
if _, err := database.Exec(`INSERT INTO order_submissions (id, task_id, authorization_id, attempt_id, status, gate1_unit_price, gate2_unit_price, quantity_read, confirm_amount, created_at) VALUES ('duplicate-auth', 'task-one', 'auth-one', 'attempt-one', 'FENCED', '1.00', '1.00', 1, '1.00', ?)`, migrationTime); err == nil {
|
||||
t.Fatal("second submission for fenced authorization succeeded")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvidenceSchemaConstraintsAndDowngradeGuard(t *testing.T) {
|
||||
database := openTestDatabase(t)
|
||||
if err := migrations.Up(context.Background(), database, migrationDirectory(t)); err != nil {
|
||||
t.Fatalf("apply migrations: %v", err)
|
||||
}
|
||||
insertV2Task(t, database, "task-one", "MANUAL", "DRAFT")
|
||||
insertV2Authorization(t, database, "auth-one", "task-one", 1, "start-one")
|
||||
insertV2Attempt(t, database, "attempt-one", "task-one", "auth-one", 1)
|
||||
insertV2Task(t, database, "task-two", "MANUAL", "DRAFT")
|
||||
insertV2Authorization(t, database, "auth-two", "task-two", 1, "start-two")
|
||||
insertV2Attempt(t, database, "attempt-two", "task-two", "auth-two", 1)
|
||||
hash := strings.Repeat("a", 64)
|
||||
insert := `INSERT INTO evidence_assets (id, upload_key, task_id, attempt_id, kind, privacy_tier, sha256, byte_size, content_type, width_px, height_px, storage_key, uploaded_by_device_id, captured_at, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
||||
validArgs := []any{"asset-one", "upload-one", "task-one", "attempt-one", "SKU_PANEL_GATE_1", "INTERNAL_RAW", hash, 100, "image/png", 100, 100, "aa/" + hash + ".png", "device-one", migrationTime, migrationTime}
|
||||
if _, err := database.Exec(insert, validArgs...); err != nil {
|
||||
t.Fatalf("insert valid evidence: %v", err)
|
||||
}
|
||||
for name, mutate := range map[string]func([]any){
|
||||
"attempt from another task": func(values []any) { values[0], values[1], values[3] = "bad-task", "upload-bad-task", "attempt-two" },
|
||||
"unapproved kind": func(values []any) { values[0], values[1], values[4] = "bad-kind", "upload-bad-kind", "ORDER_CONFIRM" },
|
||||
"wrong privacy": func(values []any) { values[0], values[1], values[5] = "bad-privacy", "upload-bad-privacy", "PUBLIC" },
|
||||
"uppercase hash": func(values []any) {
|
||||
values[0], values[1], values[6], values[11] = "bad-hash", "upload-bad-hash", strings.Repeat("A", 64), "AA/"+strings.Repeat("A", 64)+".png"
|
||||
},
|
||||
"too many pixels": func(values []any) {
|
||||
values[0], values[1], values[9], values[10] = "bad-pixels", "upload-bad-pixels", 8192, 8192
|
||||
},
|
||||
"client path": func(values []any) { values[0], values[1], values[11] = "bad-path", "upload-bad-path", `..\secret.png` },
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
values := append([]any(nil), validArgs...)
|
||||
mutate(values)
|
||||
if _, err := database.Exec(insert, values...); err == nil {
|
||||
t.Fatal("invalid evidence row succeeded")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
if err := migrations.Down(context.Background(), database, migrationDirectory(t)); err != nil {
|
||||
t.Fatalf("roll back empty device credential migration: %v", err)
|
||||
}
|
||||
if err := migrations.Down(context.Background(), database, migrationDirectory(t)); err == nil {
|
||||
t.Fatal("evidence-bearing schema downgraded successfully")
|
||||
}
|
||||
assertVersion(t, database, 3)
|
||||
assertTableExists(t, database, "evidence_assets", true)
|
||||
var count int
|
||||
if err := database.QueryRow("SELECT COUNT(*) FROM evidence_assets").Scan(&count); err != nil || count != 1 {
|
||||
t.Fatalf("evidence after rejected downgrade = %d, err=%v", count, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeviceCredentialSchemaConstraintsAndDowngradeGuard(t *testing.T) {
|
||||
database := openTestDatabase(t)
|
||||
if err := migrations.Up(context.Background(), database, migrationDirectory(t)); err != nil {
|
||||
t.Fatalf("apply migrations: %v", err)
|
||||
}
|
||||
deviceID := "13c9f507-7473-4fa6-8d71-8786c34c6301"
|
||||
hash := make([]byte, 32)
|
||||
for index := range hash {
|
||||
hash[index] = byte(index + 1)
|
||||
}
|
||||
insert := `INSERT INTO device_credentials (device_id, display_name, token_sha256, status, created_at, revoked_at) VALUES (?, ?, ?, ?, ?, ?)`
|
||||
valid := []any{deviceID, "采购工具一号", hash, "ACTIVE", migrationTime, nil}
|
||||
if _, err := database.Exec(insert, valid...); err != nil {
|
||||
t.Fatalf("insert valid credential: %v", err)
|
||||
}
|
||||
for name, mutate := range map[string]func([]any){
|
||||
"uppercase uuid": func(values []any) { values[0], values[2] = strings.ToUpper(deviceID), append([]byte(nil), hash...) },
|
||||
"wrong uuid version": func(values []any) {
|
||||
values[0], values[2] = "23c9f507-7473-3fa6-8d71-8786c34c6301", append([]byte(nil), hash...)
|
||||
},
|
||||
"blank display name": func(values []any) {
|
||||
values[0], values[1], values[2] = "33c9f507-7473-4fa6-8d71-8786c34c6301", "", append([]byte(nil), hash...)
|
||||
},
|
||||
"padded display name": func(values []any) {
|
||||
values[0], values[1], values[2] = "43c9f507-7473-4fa6-8d71-8786c34c6301", " padded", append([]byte(nil), hash...)
|
||||
},
|
||||
"text hash": func(values []any) {
|
||||
values[0], values[2] = "53c9f507-7473-4fa6-8d71-8786c34c6301", strings.Repeat("a", 32)
|
||||
},
|
||||
"short blob hash": func(values []any) { values[0], values[2] = "63c9f507-7473-4fa6-8d71-8786c34c6301", make([]byte, 31) },
|
||||
"unknown status": func(values []any) {
|
||||
values[0], values[2], values[3] = "73c9f507-7473-4fa6-8d71-8786c34c6301", append([]byte(nil), hash...), "UNKNOWN"
|
||||
},
|
||||
"active with revoke time": func(values []any) {
|
||||
values[0], values[2], values[5] = "83c9f507-7473-4fa6-8d71-8786c34c6301", append([]byte(nil), hash...), migrationTime
|
||||
},
|
||||
"revoked without time": func(values []any) {
|
||||
values[0], values[2], values[3] = "93c9f507-7473-4fa6-8d71-8786c34c6301", append([]byte(nil), hash...), "REVOKED"
|
||||
},
|
||||
"revoke before creation": func(values []any) {
|
||||
values[0], values[2], values[3], values[4], values[5] = "b3c9f507-7473-4fa6-8d71-8786c34c6301", append([]byte(nil), hash...), "REVOKED", "2026-08-04T01:00:00Z", "2026-08-04T00:00:00Z"
|
||||
},
|
||||
"non UTC created time": func(values []any) {
|
||||
values[0], values[2], values[4] = "a3c9f507-7473-4fa6-8d71-8786c34c6301", append([]byte(nil), hash...), "2026-08-04T08:00:00+08:00"
|
||||
},
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
values := append([]any(nil), valid...)
|
||||
mutate(values)
|
||||
if bytesValue, ok := values[2].([]byte); ok && len(bytesValue) == 32 {
|
||||
bytesValue[0]++
|
||||
}
|
||||
if _, err := database.Exec(insert, values...); err == nil {
|
||||
t.Fatal("invalid device credential row succeeded")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
if err := migrations.Down(context.Background(), database, migrationDirectory(t)); err == nil {
|
||||
t.Fatal("credential-bearing schema downgraded successfully")
|
||||
}
|
||||
assertVersion(t, database, 4)
|
||||
assertTableExists(t, database, "device_credentials", true)
|
||||
var count int
|
||||
if err := database.QueryRow(`SELECT COUNT(*) FROM device_credentials`).Scan(&count); err != nil || count != 1 {
|
||||
t.Fatalf("credentials after rejected downgrade = %d, err=%v", count, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDowngradeRejectsV2BusinessDataAtomically(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
setup func(*testing.T, *sql.DB)
|
||||
}{
|
||||
{"authorization", func(t *testing.T, database *sql.DB) {
|
||||
insertV2Task(t, database, "task", "MANUAL", "DRAFT")
|
||||
insertV2Authorization(t, database, "auth", "task", 1, "start")
|
||||
}},
|
||||
{"attempt", func(t *testing.T, database *sql.DB) {
|
||||
insertV2Task(t, database, "task", "MANUAL", "DRAFT")
|
||||
insertV2Authorization(t, database, "auth", "task", 1, "start")
|
||||
insertV2Attempt(t, database, "attempt", "task", "auth", 1)
|
||||
}},
|
||||
{"submission", func(t *testing.T, database *sql.DB) {
|
||||
insertV2Task(t, database, "task", "MANUAL", "DRAFT")
|
||||
insertV2Authorization(t, database, "auth", "task", 1, "start")
|
||||
insertV2Attempt(t, database, "attempt", "task", "auth", 1)
|
||||
insertV2Submission(t, database, "submission", "task", "auth", "attempt")
|
||||
}},
|
||||
{"non-draft task", func(t *testing.T, database *sql.DB) { insertV2Task(t, database, "pending", "MANUAL", "PENDING") }},
|
||||
{"non-manual task", func(t *testing.T, database *sql.DB) { insertV2Task(t, database, "excel", "EXCEL", "DRAFT") }},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
database := openTestDatabase(t)
|
||||
migrateToV2(t, database)
|
||||
test.setup(t, database)
|
||||
before := v2RowCount(t, database)
|
||||
if err := migrations.Down(context.Background(), database, migrationDirectory(t)); err == nil {
|
||||
t.Fatal("unsafe v2 data downgraded successfully")
|
||||
}
|
||||
assertVersion(t, database, 2)
|
||||
assertTableExists(t, database, "purchase_attempts", true)
|
||||
assertTableExists(t, database, "spec_trials", false)
|
||||
assertTableExists(t, database, "single_pass_downgrade_guard", false)
|
||||
if after := v2RowCount(t, database); after != before {
|
||||
t.Fatalf("v2 data changed after rejected downgrade: before=%d after=%d", before, after)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func migrateToV1(t *testing.T, database *sql.DB) {
|
||||
t.Helper()
|
||||
if err := migrations.Run(context.Background(), database, migrationDirectory(t), "up-by-one"); err != nil {
|
||||
t.Fatalf("apply v1: %v", err)
|
||||
}
|
||||
assertVersion(t, database, 1)
|
||||
}
|
||||
|
||||
func migrateToV2(t *testing.T, database *sql.DB) {
|
||||
t.Helper()
|
||||
if err := migrations.Run(context.Background(), database, migrationDirectory(t), "up-by-one"); err != nil {
|
||||
t.Fatalf("apply v1: %v", err)
|
||||
}
|
||||
if err := migrations.Run(context.Background(), database, migrationDirectory(t), "up-by-one"); err != nil {
|
||||
t.Fatalf("apply v2: %v", err)
|
||||
}
|
||||
assertVersion(t, database, 2)
|
||||
}
|
||||
|
||||
func insertV1Task(t *testing.T, database *sql.DB, id, source, status, price string) {
|
||||
t.Helper()
|
||||
if _, err := database.Exec(`INSERT INTO tasks (id, source, title, goods_id, sku_color, sku_size, quantity, max_total_price, status, created_at, updated_at) VALUES (?, ?, 'title', 'goods', 'white', 'XL', 1, ?, ?, ?, ?)`, id, source, price, status, migrationTime, migrationTime); err != nil {
|
||||
t.Fatalf("insert v1 task: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func insertV1SpecTrial(t *testing.T, database *sql.DB, id, taskID string) {
|
||||
t.Helper()
|
||||
if _, err := database.Exec(`INSERT INTO spec_trials (id, task_id, attempt, product_title, selected_color, selected_size, unit_price, total_price, evidence_sha256, created_at) VALUES (?, ?, 1, 'title', 'white', 'XL', '1.00', '1.00', 'hash', ?)`, id, taskID, migrationTime); err != nil {
|
||||
t.Fatalf("insert v1 spec trial: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func insertV1Authorization(t *testing.T, database *sql.DB, id, taskID, trialID string) {
|
||||
t.Helper()
|
||||
if _, err := database.Exec(`INSERT INTO order_authorizations (id, task_id, spec_trial_id, version, goods_id, sku_color, sku_size, quantity, authorized_unit_price, total_price_cap, status, created_by, created_at, expires_at) VALUES (?, ?, ?, 1, 'goods', 'white', 'XL', 1, '1.00', '1.00', 'PENDING_DELIVERY', 'admin', ?, ?)`, id, taskID, trialID, migrationTime, migrationTime); err != nil {
|
||||
t.Fatalf("insert v1 authorization: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func insertV2Task(t *testing.T, database *sql.DB, id, source, status string) {
|
||||
t.Helper()
|
||||
if _, err := database.Exec(`INSERT INTO tasks (id, source, title, goods_id, sku_color, sku_size, quantity, max_total_price, status, created_at, updated_at) VALUES (?, ?, 'title', 'goods', 'white', 'XL', 1, '1.00', ?, ?, ?)`, id, source, status, migrationTime, migrationTime); err != nil {
|
||||
t.Fatalf("insert v2 task: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func insertV2Authorization(t *testing.T, database *sql.DB, id, taskID string, version int, startKey string) {
|
||||
t.Helper()
|
||||
if _, err := database.Exec(`INSERT INTO order_authorizations (id, task_id, task_version, start_key, goods_id, sku_color, sku_size, quantity, total_price_cap, status, created_by, created_at, expires_at) VALUES (?, ?, ?, ?, 'goods', 'white', 'XL', 1, '1.00', 'ACTIVE', 'admin', ?, ?)`, id, taskID, version, startKey, migrationTime, migrationTime); err != nil {
|
||||
t.Fatalf("insert v2 authorization: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func insertV2Attempt(t *testing.T, database *sql.DB, id, taskID, authorizationID string, generation int) {
|
||||
t.Helper()
|
||||
if _, err := database.Exec(`INSERT INTO purchase_attempts (id, task_id, authorization_id, claim_generation, status, started_at) VALUES (?, ?, ?, ?, 'CLAIMED', ?)`, id, taskID, authorizationID, generation, migrationTime); err != nil {
|
||||
t.Fatalf("insert v2 attempt: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func insertV2Submission(t *testing.T, database *sql.DB, id, taskID, authorizationID, attemptID string) {
|
||||
t.Helper()
|
||||
if _, err := database.Exec(`INSERT INTO order_submissions (id, task_id, authorization_id, attempt_id, status, gate1_unit_price, gate2_unit_price, quantity_read, confirm_amount, created_at) VALUES (?, ?, ?, ?, 'FENCED', '1.00', '1.00', 1, '1.00', ?)`, id, taskID, authorizationID, attemptID, migrationTime); err != nil {
|
||||
t.Fatalf("insert v2 submission: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func v1RowCount(t *testing.T, database *sql.DB) int {
|
||||
t.Helper()
|
||||
var count int
|
||||
if err := database.QueryRow(`SELECT (SELECT COUNT(*) FROM tasks) + (SELECT COUNT(*) FROM spec_trials) + (SELECT COUNT(*) FROM order_authorizations) + (SELECT COUNT(*) FROM order_submissions)`).Scan(&count); err != nil {
|
||||
t.Fatalf("count v1 rows: %v", err)
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
func v2RowCount(t *testing.T, database *sql.DB) int {
|
||||
t.Helper()
|
||||
var count int
|
||||
if err := database.QueryRow(`SELECT (SELECT COUNT(*) FROM tasks) + (SELECT COUNT(*) FROM order_authorizations) + (SELECT COUNT(*) FROM purchase_attempts) + (SELECT COUNT(*) FROM order_submissions)`).Scan(&count); err != nil {
|
||||
t.Fatalf("count v2 rows: %v", err)
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
func openTestDatabase(t *testing.T) *sql.DB {
|
||||
t.Helper()
|
||||
database, err := sqlite.Open(filepath.Join(t.TempDir(), "migrations.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("open test database: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = database.Close() })
|
||||
return database
|
||||
}
|
||||
|
||||
func migrationDirectory(t *testing.T) string {
|
||||
t.Helper()
|
||||
_, file, _, ok := runtime.Caller(0)
|
||||
if !ok {
|
||||
t.Fatal("locate migration test source")
|
||||
}
|
||||
return filepath.Join(filepath.Dir(file), "..", "..", "migrations")
|
||||
}
|
||||
|
||||
func assertVersion(t *testing.T, database *sql.DB, want int64) {
|
||||
t.Helper()
|
||||
got, err := goose.GetDBVersion(database)
|
||||
if err != nil {
|
||||
t.Fatalf("read migration version: %v", err)
|
||||
}
|
||||
if got != want {
|
||||
t.Fatalf("migration version = %d, want %d", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func assertTableExists(t *testing.T, database *sql.DB, table string, want bool) {
|
||||
t.Helper()
|
||||
var count int
|
||||
if err := database.QueryRow(`SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = ?`, table).Scan(&count); err != nil {
|
||||
t.Fatalf("look up table %s: %v", table, err)
|
||||
}
|
||||
if got := count == 1; got != want {
|
||||
t.Fatalf("table %s exists = %t, want %t", table, got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func assertColumnType(t *testing.T, database *sql.DB, table, column, want string) {
|
||||
t.Helper()
|
||||
var got string
|
||||
if err := database.QueryRow(`SELECT type FROM pragma_table_info(?) WHERE name = ?`, table, column).Scan(&got); err != nil {
|
||||
t.Fatalf("read %s.%s type: %v", table, column, err)
|
||||
}
|
||||
if got != want {
|
||||
t.Fatalf("%s.%s type = %s, want %s", table, column, got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMigrationsDoNotDisableForeignKeys(t *testing.T) {
|
||||
for _, name := range []string{"00002_single_pass_model.sql", "00003_evidence_assets.sql", "00004_device_credentials.sql"} {
|
||||
contents, err := os.ReadFile(filepath.Join(migrationDirectory(t), name))
|
||||
if err != nil {
|
||||
t.Fatalf("read %s: %v", name, err)
|
||||
}
|
||||
if strings.Contains(strings.ToUpper(string(contents)), "PRAGMA FOREIGN_KEYS = OFF") {
|
||||
t.Fatalf("%s disables foreign keys", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io"
|
||||
"mime"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"cmbuyer/admin/internal/deviceauth"
|
||||
"cmbuyer/admin/internal/evidence"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
const (
|
||||
maxEvidenceRequestBytes = evidence.MaxFileBytes + 64<<10
|
||||
maxEvidenceFieldBytes = 4 << 10
|
||||
)
|
||||
|
||||
var evidenceFieldNames = map[string]struct{}{
|
||||
"upload_key": {}, "attempt_id": {}, "kind": {}, "privacy_tier": {}, "sha256": {}, "captured_at": {},
|
||||
}
|
||||
|
||||
func uploadEvidence(options Options) gin.HandlerFunc {
|
||||
return func(context *gin.Context) {
|
||||
// Authentication deliberately precedes content-type parsing and every body read. A rejected
|
||||
// device must not make the service spool or inspect a potentially sensitive upload.
|
||||
principal, err := options.DeviceAuthenticator.Authenticate(context.Request)
|
||||
if errors.Is(err, deviceauth.ErrUnauthenticated) {
|
||||
context.Header("WWW-Authenticate", "Bearer")
|
||||
context.Status(http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
context.Status(http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
if !deviceauth.ValidDeviceID(principal.ID) {
|
||||
// A custom authenticator is still an untrusted boundary. Do not defer principal
|
||||
// validation until Commit because multipart bytes would already have been read.
|
||||
context.Status(http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
|
||||
boundary, ok := multipartBoundary(context.GetHeader("Content-Type"))
|
||||
if !ok {
|
||||
context.Status(http.StatusUnsupportedMediaType)
|
||||
return
|
||||
}
|
||||
context.Request.Body = http.MaxBytesReader(context.Writer, context.Request.Body, maxEvidenceRequestBytes)
|
||||
reader := multipart.NewReader(context.Request.Body, boundary)
|
||||
fields := make(map[string]string, len(evidenceFieldNames))
|
||||
var staged evidence.StagedFile
|
||||
hasFile := false
|
||||
discard := func() {
|
||||
if hasFile {
|
||||
options.Evidence.Discard(staged)
|
||||
}
|
||||
}
|
||||
|
||||
for {
|
||||
part, err := reader.NextPart()
|
||||
if errors.Is(err, io.EOF) {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
discard()
|
||||
writeMultipartError(context, err)
|
||||
return
|
||||
}
|
||||
name := part.FormName()
|
||||
if name == "file" {
|
||||
if hasFile || part.FileName() == "" || !exactPNGContentType(part.Header.Get("Content-Type")) {
|
||||
_ = part.Close()
|
||||
discard()
|
||||
context.Status(http.StatusUnsupportedMediaType)
|
||||
return
|
||||
}
|
||||
staged, err = options.Evidence.Stage(part, evidence.PNGContentType)
|
||||
_ = part.Close()
|
||||
if err != nil {
|
||||
writeEvidenceStoreError(context, err)
|
||||
return
|
||||
}
|
||||
hasFile = true
|
||||
continue
|
||||
}
|
||||
if _, allowed := evidenceFieldNames[name]; !allowed || part.FileName() != "" {
|
||||
_ = part.Close()
|
||||
discard()
|
||||
context.Status(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if _, duplicate := fields[name]; duplicate {
|
||||
_ = part.Close()
|
||||
discard()
|
||||
context.Status(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
value, err := io.ReadAll(io.LimitReader(part, maxEvidenceFieldBytes+1))
|
||||
_ = part.Close()
|
||||
if err != nil || len(value) == 0 || len(value) > maxEvidenceFieldBytes || !utf8.Valid(value) {
|
||||
discard()
|
||||
context.Status(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
fields[name] = string(value)
|
||||
}
|
||||
if !hasFile || len(fields) != len(evidenceFieldNames) {
|
||||
discard()
|
||||
context.Status(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
captured, err := time.Parse(time.RFC3339Nano, fields["captured_at"])
|
||||
if err != nil || !strings.HasSuffix(fields["captured_at"], "Z") {
|
||||
discard()
|
||||
context.Status(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
asset, replayed, err := options.Evidence.Commit(context.Request.Context(), principal, evidence.UploadMetadata{
|
||||
UploadKey: fields["upload_key"], TaskID: context.Param("id"), AttemptID: fields["attempt_id"],
|
||||
Kind: fields["kind"], PrivacyTier: fields["privacy_tier"], SHA256: fields["sha256"], CapturedAt: captured.UTC(),
|
||||
}, staged)
|
||||
if err != nil {
|
||||
writeEvidenceStoreError(context, err)
|
||||
return
|
||||
}
|
||||
status := http.StatusCreated
|
||||
if replayed {
|
||||
status = http.StatusOK
|
||||
}
|
||||
context.JSON(status, asset)
|
||||
}
|
||||
}
|
||||
|
||||
func readEvidence(options Options) gin.HandlerFunc {
|
||||
return func(context *gin.Context) {
|
||||
if !options.Sessions.IsAuthenticated(context.Request) {
|
||||
context.Status(http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
asset, file, err := options.Evidence.Open(context.Request.Context(), context.Param("asset_id"))
|
||||
if errors.Is(err, evidence.ErrNotFound) {
|
||||
context.Status(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
context.Status(http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
context.Header("Content-Type", evidence.PNGContentType)
|
||||
context.Header("Content-Length", strconv.FormatInt(asset.ByteSize, 10))
|
||||
context.Header("Content-Disposition", `inline; filename="evidence.png"`)
|
||||
context.Header("Cache-Control", "no-store")
|
||||
context.Header("X-Content-Type-Options", "nosniff")
|
||||
context.Status(http.StatusOK)
|
||||
if _, err := io.Copy(context.Writer, file); err != nil {
|
||||
_ = context.Error(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func multipartBoundary(value string) (string, bool) {
|
||||
mediaType, parameters, err := mime.ParseMediaType(value)
|
||||
if err != nil || mediaType != "multipart/form-data" || len(parameters) != 1 || parameters["boundary"] == "" {
|
||||
return "", false
|
||||
}
|
||||
return parameters["boundary"], true
|
||||
}
|
||||
|
||||
func exactPNGContentType(value string) bool {
|
||||
mediaType, parameters, err := mime.ParseMediaType(value)
|
||||
return err == nil && mediaType == evidence.PNGContentType && len(parameters) == 0
|
||||
}
|
||||
|
||||
func writeMultipartError(context *gin.Context, err error) {
|
||||
var tooLarge *http.MaxBytesError
|
||||
if errors.As(err, &tooLarge) {
|
||||
context.Status(http.StatusRequestEntityTooLarge)
|
||||
return
|
||||
}
|
||||
context.Status(http.StatusBadRequest)
|
||||
}
|
||||
|
||||
func writeEvidenceStoreError(context *gin.Context, err error) {
|
||||
var tooLarge *http.MaxBytesError
|
||||
switch {
|
||||
case errors.As(err, &tooLarge):
|
||||
context.Status(http.StatusRequestEntityTooLarge)
|
||||
case errors.Is(err, evidence.ErrTooLarge):
|
||||
context.Status(http.StatusRequestEntityTooLarge)
|
||||
case errors.Is(err, evidence.ErrInvalid):
|
||||
context.Status(http.StatusBadRequest)
|
||||
case errors.Is(err, evidence.ErrConflict):
|
||||
context.Status(http.StatusConflict)
|
||||
default:
|
||||
context.Status(http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,451 @@
|
||||
package server_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"database/sql"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"image"
|
||||
"image/png"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/textproto"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"cmbuyer/admin/internal/deviceauth"
|
||||
"cmbuyer/admin/internal/evidence"
|
||||
"cmbuyer/admin/internal/migrations"
|
||||
evidencestorage "cmbuyer/admin/internal/storage/evidence"
|
||||
"cmbuyer/admin/internal/storage/sqlite"
|
||||
)
|
||||
|
||||
const (
|
||||
evidenceTaskID = "63c9f507-7473-4fa6-8d71-8786c34c6301"
|
||||
evidenceAuthID = "73c9f507-7473-4fa6-8d71-8786c34c6301"
|
||||
evidenceAttemptID = "83c9f507-7473-4fa6-8d71-8786c34c6301"
|
||||
evidenceUploadKey = "93c9f507-7473-4fa6-8d71-8786c34c6301"
|
||||
evidenceDeviceID = "13c9f507-7473-4fa6-8d71-8786c34c6301"
|
||||
)
|
||||
|
||||
func TestEvidenceUploadAuthenticatesBeforeReadingBody(t *testing.T) {
|
||||
authenticator := &fakeDeviceAuthenticator{}
|
||||
router, _ := newRouterWithDependencies(t, &memoryStore{}, emptyDetailStore{}, emptyEvidenceStore{}, authenticator)
|
||||
poison := &poisonBody{}
|
||||
request := httptest.NewRequest(http.MethodPost, "/api/v1/tasks/"+evidenceTaskID+"/evidence", nil)
|
||||
request.Body = poison
|
||||
request.Header.Set("Content-Type", "text/plain")
|
||||
response := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(response, request)
|
||||
|
||||
if response.Code != http.StatusUnauthorized || response.Body.Len() != 0 || response.Header().Get("WWW-Authenticate") != "Bearer" || poison.reads != 0 || authenticator.calls != 1 {
|
||||
t.Fatalf("status/reads/auth calls = %d/%d/%d, want 401/0/1", response.Code, poison.reads, authenticator.calls)
|
||||
}
|
||||
assertSecurityHeaders(t, response)
|
||||
}
|
||||
|
||||
func TestEvidenceUploadAuthenticationStorageFailureBeforeReadingBody(t *testing.T) {
|
||||
database, err := sqlite.Open(filepath.Join(t.TempDir(), "authentication-failure.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("open database: %v", err)
|
||||
}
|
||||
if err := migrations.Up(context.Background(), database, testMigrationDirectory(t)); err != nil {
|
||||
t.Fatalf("migrate database: %v", err)
|
||||
}
|
||||
authenticator, err := deviceauth.NewSQLiteAuthenticator(database)
|
||||
if err != nil {
|
||||
t.Fatalf("new authenticator: %v", err)
|
||||
}
|
||||
credentialStore, err := deviceauth.NewCredentialStore(database)
|
||||
if err != nil {
|
||||
t.Fatalf("new credential store: %v", err)
|
||||
}
|
||||
issued, err := credentialStore.Issue(context.Background(), "test device")
|
||||
if err != nil {
|
||||
t.Fatalf("issue credential: %v", err)
|
||||
}
|
||||
if err := database.Close(); err != nil {
|
||||
t.Fatalf("close database: %v", err)
|
||||
}
|
||||
router, _ := newRouterWithDependencies(t, &memoryStore{}, emptyDetailStore{}, emptyEvidenceStore{}, authenticator)
|
||||
poison := &poisonBody{}
|
||||
request := httptest.NewRequest(http.MethodPost, "/api/v1/tasks/"+evidenceTaskID+"/evidence", nil)
|
||||
request.Body = poison
|
||||
request.Header.Set(deviceauth.AuthorizationHeader, "Bearer "+issued.Token)
|
||||
request.Header.Set(deviceauth.DeviceIDHeader, issued.DeviceID)
|
||||
response := httptest.NewRecorder()
|
||||
router.ServeHTTP(response, request)
|
||||
if response.Code != http.StatusServiceUnavailable || response.Body.Len() != 0 || poison.reads != 0 {
|
||||
t.Fatalf("storage failure status/body/reads = %d/%q/%d, want 503/empty/0", response.Code, response.Body.String(), poison.reads)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvidenceUploadRejectsInvalidSuccessfulPrincipalBeforeReadingBody(t *testing.T) {
|
||||
router, _ := newRouterWithDependencies(t, &memoryStore{}, emptyDetailStore{}, emptyEvidenceStore{}, uncheckedDeviceAuthenticator{})
|
||||
poison := &poisonBody{}
|
||||
request := httptest.NewRequest(http.MethodPost, "/api/v1/tasks/"+evidenceTaskID+"/evidence", nil)
|
||||
request.Body = poison
|
||||
response := httptest.NewRecorder()
|
||||
router.ServeHTTP(response, request)
|
||||
if response.Code != http.StatusServiceUnavailable || response.Body.Len() != 0 || poison.reads != 0 {
|
||||
t.Fatalf("invalid principal status/body/reads = %d/%q/%d, want 503/empty/0", response.Code, response.Body.String(), poison.reads)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminSessionCannotActAsDeviceUploader(t *testing.T) {
|
||||
router, _ := newRouter(t)
|
||||
cookie := authenticate(t, router)
|
||||
request := httptest.NewRequest(http.MethodPost, "/api/v1/tasks/"+evidenceTaskID+"/evidence", nil)
|
||||
request.Body = &poisonBody{}
|
||||
request.AddCookie(cookie)
|
||||
response := httptest.NewRecorder()
|
||||
router.ServeHTTP(response, request)
|
||||
if response.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("admin upload status = %d, want 401", response.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRealDeviceCredentialIdentityIsolationAndMixedCredentials(t *testing.T) {
|
||||
database, err := sqlite.Open(filepath.Join(t.TempDir(), "identity-isolation.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("open database: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = database.Close() })
|
||||
if err := migrations.Up(context.Background(), database, testMigrationDirectory(t)); err != nil {
|
||||
t.Fatalf("migrate database: %v", err)
|
||||
}
|
||||
insertEvidenceAttempt(t, database)
|
||||
assetStore, err := evidencestorage.NewStore(database, filepath.Join(t.TempDir(), "assets"))
|
||||
if err != nil {
|
||||
t.Fatalf("new evidence store: %v", err)
|
||||
}
|
||||
credentialStore, err := deviceauth.NewCredentialStore(database)
|
||||
if err != nil {
|
||||
t.Fatalf("new credential store: %v", err)
|
||||
}
|
||||
issued, err := credentialStore.Issue(context.Background(), "采购工具一号")
|
||||
if err != nil {
|
||||
t.Fatalf("issue credential: %v", err)
|
||||
}
|
||||
authenticator, err := deviceauth.NewSQLiteAuthenticator(database)
|
||||
if err != nil {
|
||||
t.Fatalf("new authenticator: %v", err)
|
||||
}
|
||||
taskStore := &memoryStore{}
|
||||
router, _ := newRouterWithDependencies(t, taskStore, emptyDetailStore{}, assetStore, authenticator)
|
||||
addDeviceHeaders := func(request *http.Request) {
|
||||
request.Header.Set(deviceauth.AuthorizationHeader, "Bearer "+issued.Token)
|
||||
request.Header.Set(deviceauth.DeviceIDHeader, issued.DeviceID)
|
||||
}
|
||||
|
||||
start := newStartRequest(t, validStartBody(), "application/json", "", nil)
|
||||
addDeviceHeaders(start)
|
||||
startResponse := httptest.NewRecorder()
|
||||
router.ServeHTTP(startResponse, start)
|
||||
create := httptest.NewRequest(http.MethodPost, "/tasks", strings.NewReader("title=device"))
|
||||
create.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
addDeviceHeaders(create)
|
||||
createResponse := httptest.NewRecorder()
|
||||
router.ServeHTTP(createResponse, create)
|
||||
if startResponse.Code != http.StatusUnauthorized || createResponse.Code != http.StatusUnauthorized || taskStore.startCalls != 0 || len(taskStore.drafts) != 0 {
|
||||
t.Fatalf("device management isolation = start %d/create %d/calls %d/drafts %d", startResponse.Code, createResponse.Code, taskStore.startCalls, len(taskStore.drafts))
|
||||
}
|
||||
|
||||
adminCookie, csrf := authenticatedStartSession(t, router)
|
||||
mixedWithoutCSRF := newStartRequest(t, validStartBody(), "application/json", "", adminCookie)
|
||||
addDeviceHeaders(mixedWithoutCSRF)
|
||||
mixedWithoutCSRFResponse := httptest.NewRecorder()
|
||||
router.ServeHTTP(mixedWithoutCSRFResponse, mixedWithoutCSRF)
|
||||
if mixedWithoutCSRFResponse.Code != http.StatusForbidden || taskStore.startCalls != 0 {
|
||||
t.Fatalf("mixed request bypassed admin CSRF: status/calls=%d/%d", mixedWithoutCSRFResponse.Code, taskStore.startCalls)
|
||||
}
|
||||
mixedAdmin := newStartRequest(t, validStartBody(), "application/json", csrf, adminCookie)
|
||||
addDeviceHeaders(mixedAdmin)
|
||||
mixedAdminResponse := httptest.NewRecorder()
|
||||
router.ServeHTTP(mixedAdminResponse, mixedAdmin)
|
||||
if mixedAdminResponse.Code != http.StatusBadRequest || taskStore.startCalls != 1 {
|
||||
t.Fatalf("mixed admin request changed identity domain: status/calls=%d/%d", mixedAdminResponse.Code, taskStore.startCalls)
|
||||
}
|
||||
|
||||
pngBytes := serverTestPNG(t, 3, 2)
|
||||
upload := newEvidenceUploadRequest(t, evidenceTaskID, validEvidenceFields(pngBytes), pngBytes, evidence.PNGContentType, "raw.png", nil)
|
||||
addDeviceHeaders(upload)
|
||||
upload.AddCookie(adminCookie)
|
||||
uploadResponse := httptest.NewRecorder()
|
||||
router.ServeHTTP(uploadResponse, upload)
|
||||
if uploadResponse.Code != http.StatusCreated {
|
||||
t.Fatalf("mixed upload status/body = %d/%q", uploadResponse.Code, uploadResponse.Body.String())
|
||||
}
|
||||
var uploadedBy string
|
||||
if err := database.QueryRow(`SELECT uploaded_by_device_id FROM evidence_assets`).Scan(&uploadedBy); err != nil || uploadedBy != issued.DeviceID {
|
||||
t.Fatalf("uploaded principal = %q, err=%v", uploadedBy, err)
|
||||
}
|
||||
|
||||
if _, _, err := credentialStore.Revoke(context.Background(), issued.DeviceID); err != nil {
|
||||
t.Fatalf("revoke credential: %v", err)
|
||||
}
|
||||
revokedUpload := newEvidenceUploadRequest(t, evidenceTaskID, validEvidenceFields(pngBytes), pngBytes, evidence.PNGContentType, "raw.png", nil)
|
||||
addDeviceHeaders(revokedUpload)
|
||||
revokedResponse := httptest.NewRecorder()
|
||||
router.ServeHTTP(revokedResponse, revokedUpload)
|
||||
if revokedResponse.Code != http.StatusUnauthorized || revokedResponse.Body.Len() != 0 {
|
||||
t.Fatalf("revoked upload = %d/%q", revokedResponse.Code, revokedResponse.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvidenceUploadReplayConflictAndProtectedRead(t *testing.T) {
|
||||
router, database := newEvidenceRouter(t, &fakeDeviceAuthenticator{principal: deviceauth.Principal{ID: evidenceDeviceID}})
|
||||
pngBytes := serverTestPNG(t, 6, 4)
|
||||
fields := validEvidenceFields(pngBytes)
|
||||
|
||||
first := serveEvidenceUpload(t, router, evidenceTaskID, fields, pngBytes, evidence.PNGContentType, `..\private\original.png`, nil)
|
||||
if first.Code != http.StatusCreated {
|
||||
t.Fatalf("first upload status/body = %d/%q", first.Code, first.Body.String())
|
||||
}
|
||||
var asset evidence.Asset
|
||||
if err := json.Unmarshal(first.Body.Bytes(), &asset); err != nil {
|
||||
t.Fatalf("decode upload response: %v", err)
|
||||
}
|
||||
if asset.TaskID != evidenceTaskID || asset.AttemptID != evidenceAttemptID || asset.SHA256 != fields["sha256"] || strings.Contains(first.Body.String(), "private") || strings.Contains(first.Body.String(), "original.png") {
|
||||
t.Fatalf("unsafe upload response = %s", first.Body.String())
|
||||
}
|
||||
|
||||
replay := serveEvidenceUpload(t, router, evidenceTaskID, fields, pngBytes, evidence.PNGContentType, "again.png", nil)
|
||||
if replay.Code != http.StatusOK {
|
||||
t.Fatalf("replay status = %d, want 200", replay.Code)
|
||||
}
|
||||
var replayed evidence.Asset
|
||||
if err := json.Unmarshal(replay.Body.Bytes(), &replayed); err != nil || replayed.ID != asset.ID {
|
||||
t.Fatalf("replay asset = %#v, err %v", replayed, err)
|
||||
}
|
||||
|
||||
conflicting := copyStringMap(fields)
|
||||
conflicting["captured_at"] = "2026-08-04T09:01:01Z"
|
||||
if response := serveEvidenceUpload(t, router, evidenceTaskID, conflicting, pngBytes, evidence.PNGContentType, "same.png", nil); response.Code != http.StatusConflict {
|
||||
t.Fatalf("conflicting replay status = %d, want 409", response.Code)
|
||||
}
|
||||
var count int
|
||||
if err := database.QueryRow("SELECT COUNT(*) FROM evidence_assets").Scan(&count); err != nil || count != 1 {
|
||||
t.Fatalf("asset count = %d, err %v", count, err)
|
||||
}
|
||||
|
||||
if response := serve(router, http.MethodGet, "/evidence/"+asset.ID, nil, nil); response.Code != http.StatusUnauthorized || response.Body.Len() != 0 {
|
||||
t.Fatalf("anonymous read = %d/%q", response.Code, response.Body.String())
|
||||
}
|
||||
adminCookie := authenticate(t, router)
|
||||
read := serve(router, http.MethodGet, "/evidence/"+asset.ID, nil, adminCookie)
|
||||
if read.Code != http.StatusOK || !bytes.Equal(read.Body.Bytes(), pngBytes) {
|
||||
t.Fatalf("admin read = %d, bytes equal %t", read.Code, bytes.Equal(read.Body.Bytes(), pngBytes))
|
||||
}
|
||||
for header, want := range map[string]string{"Content-Type": "image/png", "Cache-Control": "no-store", "X-Content-Type-Options": "nosniff", "Content-Disposition": `inline; filename="evidence.png"`} {
|
||||
if got := read.Header().Get(header); got != want {
|
||||
t.Fatalf("%s = %q, want %q", header, got, want)
|
||||
}
|
||||
}
|
||||
missing := serve(router, http.MethodGet, "/evidence/not-a-uuid", nil, adminCookie)
|
||||
if missing.Code != http.StatusNotFound || missing.Body.Len() != 0 {
|
||||
t.Fatalf("missing evidence = %d/%q", missing.Code, missing.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvidenceUploadRejectsStrictMultipartViolations(t *testing.T) {
|
||||
router, database := newEvidenceRouter(t, &fakeDeviceAuthenticator{principal: deviceauth.Principal{ID: evidenceDeviceID}})
|
||||
pngBytes := serverTestPNG(t, 2, 2)
|
||||
base := validEvidenceFields(pngBytes)
|
||||
wrongHash := copyStringMap(base)
|
||||
wrongHash["sha256"] = strings.Repeat("b", 64)
|
||||
uppercaseHash := copyStringMap(base)
|
||||
uppercaseHash["sha256"] = strings.ToUpper(uppercaseHash["sha256"])
|
||||
wrongPrivacy := copyStringMap(base)
|
||||
wrongPrivacy["privacy_tier"] = "PUBLIC"
|
||||
wrongKind := copyStringMap(base)
|
||||
wrongKind["kind"] = "ORDER_CONFIRM"
|
||||
tests := []struct {
|
||||
name string
|
||||
fields map[string]string
|
||||
file []byte
|
||||
contentType string
|
||||
extra func(*multipart.Writer) error
|
||||
want int
|
||||
}{
|
||||
{name: "attempt belongs to another task", fields: base, file: pngBytes, contentType: evidence.PNGContentType, want: http.StatusBadRequest},
|
||||
{name: "xml file", fields: base, file: []byte("<hierarchy/>"), contentType: evidence.PNGContentType, want: http.StatusBadRequest},
|
||||
{name: "wrong hash", fields: wrongHash, file: pngBytes, contentType: evidence.PNGContentType, want: http.StatusBadRequest},
|
||||
{name: "uppercase hash", fields: uppercaseHash, file: pngBytes, contentType: evidence.PNGContentType, want: http.StatusBadRequest},
|
||||
{name: "wrong privacy", fields: wrongPrivacy, file: pngBytes, contentType: evidence.PNGContentType, want: http.StatusBadRequest},
|
||||
{name: "unapproved kind", fields: wrongKind, file: pngBytes, contentType: evidence.PNGContentType, want: http.StatusBadRequest},
|
||||
{name: "too large", fields: base, file: make([]byte, evidence.MaxFileBytes+1), contentType: evidence.PNGContentType, want: http.StatusRequestEntityTooLarge},
|
||||
{name: "wrong file content type", fields: base, file: pngBytes, contentType: "application/xml", want: http.StatusUnsupportedMediaType},
|
||||
{name: "unknown path field", fields: base, file: pngBytes, contentType: evidence.PNGContentType, extra: func(writer *multipart.Writer) error { return writer.WriteField("path", `C:\secret.xml`) }, want: http.StatusBadRequest},
|
||||
{name: "duplicate metadata", fields: base, file: pngBytes, contentType: evidence.PNGContentType, extra: func(writer *multipart.Writer) error { return writer.WriteField("sha256", base["sha256"]) }, want: http.StatusBadRequest},
|
||||
{name: "second file", fields: base, file: pngBytes, contentType: evidence.PNGContentType, extra: func(writer *multipart.Writer) error {
|
||||
part, err := writer.CreateFormFile("file", "second.png")
|
||||
if err == nil {
|
||||
_, err = part.Write(pngBytes)
|
||||
}
|
||||
return err
|
||||
}, want: http.StatusUnsupportedMediaType},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
taskID := evidenceTaskID
|
||||
if test.name == "attempt belongs to another task" {
|
||||
taskID = "a3c9f507-7473-4fa6-8d71-8786c34c6301"
|
||||
}
|
||||
response := serveEvidenceUpload(t, router, taskID, copyStringMap(test.fields), test.file, test.contentType, "file.png", test.extra)
|
||||
if response.Code != test.want || response.Body.Len() != 0 {
|
||||
t.Fatalf("status/body = %d/%q, want %d/empty", response.Code, response.Body.String(), test.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
var count int
|
||||
if err := database.QueryRow("SELECT COUNT(*) FROM evidence_assets").Scan(&count); err != nil || count != 0 {
|
||||
t.Fatalf("invalid requests created %d assets, err %v", count, err)
|
||||
}
|
||||
}
|
||||
|
||||
type fakeDeviceAuthenticator struct {
|
||||
principal deviceauth.Principal
|
||||
err error
|
||||
calls int
|
||||
}
|
||||
|
||||
func (authenticator *fakeDeviceAuthenticator) Authenticate(*http.Request) (deviceauth.Principal, error) {
|
||||
authenticator.calls++
|
||||
if authenticator.err != nil {
|
||||
return deviceauth.Principal{}, authenticator.err
|
||||
}
|
||||
if authenticator.principal.ID == "" {
|
||||
return deviceauth.Principal{}, deviceauth.ErrUnauthenticated
|
||||
}
|
||||
return authenticator.principal, nil
|
||||
}
|
||||
|
||||
type poisonBody struct{ reads int }
|
||||
|
||||
type uncheckedDeviceAuthenticator struct{}
|
||||
|
||||
func (uncheckedDeviceAuthenticator) Authenticate(*http.Request) (deviceauth.Principal, error) {
|
||||
return deviceauth.Principal{}, nil
|
||||
}
|
||||
|
||||
func (body *poisonBody) Read([]byte) (int, error) {
|
||||
body.reads++
|
||||
return 0, io.ErrUnexpectedEOF
|
||||
}
|
||||
func (*poisonBody) Close() error { return nil }
|
||||
|
||||
func newEvidenceRouter(t *testing.T, authenticator deviceauth.Authenticator) (http.Handler, *sql.DB) {
|
||||
t.Helper()
|
||||
database, err := sqlite.Open(filepath.Join(t.TempDir(), "server-evidence.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("open database: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = database.Close() })
|
||||
if err := migrations.Up(context.Background(), database, testMigrationDirectory(t)); err != nil {
|
||||
t.Fatalf("migrate database: %v", err)
|
||||
}
|
||||
insertEvidenceAttempt(t, database)
|
||||
store, err := evidencestorage.NewStore(database, filepath.Join(t.TempDir(), "assets"))
|
||||
if err != nil {
|
||||
t.Fatalf("new evidence store: %v", err)
|
||||
}
|
||||
router, _ := newRouterWithDependencies(t, &memoryStore{}, emptyDetailStore{}, store, authenticator)
|
||||
return router, database
|
||||
}
|
||||
|
||||
func testMigrationDirectory(t *testing.T) string {
|
||||
t.Helper()
|
||||
_, file, _, ok := runtime.Caller(0)
|
||||
if !ok {
|
||||
t.Fatal("locate migration directory")
|
||||
}
|
||||
return filepath.Join(filepath.Dir(file), "..", "..", "migrations")
|
||||
}
|
||||
|
||||
func insertEvidenceAttempt(t *testing.T, database *sql.DB) {
|
||||
t.Helper()
|
||||
timestamp := "2026-08-04T00:00:00Z"
|
||||
if _, err := database.Exec(`INSERT INTO tasks (id, source, title, goods_id, sku_color, sku_size, quantity, max_total_price, status, version, created_at, updated_at) VALUES (?, 'MANUAL', 'task', '123', 'black', 'M', 1, '1.00', 'DRAFT', 1, ?, ?)`, evidenceTaskID, timestamp, timestamp); err != nil {
|
||||
t.Fatalf("insert task: %v", err)
|
||||
}
|
||||
if _, err := database.Exec(`INSERT INTO order_authorizations (id, task_id, task_version, start_key, goods_id, sku_color, sku_size, quantity, total_price_cap, status, created_by, created_at, expires_at) VALUES (?, ?, 1, 'start', '123', 'black', 'M', 1, '1.00', 'ACTIVE', 'admin', ?, ?)`, evidenceAuthID, evidenceTaskID, timestamp, timestamp); err != nil {
|
||||
t.Fatalf("insert authorization: %v", err)
|
||||
}
|
||||
if _, err := database.Exec(`INSERT INTO purchase_attempts (id, task_id, authorization_id, claim_generation, status, started_at) VALUES (?, ?, ?, 1, 'CLAIMED', ?)`, evidenceAttemptID, evidenceTaskID, evidenceAuthID, timestamp); err != nil {
|
||||
t.Fatalf("insert attempt: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func serveEvidenceUpload(t *testing.T, router http.Handler, taskID string, fields map[string]string, file []byte, fileContentType, filename string, extra func(*multipart.Writer) error) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
request := newEvidenceUploadRequest(t, taskID, fields, file, fileContentType, filename, extra)
|
||||
response := httptest.NewRecorder()
|
||||
router.ServeHTTP(response, request)
|
||||
return response
|
||||
}
|
||||
|
||||
func newEvidenceUploadRequest(t *testing.T, taskID string, fields map[string]string, file []byte, fileContentType, filename string, extra func(*multipart.Writer) error) *http.Request {
|
||||
t.Helper()
|
||||
var body bytes.Buffer
|
||||
writer := multipart.NewWriter(&body)
|
||||
for _, name := range []string{"upload_key", "attempt_id", "kind", "privacy_tier", "sha256", "captured_at"} {
|
||||
if err := writer.WriteField(name, fields[name]); err != nil {
|
||||
t.Fatalf("write field %s: %v", name, err)
|
||||
}
|
||||
}
|
||||
header := make(textproto.MIMEHeader)
|
||||
header.Set("Content-Disposition", `form-data; name="file"; filename="`+filename+`"`)
|
||||
header.Set("Content-Type", fileContentType)
|
||||
part, err := writer.CreatePart(header)
|
||||
if err != nil {
|
||||
t.Fatalf("create file part: %v", err)
|
||||
}
|
||||
if _, err := part.Write(file); err != nil {
|
||||
t.Fatalf("write file: %v", err)
|
||||
}
|
||||
if extra != nil {
|
||||
if err := extra(writer); err != nil {
|
||||
t.Fatalf("write extra part: %v", err)
|
||||
}
|
||||
}
|
||||
if err := writer.Close(); err != nil {
|
||||
t.Fatalf("close multipart: %v", err)
|
||||
}
|
||||
request := httptest.NewRequest(http.MethodPost, "/api/v1/tasks/"+taskID+"/evidence", bytes.NewReader(body.Bytes()))
|
||||
request.Header.Set("Content-Type", writer.FormDataContentType())
|
||||
return request
|
||||
}
|
||||
|
||||
func validEvidenceFields(pngBytes []byte) map[string]string {
|
||||
hash := sha256.Sum256(pngBytes)
|
||||
return map[string]string{
|
||||
"upload_key": evidenceUploadKey, "attempt_id": evidenceAttemptID,
|
||||
"kind": evidence.KindSKUPanelGate1, "privacy_tier": evidence.PrivacyInternalRaw,
|
||||
"sha256": hex.EncodeToString(hash[:]), "captured_at": "2026-08-04T09:01:00Z",
|
||||
}
|
||||
}
|
||||
|
||||
func serverTestPNG(t *testing.T, width, height int) []byte {
|
||||
t.Helper()
|
||||
var buffer bytes.Buffer
|
||||
if err := png.Encode(&buffer, image.NewNRGBA(image.Rect(0, 0, width, height))); err != nil {
|
||||
t.Fatalf("encode PNG: %v", err)
|
||||
}
|
||||
return buffer.Bytes()
|
||||
}
|
||||
|
||||
func copyStringMap(values map[string]string) map[string]string {
|
||||
copy := make(map[string]string, len(values))
|
||||
for key, value := range values {
|
||||
copy[key] = value
|
||||
}
|
||||
return copy
|
||||
}
|
||||
@@ -2,18 +2,435 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/subtle"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"mime"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
"cmbuyer/admin/internal/auth"
|
||||
"cmbuyer/admin/internal/deviceauth"
|
||||
"cmbuyer/admin/internal/evidence"
|
||||
"cmbuyer/admin/internal/taskdetail"
|
||||
"cmbuyer/admin/internal/tasks"
|
||||
"cmbuyer/admin/internal/transport/webui"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
// NewRouter 返回当前服务范围内的完整 HTTP 路由。
|
||||
func NewRouter() *gin.Engine {
|
||||
router := gin.New()
|
||||
const maxFormBytes = 8 << 10
|
||||
const maxJSONBytes = 64 << 10
|
||||
|
||||
router.GET("/healthz", func(context *gin.Context) {
|
||||
context.JSON(http.StatusOK, gin.H{"status": "ok"})
|
||||
// Options 是路由层需要的安全依赖。凭据由启动配置注入,不能在路由中设置默认值。
|
||||
type Options struct {
|
||||
AdminUsername string
|
||||
AdminPasswordBcrypt string
|
||||
Sessions *auth.Manager
|
||||
Tasks tasks.Store
|
||||
TaskDetails taskdetail.Store
|
||||
Evidence evidence.Store
|
||||
DeviceAuthenticator deviceauth.Authenticator
|
||||
}
|
||||
|
||||
// NewRouter 返回当前服务范围内的完整 HTTP 路由。
|
||||
func NewRouter(options Options) (*gin.Engine, error) {
|
||||
if options.AdminUsername == "" || options.AdminPasswordBcrypt == "" || options.Sessions == nil || options.Tasks == nil || options.TaskDetails == nil || options.Evidence == nil || options.DeviceAuthenticator == nil {
|
||||
return nil, errors.New("server authentication options are incomplete")
|
||||
}
|
||||
|
||||
router := gin.New()
|
||||
router.Use(gin.Recovery())
|
||||
router.Use(securityHeaders())
|
||||
router.GET("/healthz", healthz)
|
||||
router.GET("/login", loginPage(options))
|
||||
router.POST("/login", login(options))
|
||||
router.POST("/logout", logout(options))
|
||||
router.GET("/tasks", tasksPage(options))
|
||||
router.GET("/tasks/:id", taskDetailPage(options))
|
||||
router.GET("/tasks/new", newTaskPage(options))
|
||||
router.POST("/tasks", createTask(options))
|
||||
router.POST("/tasks/start-purchases", startPurchases(options))
|
||||
router.POST("/api/v1/tasks/:id/evidence", uploadEvidence(options))
|
||||
router.GET("/evidence/:asset_id", readEvidence(options))
|
||||
router.GET("/static/tasks.js", func(context *gin.Context) {
|
||||
context.Data(http.StatusOK, "application/javascript; charset=utf-8", webui.TasksScript())
|
||||
})
|
||||
|
||||
return router
|
||||
return router, nil
|
||||
}
|
||||
|
||||
func startPurchases(options Options) gin.HandlerFunc {
|
||||
return func(context *gin.Context) {
|
||||
if !options.Sessions.IsAuthenticated(context.Request) {
|
||||
context.Status(http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
authenticated, csrfOK := options.Sessions.VerifyCSRF(context.Request, context.GetHeader("X-CSRF-Token"))
|
||||
if !authenticated || !csrfOK {
|
||||
context.Status(http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
if !isJSONContentType(context.GetHeader("Content-Type")) {
|
||||
context.Status(http.StatusUnsupportedMediaType)
|
||||
return
|
||||
}
|
||||
context.Request.Body = http.MaxBytesReader(context.Writer, context.Request.Body, maxJSONBytes)
|
||||
raw, err := io.ReadAll(context.Request.Body)
|
||||
if err != nil {
|
||||
var tooLarge *http.MaxBytesError
|
||||
if errors.As(err, &tooLarge) {
|
||||
context.Status(http.StatusRequestEntityTooLarge)
|
||||
} else {
|
||||
context.Status(http.StatusBadRequest)
|
||||
}
|
||||
return
|
||||
}
|
||||
if !utf8.Valid(raw) {
|
||||
context.Status(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
decoder := json.NewDecoder(bytes.NewReader(raw))
|
||||
decoder.DisallowUnknownFields()
|
||||
var command tasks.StartCommand
|
||||
if err := decoder.Decode(&command); err != nil {
|
||||
context.Status(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
var extra any
|
||||
if err := decoder.Decode(&extra); err != io.EOF {
|
||||
context.Status(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
result, err := options.Tasks.StartPurchases(context.Request.Context(), command, options.AdminUsername)
|
||||
if err != nil {
|
||||
if errors.Is(err, tasks.ErrInvalidStart) {
|
||||
context.Status(http.StatusBadRequest)
|
||||
} else if errors.Is(err, tasks.ErrStartConflict) {
|
||||
context.Status(http.StatusConflict)
|
||||
} else {
|
||||
context.Status(http.StatusInternalServerError)
|
||||
}
|
||||
return
|
||||
}
|
||||
context.JSON(http.StatusOK, result)
|
||||
}
|
||||
}
|
||||
|
||||
func isJSONContentType(value string) bool {
|
||||
mediaType, parameters, err := mime.ParseMediaType(value)
|
||||
if err != nil || mediaType != "application/json" {
|
||||
return false
|
||||
}
|
||||
for name, value := range parameters {
|
||||
if name != "charset" || !strings.EqualFold(value, "utf-8") {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func healthz(context *gin.Context) {
|
||||
context.JSON(http.StatusOK, gin.H{"status": "ok"})
|
||||
}
|
||||
|
||||
func securityHeaders() gin.HandlerFunc {
|
||||
return func(context *gin.Context) {
|
||||
context.Header("Cache-Control", "no-store")
|
||||
context.Header("X-Content-Type-Options", "nosniff")
|
||||
context.Header("Referrer-Policy", "no-referrer")
|
||||
context.Header("Content-Security-Policy", "default-src 'self'; style-src 'self' 'unsafe-inline'; script-src 'self'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'; form-action 'self'")
|
||||
context.Next()
|
||||
}
|
||||
}
|
||||
|
||||
func loginPage(options Options) gin.HandlerFunc {
|
||||
return func(context *gin.Context) {
|
||||
csrfToken, authenticated := options.Sessions.Ensure(context.Writer, context.Request)
|
||||
if authenticated {
|
||||
context.Redirect(http.StatusSeeOther, "/tasks")
|
||||
return
|
||||
}
|
||||
|
||||
renderLogin(context, http.StatusOK, csrfToken, returnTo(context.Query("return_to")), "", "")
|
||||
}
|
||||
}
|
||||
|
||||
func login(options Options) gin.HandlerFunc {
|
||||
return func(context *gin.Context) {
|
||||
if !parseForm(context) {
|
||||
return
|
||||
}
|
||||
form := context.Request.PostForm
|
||||
csrfToken := form.Get("csrf_token")
|
||||
returnPath := returnTo(form.Get("return_to"))
|
||||
username := form.Get("username")
|
||||
password := form.Get("password")
|
||||
|
||||
if _, ok := options.Sessions.VerifyCSRF(context.Request, csrfToken); !ok {
|
||||
newCSRF, _ := options.Sessions.Ensure(context.Writer, context.Request)
|
||||
renderLogin(context, http.StatusForbidden, newCSRF, returnPath, "", "请求已过期,请重新登录。")
|
||||
return
|
||||
}
|
||||
|
||||
usernameMatches := subtle.ConstantTimeCompare([]byte(options.AdminUsername), []byte(username)) == 1
|
||||
passwordMatches := bcrypt.CompareHashAndPassword([]byte(options.AdminPasswordBcrypt), []byte(password)) == nil
|
||||
if !usernameMatches || !passwordMatches {
|
||||
csrf, _ := options.Sessions.Ensure(context.Writer, context.Request)
|
||||
renderLogin(context, http.StatusUnauthorized, csrf, returnPath, "", "账号或密码不正确,请检查后重试。")
|
||||
return
|
||||
}
|
||||
|
||||
options.Sessions.RotateAuthenticated(context.Writer, context.Request)
|
||||
context.Redirect(http.StatusSeeOther, returnPath)
|
||||
}
|
||||
}
|
||||
|
||||
func logout(options Options) gin.HandlerFunc {
|
||||
return func(context *gin.Context) {
|
||||
if !parseForm(context) {
|
||||
return
|
||||
}
|
||||
authenticated, ok := options.Sessions.VerifyCSRF(context.Request, context.Request.PostForm.Get("csrf_token"))
|
||||
if !ok || !authenticated {
|
||||
context.Status(http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
options.Sessions.Logout(context.Writer, context.Request)
|
||||
context.Redirect(http.StatusSeeOther, "/login")
|
||||
}
|
||||
}
|
||||
|
||||
func tasksPage(options Options) gin.HandlerFunc {
|
||||
return func(context *gin.Context) {
|
||||
csrfToken, authenticated := options.Sessions.Ensure(context.Writer, context.Request)
|
||||
if !authenticated {
|
||||
context.Redirect(http.StatusSeeOther, "/login?return_to="+url.QueryEscape(context.Request.URL.RequestURI()))
|
||||
return
|
||||
}
|
||||
|
||||
filter := tasks.TaskFilter{Keyword: context.Query("keyword"), Status: context.Query("status"), CreatedFrom: context.Query("created_from"), CreatedTo: context.Query("created_to")}
|
||||
if validation := tasks.ValidateTaskFilter(filter); !validation.Valid() {
|
||||
startKey, err := tasks.NewCreateKey()
|
||||
if err != nil {
|
||||
context.Status(http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
renderTasks(context, http.StatusBadRequest, webui.TasksData{CSRFToken: csrfToken, Filter: filter, FilterErrors: validation, HasFilter: true, StartKey: startKey})
|
||||
return
|
||||
}
|
||||
data, err := taskListData(context, options, csrfToken, filter)
|
||||
if err != nil {
|
||||
context.Status(http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
for _, row := range data.Tasks {
|
||||
if row.ID == context.Query("created") {
|
||||
data.Success = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if context.Query("create") == "1" {
|
||||
form, err := newTaskForm()
|
||||
if err != nil {
|
||||
context.Status(http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
data.OpenForm = true
|
||||
data.Form = form
|
||||
data.FocusField = "title"
|
||||
}
|
||||
renderTasks(context, http.StatusOK, data)
|
||||
}
|
||||
}
|
||||
|
||||
func newTaskPage(options Options) gin.HandlerFunc {
|
||||
return func(context *gin.Context) {
|
||||
csrf, authenticated := options.Sessions.Ensure(context.Writer, context.Request)
|
||||
if !authenticated {
|
||||
context.Redirect(http.StatusSeeOther, "/login?return_to=%2Ftasks%2Fnew")
|
||||
return
|
||||
}
|
||||
form, err := newTaskForm()
|
||||
if err != nil {
|
||||
context.Status(http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
renderTasks(context, http.StatusOK, webui.TasksData{CSRFToken: csrf, Form: form, FullPage: true, FocusField: "title"})
|
||||
}
|
||||
}
|
||||
func createTask(options Options) gin.HandlerFunc {
|
||||
return func(context *gin.Context) {
|
||||
if !options.Sessions.IsAuthenticated(context.Request) {
|
||||
context.Status(http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
if !parseForm(context) {
|
||||
return
|
||||
}
|
||||
requestForm := context.Request.PostForm
|
||||
authenticated, csrfOK := options.Sessions.VerifyCSRF(context.Request, requestForm.Get("csrf_token"))
|
||||
if !csrfOK || !authenticated {
|
||||
context.Status(http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
form := taskForm(requestForm)
|
||||
draft, validation := tasks.Validate(form)
|
||||
if draft.GoodsID != "" {
|
||||
form.ProductURL = tasks.CanonicalURL(draft.GoodsID)
|
||||
}
|
||||
fullPage := requestForm.Get("form_mode") == "full"
|
||||
if !validation.Valid() {
|
||||
data, ok := createErrorData(context, options, fullPage)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
data.Form = form
|
||||
data.Errors = validation
|
||||
data.OpenForm = !fullPage
|
||||
data.FullPage = fullPage
|
||||
data.FocusField = firstError(validation)
|
||||
renderTasks(context, http.StatusBadRequest, data)
|
||||
return
|
||||
}
|
||||
created, err := options.Tasks.CreateDraft(context.Request.Context(), draft)
|
||||
if err != nil {
|
||||
if errors.Is(err, tasks.ErrCreateKeyConflict) {
|
||||
validation["create_key"] = "该创建请求已用于另一条任务,请重新打开表单。"
|
||||
data, ok := createErrorData(context, options, fullPage)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
data.Form = form
|
||||
data.Errors = validation
|
||||
data.OpenForm = !fullPage
|
||||
data.FullPage = fullPage
|
||||
data.FocusField = firstError(validation)
|
||||
renderTasks(context, http.StatusConflict, data)
|
||||
return
|
||||
}
|
||||
context.Status(http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
context.Redirect(http.StatusSeeOther, "/tasks?created="+url.QueryEscape(created.ID))
|
||||
}
|
||||
}
|
||||
|
||||
func newTaskForm() (tasks.Form, error) {
|
||||
key, err := tasks.NewCreateKey()
|
||||
if err != nil {
|
||||
return tasks.Form{}, err
|
||||
}
|
||||
return tasks.Form{CreateKey: key}, nil
|
||||
}
|
||||
func taskForm(form url.Values) tasks.Form {
|
||||
return tasks.Form{CreateKey: form.Get("create_key"), Title: form.Get("title"), ProductURL: form.Get("product_url"), SKUColor: form.Get("sku_color"), SKUSize: form.Get("sku_size"), Quantity: form.Get("quantity"), MaxTotalPrice: form.Get("max_total_price")}
|
||||
}
|
||||
func csrfFor(context *gin.Context, options Options) string {
|
||||
csrf, _ := options.Sessions.Ensure(context.Writer, context.Request)
|
||||
return csrf
|
||||
}
|
||||
|
||||
func taskListData(context *gin.Context, options Options, csrfToken string, filter tasks.TaskFilter) (webui.TasksData, error) {
|
||||
rows, err := options.Tasks.ListTasks(context.Request.Context(), filter)
|
||||
if err != nil {
|
||||
return webui.TasksData{}, err
|
||||
}
|
||||
startKey, err := tasks.NewCreateKey()
|
||||
if err != nil {
|
||||
return webui.TasksData{}, err
|
||||
}
|
||||
return webui.TasksData{
|
||||
CSRFToken: csrfToken,
|
||||
Tasks: rows,
|
||||
Filter: filter,
|
||||
HasFilter: filter.Keyword != "" || filter.Status != "" || filter.CreatedFrom != "" || filter.CreatedTo != "",
|
||||
StartKey: startKey,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func createErrorData(context *gin.Context, options Options, fullPage bool) (webui.TasksData, bool) {
|
||||
csrfToken := csrfFor(context, options)
|
||||
if fullPage {
|
||||
return webui.TasksData{CSRFToken: csrfToken}, true
|
||||
}
|
||||
data, err := taskListData(context, options, csrfToken, tasks.TaskFilter{})
|
||||
if err != nil {
|
||||
context.Status(http.StatusInternalServerError)
|
||||
return webui.TasksData{}, false
|
||||
}
|
||||
return data, true
|
||||
}
|
||||
|
||||
func renderTasks(context *gin.Context, status int, data webui.TasksData) {
|
||||
context.Header("Content-Type", "text/html; charset=utf-8")
|
||||
context.Status(status)
|
||||
if err := webui.RenderTasks(context.Writer, data); err != nil {
|
||||
_ = context.Error(err)
|
||||
}
|
||||
}
|
||||
|
||||
func renderLogin(context *gin.Context, status int, csrfToken, returnPath, username, message string) {
|
||||
context.Header("Content-Type", "text/html; charset=utf-8")
|
||||
context.Status(status)
|
||||
if err := webui.RenderLogin(context.Writer, webui.LoginData{
|
||||
CSRFToken: csrfToken,
|
||||
ReturnTo: returnPath,
|
||||
Username: username,
|
||||
Error: message,
|
||||
}); err != nil {
|
||||
_ = context.Error(err)
|
||||
}
|
||||
}
|
||||
|
||||
func parseForm(context *gin.Context) bool {
|
||||
context.Request.Body = http.MaxBytesReader(context.Writer, context.Request.Body, maxFormBytes)
|
||||
if err := context.Request.ParseForm(); err != nil {
|
||||
var tooLarge *http.MaxBytesError
|
||||
if errors.As(err, &tooLarge) {
|
||||
context.Status(http.StatusRequestEntityTooLarge)
|
||||
} else {
|
||||
context.Status(http.StatusBadRequest)
|
||||
}
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func firstError(validation tasks.Errors) string {
|
||||
for _, field := range []string{"title", "product_url", "sku_color", "sku_size", "quantity", "max_total_price"} {
|
||||
if _, ok := validation[field]; ok {
|
||||
return field
|
||||
}
|
||||
}
|
||||
return "title"
|
||||
}
|
||||
|
||||
func returnTo(value string) string {
|
||||
if value == "/tasks" || strings.HasPrefix(value, "/tasks/") || strings.HasPrefix(value, "/tasks?") {
|
||||
if strings.Contains(value, "\\") || strings.Contains(value, "%") || strings.HasPrefix(value, "//") {
|
||||
return "/tasks"
|
||||
}
|
||||
parsed, err := url.ParseRequestURI(value)
|
||||
if err == nil && parsed.IsAbs() == false && parsed.Host == "" && hasSafeTaskPath(parsed.Path) {
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
||||
return "/tasks"
|
||||
}
|
||||
|
||||
func hasSafeTaskPath(path string) bool {
|
||||
for _, segment := range strings.Split(path, "/") {
|
||||
if segment == "." || segment == ".." {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -1,28 +1,637 @@
|
||||
package server_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"cmbuyer/admin/internal/auth"
|
||||
"cmbuyer/admin/internal/deviceauth"
|
||||
"cmbuyer/admin/internal/evidence"
|
||||
"cmbuyer/admin/internal/server"
|
||||
"cmbuyer/admin/internal/taskdetail"
|
||||
"cmbuyer/admin/internal/tasks"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
func TestHealthz(t *testing.T) {
|
||||
var csrfPattern = regexp.MustCompile(`name="csrf_token" value="([^"]+)"`)
|
||||
var createKeyPattern = regexp.MustCompile(`name="create_key" value="([^"]+)"`)
|
||||
|
||||
func TestHealthzIsPublic(t *testing.T) {
|
||||
router, _ := newRouter(t)
|
||||
request := httptest.NewRequest(http.MethodGet, "/healthz", nil)
|
||||
response := httptest.NewRecorder()
|
||||
|
||||
server.NewRouter().ServeHTTP(response, request)
|
||||
router.ServeHTTP(response, request)
|
||||
|
||||
if response.Code != http.StatusOK {
|
||||
t.Fatalf("healthz status = %d, want %d", response.Code, http.StatusOK)
|
||||
}
|
||||
|
||||
if contentType := response.Header().Get("Content-Type"); contentType != "application/json; charset=utf-8" {
|
||||
t.Fatalf("healthz content type = %q, want application/json; charset=utf-8", contentType)
|
||||
}
|
||||
|
||||
if body := response.Body.String(); body != "{\"status\":\"ok\"}" {
|
||||
t.Fatalf("healthz body = %q, want {\"status\":\"ok\"}", body)
|
||||
}
|
||||
assertSecurityHeaders(t, response)
|
||||
}
|
||||
|
||||
func TestTasksRequiresLoginAndBlocksOpenRedirects(t *testing.T) {
|
||||
router, _ := newRouter(t)
|
||||
|
||||
tasks := serve(router, http.MethodGet, "/tasks", nil, nil)
|
||||
if tasks.Code != http.StatusSeeOther {
|
||||
t.Fatalf("GET /tasks status = %d, want %d", tasks.Code, http.StatusSeeOther)
|
||||
}
|
||||
if location := tasks.Header().Get("Location"); location != "/login?return_to=%2Ftasks" {
|
||||
t.Fatalf("GET /tasks location = %q, want login return path", location)
|
||||
}
|
||||
|
||||
for _, target := range []string{"https://example.invalid", "//example.invalid", `\\example.invalid`, "/other", "/tasks/..", "/tasks/../other", "/tasks/%2e%2e", "%2F%2Fevil.invalid", "%252F%252Fevil.invalid"} {
|
||||
response := serve(router, http.MethodGet, "/login?return_to="+url.QueryEscape(target), nil, nil)
|
||||
if response.Code != http.StatusOK {
|
||||
t.Fatalf("GET /login return_to=%q status = %d, want 200", target, response.Code)
|
||||
}
|
||||
if strings.Contains(response.Body.String(), target) || !strings.Contains(response.Body.String(), `name="return_to" value="/tasks"`) {
|
||||
t.Fatalf("GET /login accepted unsafe return_to %q", target)
|
||||
}
|
||||
}
|
||||
|
||||
encodedPath := serve(router, http.MethodGet, "/login?return_to=%2Ftasks%252F..", nil, nil)
|
||||
if !strings.Contains(encodedPath.Body.String(), `name="return_to" value="/tasks"`) {
|
||||
t.Fatal("encoded parent path was accepted as return_to")
|
||||
}
|
||||
encodedQuery := serve(router, http.MethodGet, "/login?return_to=%2Ftasks%3Fnext%3D%252Ftasks%252F..", nil, nil)
|
||||
if !strings.Contains(encodedQuery.Body.String(), `name="return_to" value="/tasks"`) {
|
||||
t.Fatal("encoded query bypass was accepted as return_to")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoginRotatesSessionAndCSRF(t *testing.T) {
|
||||
router, _ := newRouter(t)
|
||||
initial := serve(router, http.MethodGet, "/login?return_to=%2Ftasks%3Fview%3Dmine", nil, nil)
|
||||
oldCookie := sessionCookie(t, initial)
|
||||
oldCSRF := csrfToken(t, initial.Body.String())
|
||||
|
||||
login := serve(router, http.MethodPost, "/login", url.Values{
|
||||
"csrf_token": {oldCSRF},
|
||||
"return_to": {"/tasks?view=mine"},
|
||||
"username": {"admin"},
|
||||
"password": {"test-password"},
|
||||
}, oldCookie)
|
||||
if login.Code != http.StatusSeeOther || login.Header().Get("Location") != "/tasks?view=mine" {
|
||||
t.Fatalf("successful login = (%d, %q), want 303 /tasks?view=mine", login.Code, login.Header().Get("Location"))
|
||||
}
|
||||
newCookie := sessionCookie(t, login)
|
||||
if newCookie.Value == oldCookie.Value {
|
||||
t.Fatal("successful login reused the anonymous session cookie")
|
||||
}
|
||||
|
||||
tasks := serve(router, http.MethodGet, "/tasks", nil, newCookie)
|
||||
if tasks.Code != http.StatusOK {
|
||||
t.Fatalf("GET /tasks after login status = %d, want 200", tasks.Code)
|
||||
}
|
||||
if newCSRF := csrfToken(t, tasks.Body.String()); newCSRF == oldCSRF {
|
||||
t.Fatal("successful login reused the anonymous CSRF token")
|
||||
}
|
||||
for _, forbidden := range []string{"建单", "试选", "拼多多", "规格", "单价", "证据"} {
|
||||
if strings.Contains(tasks.Body.String(), forbidden) {
|
||||
t.Fatalf("task shell must not expose deferred feature content %q", forbidden)
|
||||
}
|
||||
}
|
||||
assertSecurityHeaders(t, initial)
|
||||
assertSecurityHeaders(t, tasks)
|
||||
}
|
||||
|
||||
func TestLoginPageIncludesAccessibleFormBasics(t *testing.T) {
|
||||
router, _ := newRouter(t)
|
||||
page := serve(router, http.MethodGet, "/login", nil, nil)
|
||||
body := page.Body.String()
|
||||
for _, want := range []string{
|
||||
`<label for="username">`,
|
||||
`<label for="password">`,
|
||||
`autocomplete="username"`,
|
||||
`autocomplete="current-password"`,
|
||||
`min-height:44px`,
|
||||
`:focus-visible`,
|
||||
`prefers-reduced-motion`,
|
||||
} {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Fatalf("login page is missing %q", want)
|
||||
}
|
||||
}
|
||||
if strings.Contains(body, "http://") || strings.Contains(body, "https://") || strings.Contains(body, "<script") {
|
||||
t.Fatal("login page must not load external resources or require client-side JavaScript")
|
||||
}
|
||||
|
||||
failure := serve(router, http.MethodPost, "/login", url.Values{
|
||||
"csrf_token": {csrfToken(t, body)},
|
||||
"username": {"admin"},
|
||||
"password": {"wrong"},
|
||||
}, sessionCookie(t, page))
|
||||
if !strings.Contains(failure.Body.String(), `role="alert"`) {
|
||||
t.Fatal("login failure must announce its error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoginCSRFAndCredentialFailuresAreSafe(t *testing.T) {
|
||||
router, _ := newRouter(t)
|
||||
page := serve(router, http.MethodGet, "/login", nil, nil)
|
||||
cookie := sessionCookie(t, page)
|
||||
|
||||
withoutCSRF := serve(router, http.MethodPost, "/login", url.Values{
|
||||
"username": {"admin"},
|
||||
"password": {"test-password"},
|
||||
}, cookie)
|
||||
if withoutCSRF.Code != http.StatusForbidden || !strings.Contains(withoutCSRF.Body.String(), "请求已过期") {
|
||||
t.Fatalf("login without CSRF = (%d, %q), want rejected form", withoutCSRF.Code, withoutCSRF.Body.String())
|
||||
}
|
||||
|
||||
page = serve(router, http.MethodGet, "/login", nil, cookie)
|
||||
badCredentials := serve(router, http.MethodPost, "/login", url.Values{
|
||||
"csrf_token": {csrfToken(t, page.Body.String())},
|
||||
"username": {"unknown"},
|
||||
"password": {"wrong"},
|
||||
}, cookie)
|
||||
if badCredentials.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("login with invalid credentials status = %d, want 401", badCredentials.Code)
|
||||
}
|
||||
if body := badCredentials.Body.String(); !strings.Contains(body, "账号或密码不正确") || strings.Contains(body, "unknown") {
|
||||
t.Fatalf("invalid login leaked account detail: %q", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTamperedCookieCannotAccessTasks(t *testing.T) {
|
||||
router, _ := newRouter(t)
|
||||
page := serve(router, http.MethodGet, "/login", nil, nil)
|
||||
cookie := sessionCookie(t, page)
|
||||
|
||||
tampered := *cookie
|
||||
tampered.Value = flipCookieValue(t, cookie.Value)
|
||||
response := serve(router, http.MethodGet, "/tasks", nil, &tampered)
|
||||
if response.Code != http.StatusSeeOther {
|
||||
t.Fatalf("tampered cookie status = %d, want 303", response.Code)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
func TestTaskCreationRendersSharedFormsAndPersistsOnlyDraft(t *testing.T) {
|
||||
router, _ := newRouter(t)
|
||||
cookie := authenticate(t, router)
|
||||
|
||||
modal := serve(router, http.MethodGet, "/tasks?create=1", nil, cookie)
|
||||
if modal.Code != http.StatusOK {
|
||||
t.Fatalf("GET dialog form status = %d, want 200", modal.Code)
|
||||
}
|
||||
fullPage := serve(router, http.MethodGet, "/tasks/new", nil, cookie)
|
||||
if fullPage.Code != http.StatusOK {
|
||||
t.Fatalf("GET full form status = %d, want 200", fullPage.Code)
|
||||
}
|
||||
for _, want := range []string{`<div class="modal-scrim"`, `<dialog open`, `aria-modal="true"`, `name="title"`, `name="product_url"`, `name="sku_color"`, `name="sku_size"`, `name="quantity"`, `name="max_total_price"`, `type="url" inputmode="url" maxlength="2048"`, `type="number" inputmode="numeric" min="1" step="1"`, `inputmode="decimal" pattern="[0-9]+(\.[0-9]{1,2})?"`, `maxlength="120"`, `maxlength="80"`, `required`, `autofocus`, `导入</button><a class="button primary"`, `type="search"`, `data-start-purchases`, `data-select-all`, `最高总额`, `min-height:44px`, `:focus-visible`, `overflow-x:auto`, `prefers-reduced-motion`} {
|
||||
if !strings.Contains(modal.Body.String(), want) {
|
||||
t.Fatalf("dialog form is missing %q", want)
|
||||
}
|
||||
}
|
||||
for _, want := range []string{`name="title"`, `name="product_url"`, `name="sku_color"`, `name="sku_size"`, `name="quantity"`, `name="max_total_price"`, `name="form_mode" value="full"`} {
|
||||
if !strings.Contains(fullPage.Body.String(), want) {
|
||||
t.Fatalf("full-page form is missing %q", want)
|
||||
}
|
||||
}
|
||||
|
||||
invalid := serve(router, http.MethodPost, "/tasks", url.Values{
|
||||
"csrf_token": {csrfToken(t, modal.Body.String())},
|
||||
"create_key": {createKey(t, modal.Body.String())},
|
||||
"title": {`<script>alert(1)</script>`},
|
||||
"product_url": {"https://mobile.yangkeduo.com/goods.html?goods_id=937122477375&uin=discard"},
|
||||
"sku_color": {"black"},
|
||||
"sku_size": {"M"},
|
||||
"quantity": {"0"},
|
||||
"max_total_price": {"12.80"},
|
||||
"form_mode": {"dialog"},
|
||||
}, cookie)
|
||||
if invalid.Code != http.StatusBadRequest || !strings.Contains(invalid.Body.String(), `<dialog open`) || !strings.Contains(invalid.Body.String(), "数量必须是正整数") || !strings.Contains(invalid.Body.String(), `role="alert"`) || !strings.Contains(invalid.Body.String(), `href="#quantity"`) || !strings.Contains(invalid.Body.String(), `aria-describedby="quantity-error"`) || !strings.Contains(invalid.Body.String(), `autofocus`) {
|
||||
t.Fatalf("invalid create = (%d, %q), want dialog validation response", invalid.Code, invalid.Body.String())
|
||||
}
|
||||
if strings.Contains(invalid.Body.String(), `<script>alert(1)</script>`) || !strings.Contains(invalid.Body.String(), `<script>alert(1)</script>`) {
|
||||
t.Fatalf("invalid create did not safely preserve title: %q", invalid.Body.String())
|
||||
}
|
||||
if strings.Contains(invalid.Body.String(), "uin=discard") || !strings.Contains(invalid.Body.String(), `value="https://mobile.yangkeduo.com/goods.html?goods_id=937122477375"`) {
|
||||
t.Fatalf("invalid create did not canonicalize product URL: %q", invalid.Body.String())
|
||||
}
|
||||
|
||||
createPage := serve(router, http.MethodGet, "/tasks?create=1", nil, cookie)
|
||||
key := createKey(t, createPage.Body.String())
|
||||
created := serve(router, http.MethodPost, "/tasks", url.Values{
|
||||
"csrf_token": {csrfToken(t, createPage.Body.String())},
|
||||
"create_key": {key},
|
||||
"title": {"<b>夏季上衣</b>"},
|
||||
"product_url": {"https://mobile.yangkeduo.com/goods.html?goods_id=937122477375&utm_source=discard"},
|
||||
"sku_color": {"black"},
|
||||
"sku_size": {"M"},
|
||||
"quantity": {"2"},
|
||||
"max_total_price": {"12.8"},
|
||||
"form_mode": {"dialog"},
|
||||
}, cookie)
|
||||
if created.Code != http.StatusSeeOther || !strings.HasPrefix(created.Header().Get("Location"), "/tasks?created=") {
|
||||
t.Fatalf("valid create = (%d, %q), want 303 to a created-task acknowledgement", created.Code, created.Header().Get("Location"))
|
||||
}
|
||||
replay := serve(router, http.MethodPost, "/tasks", url.Values{
|
||||
"csrf_token": {csrfToken(t, createPage.Body.String())},
|
||||
"create_key": {key},
|
||||
"title": {"<b>夏季上衣</b>"},
|
||||
"product_url": {"https://mobile.yangkeduo.com/goods.html?goods_id=937122477375&utm_source=discard"},
|
||||
"sku_color": {"black"},
|
||||
"sku_size": {"M"},
|
||||
"quantity": {"2"},
|
||||
"max_total_price": {"12.8"},
|
||||
"form_mode": {"dialog"},
|
||||
}, cookie)
|
||||
if replay.Code != http.StatusSeeOther {
|
||||
t.Fatalf("idempotent replay status = %d, want 303", replay.Code)
|
||||
}
|
||||
conflict := serve(router, http.MethodPost, "/tasks", url.Values{
|
||||
"csrf_token": {csrfToken(t, createPage.Body.String())},
|
||||
"create_key": {key},
|
||||
"title": {"different task"},
|
||||
"product_url": {"https://mobile.yangkeduo.com/goods.html?goods_id=937122477375"},
|
||||
"sku_color": {"black"},
|
||||
"sku_size": {"M"},
|
||||
"quantity": {"2"},
|
||||
"max_total_price": {"12.80"},
|
||||
"form_mode": {"dialog"},
|
||||
}, cookie)
|
||||
if conflict.Code != http.StatusConflict || !strings.Contains(conflict.Body.String(), "该创建请求已用于另一条任务") {
|
||||
t.Fatalf("conflicting create = (%d, %q), want a 409 form error", conflict.Code, conflict.Body.String())
|
||||
}
|
||||
|
||||
list := serve(router, http.MethodGet, created.Header().Get("Location"), nil, cookie)
|
||||
if list.Code != http.StatusOK {
|
||||
t.Fatalf("GET /tasks status = %d, want 200", list.Code)
|
||||
}
|
||||
body := list.Body.String()
|
||||
for _, want := range []string{`任务已创建,已显示在列表首行。`, `<b>夏季上衣</b>`, `https://mobile.yangkeduo.com/goods.html?goods_id=937122477375`, `target="_blank"`, `rel="noopener noreferrer"`, `¥12.80`, `待开始`, `选择全部任务`, `选择任务`} {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Fatalf("task list is missing %q", want)
|
||||
}
|
||||
}
|
||||
for _, forbidden := range []string{"utm_source", "试选", "订单确认", "真机", "提交订单"} {
|
||||
if strings.Contains(body, forbidden) {
|
||||
t.Fatalf("task list exposed deferred scope %q", forbidden)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTasksPageKeepsOriginalShellAndRendersFilteredWorkbench(t *testing.T) {
|
||||
store := &memoryStore{rows: []tasks.TaskRow{
|
||||
{ID: "b3c9f507-7473-4fa6-8d71-8786c34c6301", Title: "待开始衬衫", GoodsID: "937122477375", SKUColor: "黑色", SKUSize: "M", Quantity: 2, MaxTotalPrice: "12.80", Status: "DRAFT", Version: 3, CreatedAt: time.Date(2026, 8, 4, 1, 2, 3, 0, time.UTC)},
|
||||
{ID: "c3c9f507-7473-4fa6-8d71-8786c34c6301", Title: "等待领取衬衫", GoodsID: "958756616606", SKUColor: "白色", SKUSize: "L", Quantity: 1, MaxTotalPrice: "20.00", Status: "PENDING", Version: 4, CreatedAt: time.Date(2026, 8, 4, 2, 3, 4, 0, time.UTC)},
|
||||
}}
|
||||
router, _ := newRouterWithStore(t, store)
|
||||
cookie := authenticate(t, router)
|
||||
query := url.Values{"keyword": {"衬衫"}, "created_from": {"2026-08-04"}, "created_to": {"2026-08-04"}}
|
||||
response := serve(router, http.MethodGet, "/tasks?"+query.Encode(), nil, cookie)
|
||||
if response.Code != http.StatusOK {
|
||||
t.Fatalf("filtered tasks status = %d, want 200", response.Code)
|
||||
}
|
||||
body := response.Body.String()
|
||||
for _, want := range []string{
|
||||
`<a class="skip" href="#main">`,
|
||||
`:focus-visible`,
|
||||
`min-height:44px`,
|
||||
`@media(max-width:420px)`,
|
||||
`prefers-reduced-motion`,
|
||||
`<button class="button" type="button" disabled>导入</button><a class="button primary" href="/tasks?create=1">创建任务</a>`,
|
||||
`name="keyword" type="search" value="衬衫"`,
|
||||
`name="created_from" type="date" value="2026-08-04"`,
|
||||
`name="created_to" type="date" value="2026-08-04"`,
|
||||
`data-start-purchases`,
|
||||
`data-selection-summary aria-live="polite"`,
|
||||
`系统不会付款`,
|
||||
`开始采购(只创建待付款订单)`,
|
||||
`采购结果`,
|
||||
`创建时间(上海)`,
|
||||
`https://mobile.yangkeduo.com/goods.html?goods_id=937122477375`,
|
||||
`target="_blank" rel="noopener noreferrer"`,
|
||||
`data-task-row data-detail-url="/tasks/b3c9f507-7473-4fa6-8d71-8786c34c6301" tabindex="0"`,
|
||||
`data-open-detail>查看详情</button>`,
|
||||
`.detail-link-button{display:block;min-height:44px`,
|
||||
`data-detail-drawer aria-modal="true"`,
|
||||
`待开始`,
|
||||
`已授权待领取`,
|
||||
`datetime="2026-08-04T09:02:03+08:00">2026-08-04 09:02`,
|
||||
`<script src="/static/tasks.js" defer></script>`,
|
||||
} {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Fatalf("workbench is missing %q", want)
|
||||
}
|
||||
}
|
||||
if strings.Index(body, `name="keyword"`) > strings.Index(body, `data-start-purchases`) || strings.Index(body, `data-start-purchases`) > strings.Index(body, `<div class="table-wrap">`) {
|
||||
t.Fatal("workbench rows are not ordered as toolbar, filters, batch actions, table")
|
||||
}
|
||||
if count := strings.Count(body, `data-task-id=`); count != 1 {
|
||||
t.Fatalf("selectable row count = %d, want only the DRAFT row", count)
|
||||
}
|
||||
for _, forbidden := range []string{`<th scope="col">操作</th>`, `确认开始采购`, `确认机器选对了吗`} {
|
||||
if strings.Contains(body, forbidden) {
|
||||
t.Fatalf("workbench exposed forbidden per-row or confirmation UI %q", forbidden)
|
||||
}
|
||||
}
|
||||
if store.listTasksCalls != 1 || store.listDraftsCalls != 0 {
|
||||
t.Fatalf("GET /tasks calls = (ListTasks %d, ListDrafts %d), want (1, 0)", store.listTasksCalls, store.listDraftsCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTasksPageRerendersAccessibleFilterErrorsAndKeepsValues(t *testing.T) {
|
||||
store := &memoryStore{}
|
||||
router, _ := newRouterWithStore(t, store)
|
||||
cookie := authenticate(t, router)
|
||||
query := url.Values{
|
||||
"keyword": {`保留%_\`},
|
||||
"status": {"UNKNOWN"},
|
||||
"created_from": {"2026-02-30"},
|
||||
"created_to": {"not-a-date"},
|
||||
}
|
||||
response := serve(router, http.MethodGet, "/tasks?"+query.Encode(), nil, cookie)
|
||||
if response.Code != http.StatusBadRequest {
|
||||
t.Fatalf("invalid filter status = %d, want 400", response.Code)
|
||||
}
|
||||
body := response.Body.String()
|
||||
for _, want := range []string{
|
||||
`role="alert" aria-live="assertive"`,
|
||||
`href="#filter-status"`,
|
||||
`href="#filter-created-from"`,
|
||||
`href="#filter-created-to"`,
|
||||
`name="keyword" type="search" value="保留%_\"`,
|
||||
`<option value="UNKNOWN" selected>无效状态:UNKNOWN</option>`,
|
||||
`name="created_from" type="date" value="2026-02-30" aria-invalid="true" aria-describedby="filter-created-from-error"`,
|
||||
`name="created_to" type="date" value="not-a-date" aria-invalid="true" aria-describedby="filter-created-to-error"`,
|
||||
`id="filter-status-error"`,
|
||||
`id="filter-created-from-error"`,
|
||||
`id="filter-created-to-error"`,
|
||||
`筛选条件有误`,
|
||||
} {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Fatalf("invalid filter page is missing %q", want)
|
||||
}
|
||||
}
|
||||
if store.listTasksCalls != 0 || store.listDraftsCalls != 0 {
|
||||
t.Fatalf("invalid filter queried stores: ListTasks=%d ListDrafts=%d", store.listTasksCalls, store.listDraftsCalls)
|
||||
}
|
||||
assertSecurityHeaders(t, response)
|
||||
}
|
||||
|
||||
func TestTaskCreationRequiresAuthenticationAndCSRF(t *testing.T) {
|
||||
router, _ := newRouter(t)
|
||||
if response := serve(router, http.MethodPost, "/tasks", url.Values{}, nil); response.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("anonymous POST /tasks = %d, want 401", response.Code)
|
||||
}
|
||||
cookie := authenticate(t, router)
|
||||
if response := serve(router, http.MethodPost, "/tasks", url.Values{}, cookie); response.Code != http.StatusForbidden {
|
||||
t.Fatalf("POST /tasks without CSRF = %d, want 403", response.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTaskCreationFailsClosedForMalformedOrOversizedForms(t *testing.T) {
|
||||
router, _ := newRouter(t)
|
||||
cookie := authenticate(t, router)
|
||||
page := serve(router, http.MethodGet, "/tasks?create=1", nil, cookie)
|
||||
base := url.Values{
|
||||
"csrf_token": {csrfToken(t, page.Body.String())},
|
||||
"create_key": {createKey(t, page.Body.String())},
|
||||
"title": {"title"},
|
||||
"product_url": {"https://mobile.yangkeduo.com/goods.html?goods_id=1;uin=malformed"},
|
||||
"sku_color": {"black"},
|
||||
"sku_size": {"M"},
|
||||
"quantity": {"1"},
|
||||
"max_total_price": {"1.00"},
|
||||
"form_mode": {"dialog"},
|
||||
}
|
||||
malformed := serve(router, http.MethodPost, "/tasks", base, cookie)
|
||||
if malformed.Code != http.StatusBadRequest || !strings.Contains(malformed.Body.String(), "canonical 商品链接") {
|
||||
t.Fatalf("malformed URL create = (%d, %q), want validation failure", malformed.Code, malformed.Body.String())
|
||||
}
|
||||
|
||||
oversized := url.Values{"csrf_token": {csrfToken(t, page.Body.String())}, "title": {strings.Repeat("x", 9<<10)}}
|
||||
if response := serve(router, http.MethodPost, "/tasks", oversized, cookie); response.Code != http.StatusRequestEntityTooLarge {
|
||||
t.Fatalf("oversized form status = %d, want 413", response.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func assertSecurityHeaders(t *testing.T, response *httptest.ResponseRecorder) {
|
||||
t.Helper()
|
||||
want := map[string]string{
|
||||
"Cache-Control": "no-store",
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
"Referrer-Policy": "no-referrer",
|
||||
"Content-Security-Policy": "default-src 'self'; style-src 'self' 'unsafe-inline'; script-src 'self'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'; form-action 'self'",
|
||||
}
|
||||
for name, expected := range want {
|
||||
if got := response.Header().Get(name); got != expected {
|
||||
t.Fatalf("%s = %q, want %q", name, got, expected)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func flipCookieValue(t *testing.T, value string) string {
|
||||
t.Helper()
|
||||
if value == "" {
|
||||
t.Fatal("cannot tamper with an empty cookie")
|
||||
}
|
||||
if value[0] == 'A' {
|
||||
return "B" + value[1:]
|
||||
}
|
||||
return "A" + value[1:]
|
||||
}
|
||||
|
||||
func TestLogoutRequiresCSRFAndRevokesSession(t *testing.T) {
|
||||
router, _ := newRouter(t)
|
||||
loginPage := serve(router, http.MethodGet, "/login", nil, nil)
|
||||
loginCookie := sessionCookie(t, loginPage)
|
||||
login := serve(router, http.MethodPost, "/login", url.Values{
|
||||
"csrf_token": {csrfToken(t, loginPage.Body.String())},
|
||||
"username": {"admin"},
|
||||
"password": {"test-password"},
|
||||
}, loginCookie)
|
||||
authenticatedCookie := sessionCookie(t, login)
|
||||
|
||||
missingCSRF := serve(router, http.MethodPost, "/logout", url.Values{}, authenticatedCookie)
|
||||
if missingCSRF.Code != http.StatusForbidden {
|
||||
t.Fatalf("logout without CSRF status = %d, want 403", missingCSRF.Code)
|
||||
}
|
||||
|
||||
tasks := serve(router, http.MethodGet, "/tasks", nil, authenticatedCookie)
|
||||
logout := serve(router, http.MethodPost, "/logout", url.Values{
|
||||
"csrf_token": {csrfToken(t, tasks.Body.String())},
|
||||
}, authenticatedCookie)
|
||||
if logout.Code != http.StatusSeeOther || logout.Header().Get("Location") != "/login" {
|
||||
t.Fatalf("logout = (%d, %q), want 303 /login", logout.Code, logout.Header().Get("Location"))
|
||||
}
|
||||
if cookie := sessionCookie(t, logout); cookie.MaxAge >= 0 {
|
||||
t.Fatalf("logout cookie MaxAge = %d, want a deletion cookie", cookie.MaxAge)
|
||||
}
|
||||
|
||||
reused := serve(router, http.MethodGet, "/tasks", nil, authenticatedCookie)
|
||||
if reused.Code != http.StatusSeeOther {
|
||||
t.Fatalf("revoked session status = %d, want 303", reused.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func newRouter(t *testing.T) (*gin.Engine, *auth.Manager) {
|
||||
return newRouterWithStore(t, &memoryStore{})
|
||||
}
|
||||
|
||||
func newRouterWithStore(t *testing.T, store tasks.Store) (*gin.Engine, *auth.Manager) {
|
||||
return newRouterWithDependencies(t, store, emptyDetailStore{}, emptyEvidenceStore{}, deviceauth.RejectAllAuthenticator{})
|
||||
}
|
||||
|
||||
func newRouterWithDependencies(t *testing.T, store tasks.Store, details taskdetail.Store, evidenceStore evidence.Store, deviceAuthenticator deviceauth.Authenticator) (*gin.Engine, *auth.Manager) {
|
||||
t.Helper()
|
||||
gin.SetMode(gin.TestMode)
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte("test-password"), bcrypt.MinCost)
|
||||
if err != nil {
|
||||
t.Fatalf("generate bcrypt hash: %v", err)
|
||||
}
|
||||
manager := auth.NewManager([]byte(strings.Repeat("s", 32)), false)
|
||||
router, err := server.NewRouter(server.Options{
|
||||
AdminUsername: "admin",
|
||||
AdminPasswordBcrypt: string(hash),
|
||||
Sessions: manager,
|
||||
Tasks: store,
|
||||
TaskDetails: details,
|
||||
Evidence: evidenceStore,
|
||||
DeviceAuthenticator: deviceAuthenticator,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("NewRouter: %v", err)
|
||||
}
|
||||
return router, manager
|
||||
}
|
||||
|
||||
type emptyDetailStore struct{}
|
||||
|
||||
func (emptyDetailStore) Get(context.Context, string) (taskdetail.Detail, error) {
|
||||
return taskdetail.Detail{}, taskdetail.ErrNotFound
|
||||
}
|
||||
|
||||
type emptyEvidenceStore struct{}
|
||||
|
||||
func (emptyEvidenceStore) Stage(io.Reader, string) (evidence.StagedFile, error) {
|
||||
return evidence.StagedFile{}, evidence.ErrInvalid
|
||||
}
|
||||
func (emptyEvidenceStore) Discard(evidence.StagedFile) {}
|
||||
func (emptyEvidenceStore) Commit(context.Context, deviceauth.Principal, evidence.UploadMetadata, evidence.StagedFile) (evidence.Asset, bool, error) {
|
||||
return evidence.Asset{}, false, evidence.ErrInvalid
|
||||
}
|
||||
func (emptyEvidenceStore) Open(context.Context, string) (evidence.Asset, io.ReadSeekCloser, error) {
|
||||
return evidence.Asset{}, nil, evidence.ErrNotFound
|
||||
}
|
||||
|
||||
type memoryStore struct {
|
||||
drafts []tasks.Draft
|
||||
rows []tasks.TaskRow
|
||||
listDraftsCalls int
|
||||
listTasksCalls int
|
||||
startCalls int
|
||||
}
|
||||
|
||||
func (store *memoryStore) CreateDraft(_ context.Context, draft tasks.Draft) (tasks.Draft, error) {
|
||||
for _, existing := range store.drafts {
|
||||
if existing.ID == draft.ID {
|
||||
if existing.Title != draft.Title || existing.GoodsID != draft.GoodsID || existing.SKUColor != draft.SKUColor || existing.SKUSize != draft.SKUSize || existing.Quantity != draft.Quantity || existing.MaxTotalPrice != draft.MaxTotalPrice {
|
||||
return tasks.Draft{}, tasks.ErrCreateKeyConflict
|
||||
}
|
||||
return existing, nil
|
||||
}
|
||||
}
|
||||
store.drafts = append(store.drafts, draft)
|
||||
return draft, nil
|
||||
}
|
||||
func (store *memoryStore) ListDrafts(_ context.Context) ([]tasks.Draft, error) {
|
||||
store.listDraftsCalls++
|
||||
return append([]tasks.Draft(nil), store.drafts...), nil
|
||||
}
|
||||
func (store *memoryStore) ListTasks(_ context.Context, _ tasks.TaskFilter) ([]tasks.TaskRow, error) {
|
||||
store.listTasksCalls++
|
||||
if store.rows != nil {
|
||||
return append([]tasks.TaskRow(nil), store.rows...), nil
|
||||
}
|
||||
result := make([]tasks.TaskRow, 0, len(store.drafts))
|
||||
for _, draft := range store.drafts {
|
||||
result = append(result, tasks.TaskRow{ID: draft.ID, Title: draft.Title, GoodsID: draft.GoodsID, SKUColor: draft.SKUColor, SKUSize: draft.SKUSize, Quantity: draft.Quantity, MaxTotalPrice: draft.MaxTotalPrice, Status: "DRAFT", Version: 1, CreatedAt: draft.CreatedAt})
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
func (store *memoryStore) StartPurchases(_ context.Context, _ tasks.StartCommand, _ string) (tasks.StartResult, error) {
|
||||
store.startCalls++
|
||||
return tasks.StartResult{}, tasks.ErrInvalidStart
|
||||
}
|
||||
|
||||
func serve(router http.Handler, method, target string, form url.Values, cookie *http.Cookie) *httptest.ResponseRecorder {
|
||||
var body *strings.Reader
|
||||
if form == nil {
|
||||
body = strings.NewReader("")
|
||||
} else {
|
||||
body = strings.NewReader(form.Encode())
|
||||
}
|
||||
request := httptest.NewRequest(method, target, body)
|
||||
if form != nil {
|
||||
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
}
|
||||
if cookie != nil {
|
||||
request.AddCookie(cookie)
|
||||
}
|
||||
response := httptest.NewRecorder()
|
||||
router.ServeHTTP(response, request)
|
||||
return response
|
||||
}
|
||||
|
||||
func sessionCookie(t *testing.T, response *httptest.ResponseRecorder) *http.Cookie {
|
||||
t.Helper()
|
||||
for _, cookie := range response.Result().Cookies() {
|
||||
if cookie.Name == auth.CookieName {
|
||||
return cookie
|
||||
}
|
||||
}
|
||||
t.Fatalf("response did not set %s cookie", auth.CookieName)
|
||||
return nil
|
||||
}
|
||||
|
||||
func csrfToken(t *testing.T, body string) string {
|
||||
t.Helper()
|
||||
matches := csrfPattern.FindStringSubmatch(body)
|
||||
if len(matches) != 2 || matches[1] == "" {
|
||||
t.Fatalf("no CSRF token in response body: %q", body)
|
||||
}
|
||||
return matches[1]
|
||||
}
|
||||
|
||||
func createKey(t *testing.T, body string) string {
|
||||
t.Helper()
|
||||
matches := createKeyPattern.FindStringSubmatch(body)
|
||||
if len(matches) != 2 || matches[1] == "" {
|
||||
t.Fatalf("no create key in response body: %q", body)
|
||||
}
|
||||
return matches[1]
|
||||
}
|
||||
|
||||
func authenticate(t *testing.T, router http.Handler) *http.Cookie {
|
||||
t.Helper()
|
||||
page := serve(router, http.MethodGet, "/login", nil, nil)
|
||||
login := serve(router, http.MethodPost, "/login", url.Values{
|
||||
"csrf_token": {csrfToken(t, page.Body.String())},
|
||||
"username": {"admin"},
|
||||
"password": {"test-password"},
|
||||
}, sessionCookie(t, page))
|
||||
if login.Code != http.StatusSeeOther {
|
||||
t.Fatalf("authenticate status = %d, want 303", login.Code)
|
||||
}
|
||||
return sessionCookie(t, login)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,328 @@
|
||||
package server_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"cmbuyer/admin/internal/tasks"
|
||||
)
|
||||
|
||||
const (
|
||||
startKeyForHTTP = "c3c9f507-7473-4fa6-8d71-8786c34c6301"
|
||||
taskIDForHTTP = "a3c9f507-7473-4fa6-8d71-8786c34c6301"
|
||||
)
|
||||
|
||||
func TestStartPurchasesAuthenticatesBeforeInspectingRequestBody(t *testing.T) {
|
||||
store := &startRecordingStore{}
|
||||
router, _ := newRouterWithStore(t, store)
|
||||
hugeMalformed := `{"start_key":"` + strings.Repeat("x", 70<<10)
|
||||
|
||||
for name, request := range map[string]*http.Request{
|
||||
"anonymous malformed": newStartRequest(t, hugeMalformed, "text/plain", "", nil),
|
||||
"device bearer": newStartRequest(t, validStartBody(), "application/json", "", nil),
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
if name == "device bearer" {
|
||||
request.Header.Set("Authorization", "Bearer device-token")
|
||||
}
|
||||
response := httptest.NewRecorder()
|
||||
router.ServeHTTP(response, request)
|
||||
if response.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("status = %d, want 401", response.Code)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
cookie, csrf := authenticatedStartSession(t, router)
|
||||
for name, token := range map[string]string{"missing CSRF": "", "wrong CSRF": "wrong-csrf"} {
|
||||
request := newStartRequest(t, hugeMalformed, "text/plain", token, cookie)
|
||||
response := httptest.NewRecorder()
|
||||
router.ServeHTTP(response, request)
|
||||
if response.Code != http.StatusForbidden {
|
||||
t.Fatalf("%s status = %d, want 403", name, response.Code)
|
||||
}
|
||||
}
|
||||
if csrf == "" {
|
||||
t.Fatal("authenticated page did not contain a CSRF token")
|
||||
}
|
||||
if store.startCalls != 0 {
|
||||
t.Fatalf("unauthorized requests called store %d times", store.startCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStartPurchasesRejectsInvalidUTF8BeforeJSONDecoding(t *testing.T) {
|
||||
validPrefix := []byte(`{"start_key":"` + startKeyForHTTP + `","tasks":[],"start_key":"`)
|
||||
duplicateKeyBypass := append(append([]byte(nil), validPrefix...), 0xff)
|
||||
duplicateKeyBypass = append(duplicateKeyBypass, []byte(`"}`)...)
|
||||
invalidWhitespace := append([]byte(validStartBody()), 0xfe)
|
||||
|
||||
for name, body := range map[string][]byte{
|
||||
"invalid byte after JSON": invalidWhitespace,
|
||||
"invalid duplicate-key value": duplicateKeyBypass,
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
store := &startRecordingStore{}
|
||||
router, _ := newRouterWithStore(t, store)
|
||||
cookie, csrf := authenticatedStartSession(t, router)
|
||||
response := serveStartBytes(t, router, body, "application/json", csrf, cookie)
|
||||
if response.Code != http.StatusBadRequest || store.startCalls != 0 {
|
||||
t.Fatalf("status/calls = %d/%d, want 400/0", response.Code, store.startCalls)
|
||||
}
|
||||
if response.Body.Len() != 0 {
|
||||
t.Fatalf("invalid UTF-8 response leaked body %q", response.Body.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestStartPurchasesEnforcesExact64KiBBodyBoundary(t *testing.T) {
|
||||
const limit = 64 << 10
|
||||
base := validStartBody()
|
||||
for name, test := range map[string]struct {
|
||||
body string
|
||||
want int
|
||||
wantCalls int
|
||||
}{
|
||||
"exact limit": {body: base + strings.Repeat(" ", limit-len(base)), want: http.StatusOK, wantCalls: 1},
|
||||
"one over": {body: base + strings.Repeat(" ", limit-len(base)+1), want: http.StatusRequestEntityTooLarge},
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
store := &startRecordingStore{startResult: successfulStartResult()}
|
||||
router, _ := newRouterWithStore(t, store)
|
||||
cookie, csrf := authenticatedStartSession(t, router)
|
||||
response := serveStartRequest(t, router, test.body, "application/json", csrf, cookie)
|
||||
if response.Code != test.want || store.startCalls != test.wantCalls {
|
||||
t.Fatalf("status/calls = %d/%d, want %d/%d", response.Code, store.startCalls, test.want, test.wantCalls)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestStartPurchasesContentTypeContract(t *testing.T) {
|
||||
for _, contentType := range []string{
|
||||
"application/json",
|
||||
"application/json; charset=utf-8",
|
||||
"application/json;charset=UTF-8",
|
||||
} {
|
||||
t.Run("accept "+contentType, func(t *testing.T) {
|
||||
store := &startRecordingStore{startResult: successfulStartResult()}
|
||||
router, _ := newRouterWithStore(t, store)
|
||||
cookie, csrf := authenticatedStartSession(t, router)
|
||||
response := serveStartRequest(t, router, validStartBody(), contentType, csrf, cookie)
|
||||
if response.Code != http.StatusOK || store.startCalls != 1 {
|
||||
t.Fatalf("status/calls = %d/%d, want 200/1", response.Code, store.startCalls)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
for _, contentType := range []string{
|
||||
"",
|
||||
"text/plain",
|
||||
"application/json-patch+json",
|
||||
"application/json; charset=gbk",
|
||||
"application/json; profile=unapproved",
|
||||
"application/json; charset",
|
||||
} {
|
||||
t.Run("reject "+contentType, func(t *testing.T) {
|
||||
store := &startRecordingStore{}
|
||||
router, _ := newRouterWithStore(t, store)
|
||||
cookie, csrf := authenticatedStartSession(t, router)
|
||||
response := serveStartRequest(t, router, validStartBody(), contentType, csrf, cookie)
|
||||
if response.Code != http.StatusUnsupportedMediaType || store.startCalls != 0 {
|
||||
t.Fatalf("status/calls = %d/%d, want 415/0", response.Code, store.startCalls)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestStartPurchasesRejectsMalformedAndOversizedJSON(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
body string
|
||||
want int
|
||||
storeErr error
|
||||
wantCalls int
|
||||
}{
|
||||
{name: "empty", body: "", want: http.StatusBadRequest},
|
||||
{name: "empty object", body: `{}`, want: http.StatusBadRequest, storeErr: tasks.ErrInvalidStart, wantCalls: 1},
|
||||
{name: "null object", body: `null`, want: http.StatusBadRequest, storeErr: tasks.ErrInvalidStart, wantCalls: 1},
|
||||
{name: "malformed", body: `{`, want: http.StatusBadRequest},
|
||||
{name: "wrong top-level type", body: `[]`, want: http.StatusBadRequest},
|
||||
{name: "unknown field", body: `{"start_key":"` + startKeyForHTTP + `","tasks":[],"created_by":"attacker"}`, want: http.StatusBadRequest},
|
||||
{name: "wrong field type", body: `{"start_key":"` + startKeyForHTTP + `","tasks":[{"task_id":"` + taskIDForHTTP + `","expected_task_version":"1"}]}`, want: http.StatusBadRequest},
|
||||
{name: "second JSON value", body: validStartBody() + `{}`, want: http.StatusBadRequest},
|
||||
{name: "duplicate task ids", body: `{"start_key":"` + startKeyForHTTP + `","tasks":[{"task_id":"` + taskIDForHTTP + `","expected_task_version":1},{"task_id":"` + taskIDForHTTP + `","expected_task_version":1}]}`, want: http.StatusBadRequest, storeErr: tasks.ErrInvalidStart, wantCalls: 1},
|
||||
{name: "oversized first value", body: `{"start_key":"` + strings.Repeat("x", 70<<10), want: http.StatusRequestEntityTooLarge},
|
||||
{name: "oversized trailing whitespace", body: validStartBody() + strings.Repeat(" ", 70<<10), want: http.StatusRequestEntityTooLarge},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
store := &startRecordingStore{startErr: test.storeErr}
|
||||
router, _ := newRouterWithStore(t, store)
|
||||
cookie, csrf := authenticatedStartSession(t, router)
|
||||
response := serveStartRequest(t, router, test.body, "application/json", csrf, cookie)
|
||||
if response.Code != test.want || store.startCalls != test.wantCalls {
|
||||
t.Fatalf("status/calls = %d/%d, want %d/%d", response.Code, store.startCalls, test.want, test.wantCalls)
|
||||
}
|
||||
if response.Body.Len() != 0 {
|
||||
t.Fatalf("error response leaked body %q", response.Body.String())
|
||||
}
|
||||
assertSecurityHeaders(t, response)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestStartPurchasesUsesAuthenticatedAdminAndReturnsStableSafeResult(t *testing.T) {
|
||||
result := successfulStartResult()
|
||||
store := &startRecordingStore{startResult: result}
|
||||
router, _ := newRouterWithStore(t, store)
|
||||
cookie, csrf := authenticatedStartSession(t, router)
|
||||
|
||||
first := serveStartRequest(t, router, validStartBody(), "application/json; charset=utf-8", csrf, cookie)
|
||||
second := serveStartRequest(t, router, validStartBody(), "application/json", csrf, cookie)
|
||||
for index, response := range []*httptest.ResponseRecorder{first, second} {
|
||||
if response.Code != http.StatusOK {
|
||||
t.Fatalf("response %d status = %d, want 200", index, response.Code)
|
||||
}
|
||||
if got := response.Header().Get("Content-Type"); got != "application/json; charset=utf-8" {
|
||||
t.Fatalf("response content type = %q", got)
|
||||
}
|
||||
var decoded tasks.StartResult
|
||||
if err := json.Unmarshal(response.Body.Bytes(), &decoded); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
}
|
||||
if decoded.PaymentAutomated || decoded.AuthorizedCount != 1 || decoded.Tasks[0].AuthorizationID != result.Tasks[0].AuthorizationID {
|
||||
t.Fatalf("unsafe or unstable response = %#v", decoded)
|
||||
}
|
||||
assertSecurityHeaders(t, response)
|
||||
}
|
||||
if store.startCalls != 2 || len(store.createdBy) != 2 || store.createdBy[0] != "admin" || store.createdBy[1] != "admin" {
|
||||
t.Fatalf("store calls/created_by = %d/%#v", store.startCalls, store.createdBy)
|
||||
}
|
||||
for _, command := range store.commands {
|
||||
if command.StartKey != startKeyForHTTP || len(command.Tasks) != 1 || command.Tasks[0].TaskID != taskIDForHTTP || command.Tasks[0].ExpectedTaskVersion != 7 {
|
||||
t.Fatalf("decoded command = %#v", command)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestStartPurchasesMapsStoreErrorsWithoutLeakingDetails(t *testing.T) {
|
||||
for name, test := range map[string]struct {
|
||||
err error
|
||||
want int
|
||||
}{
|
||||
"invalid": {err: tasks.ErrInvalidStart, want: http.StatusBadRequest},
|
||||
"conflict": {err: tasks.ErrStartConflict, want: http.StatusConflict},
|
||||
"internal": {err: errors.New("sqlite secret path and query"), want: http.StatusInternalServerError},
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
store := &startRecordingStore{startErr: test.err}
|
||||
router, _ := newRouterWithStore(t, store)
|
||||
cookie, csrf := authenticatedStartSession(t, router)
|
||||
response := serveStartRequest(t, router, validStartBody(), "application/json", csrf, cookie)
|
||||
if response.Code != test.want || store.startCalls != 1 {
|
||||
t.Fatalf("status/calls = %d/%d, want %d/1", response.Code, store.startCalls, test.want)
|
||||
}
|
||||
if response.Body.Len() != 0 || strings.Contains(response.Body.String(), "sqlite") {
|
||||
t.Fatalf("error leaked details: %q", response.Body.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
type startRecordingStore struct {
|
||||
startResult tasks.StartResult
|
||||
startErr error
|
||||
startCalls int
|
||||
commands []tasks.StartCommand
|
||||
createdBy []string
|
||||
}
|
||||
|
||||
func (store *startRecordingStore) CreateDraft(_ context.Context, draft tasks.Draft) (tasks.Draft, error) {
|
||||
return draft, nil
|
||||
}
|
||||
|
||||
func (store *startRecordingStore) ListDrafts(context.Context) ([]tasks.Draft, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (store *startRecordingStore) ListTasks(context.Context, tasks.TaskFilter) ([]tasks.TaskRow, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (store *startRecordingStore) StartPurchases(_ context.Context, command tasks.StartCommand, createdBy string) (tasks.StartResult, error) {
|
||||
store.startCalls++
|
||||
store.commands = append(store.commands, command)
|
||||
store.createdBy = append(store.createdBy, createdBy)
|
||||
return store.startResult, store.startErr
|
||||
}
|
||||
|
||||
func authenticatedStartSession(t *testing.T, router http.Handler) (*http.Cookie, string) {
|
||||
t.Helper()
|
||||
cookie := authenticate(t, router)
|
||||
page := serve(router, http.MethodGet, "/tasks", nil, cookie)
|
||||
if page.Code != http.StatusOK {
|
||||
t.Fatalf("GET /tasks status = %d", page.Code)
|
||||
}
|
||||
return cookie, csrfToken(t, page.Body.String())
|
||||
}
|
||||
|
||||
func newStartRequest(t *testing.T, body, contentType, csrf string, cookie *http.Cookie) *http.Request {
|
||||
t.Helper()
|
||||
return newStartByteRequest(t, []byte(body), contentType, csrf, cookie)
|
||||
}
|
||||
|
||||
func newStartByteRequest(t *testing.T, body []byte, contentType, csrf string, cookie *http.Cookie) *http.Request {
|
||||
t.Helper()
|
||||
request := httptest.NewRequest(http.MethodPost, "/tasks/start-purchases", bytes.NewReader(body))
|
||||
if contentType != "" {
|
||||
request.Header.Set("Content-Type", contentType)
|
||||
}
|
||||
if csrf != "" {
|
||||
request.Header.Set("X-CSRF-Token", csrf)
|
||||
}
|
||||
if cookie != nil {
|
||||
request.AddCookie(cookie)
|
||||
}
|
||||
return request
|
||||
}
|
||||
|
||||
func serveStartBytes(t *testing.T, router http.Handler, body []byte, contentType, csrf string, cookie *http.Cookie) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
response := httptest.NewRecorder()
|
||||
router.ServeHTTP(response, newStartByteRequest(t, body, contentType, csrf, cookie))
|
||||
return response
|
||||
}
|
||||
|
||||
func serveStartRequest(t *testing.T, router http.Handler, body, contentType, csrf string, cookie *http.Cookie) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
response := httptest.NewRecorder()
|
||||
router.ServeHTTP(response, newStartRequest(t, body, contentType, csrf, cookie))
|
||||
return response
|
||||
}
|
||||
|
||||
func validStartBody() string {
|
||||
return `{"start_key":"` + startKeyForHTTP + `","tasks":[{"task_id":"` + taskIDForHTTP + `","expected_task_version":7}]}`
|
||||
}
|
||||
|
||||
func successfulStartResult() tasks.StartResult {
|
||||
expires := time.Date(2026, 8, 4, 2, 3, 4, 0, time.UTC)
|
||||
return tasks.StartResult{
|
||||
StartKey: startKeyForHTTP,
|
||||
AuthorizedCount: 1,
|
||||
PaymentAutomated: false,
|
||||
Tasks: []tasks.AuthorizedTask{{
|
||||
TaskID: taskIDForHTTP,
|
||||
TaskVersion: 8,
|
||||
AuthorizationID: "d3c9f507-7473-4fa6-8d71-8786c34c6301",
|
||||
ExpiresAt: expires,
|
||||
}},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"mime"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"cmbuyer/admin/internal/taskdetail"
|
||||
"cmbuyer/admin/internal/transport/webui"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
const detailViewHeader = "X-CMBuyer-View"
|
||||
const detailVaryHeader = "X-CMBuyer-View, Accept, Sec-Fetch-Site"
|
||||
|
||||
func taskDetailPage(options Options) gin.HandlerFunc {
|
||||
return func(context *gin.Context) {
|
||||
context.Header("Vary", detailVaryHeader)
|
||||
if !options.Sessions.IsAuthenticated(context.Request) {
|
||||
context.Redirect(http.StatusSeeOther, "/login?return_to="+url.QueryEscape(context.Request.URL.RequestURI()))
|
||||
return
|
||||
}
|
||||
view := context.GetHeader(detailViewHeader)
|
||||
if view != "" && view != "drawer" {
|
||||
context.Status(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if view == "drawer" {
|
||||
if context.GetHeader("Sec-Fetch-Site") != "same-origin" {
|
||||
context.Status(http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
if !acceptsHTML(context.GetHeader("Accept")) {
|
||||
context.Status(http.StatusNotAcceptable)
|
||||
return
|
||||
}
|
||||
}
|
||||
detail, err := options.TaskDetails.Get(context.Request.Context(), context.Param("id"))
|
||||
if errors.Is(err, taskdetail.ErrNotFound) {
|
||||
context.Status(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
context.Status(http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
context.Header("Content-Type", "text/html; charset=utf-8")
|
||||
context.Status(http.StatusOK)
|
||||
data := webui.TaskDetailData{Detail: detail}
|
||||
if view == "drawer" {
|
||||
if err := webui.RenderTaskDetailFragment(context.Writer, data); err != nil {
|
||||
_ = context.Error(err)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err := webui.RenderTaskDetailPage(context.Writer, data); err != nil {
|
||||
_ = context.Error(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func acceptsHTML(header string) bool {
|
||||
for _, value := range strings.Split(header, ",") {
|
||||
mediaType, parameters, err := mime.ParseMediaType(strings.TrimSpace(value))
|
||||
if err != nil || !strings.EqualFold(mediaType, "text/html") {
|
||||
continue
|
||||
}
|
||||
quality := 1.0
|
||||
if rawQuality, exists := parameters["q"]; exists {
|
||||
quality, err = strconv.ParseFloat(rawQuality, 64)
|
||||
if err != nil || quality < 0 || quality > 1 {
|
||||
continue
|
||||
}
|
||||
}
|
||||
if quality > 0 {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
package server_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"cmbuyer/admin/internal/deviceauth"
|
||||
"cmbuyer/admin/internal/taskdetail"
|
||||
)
|
||||
|
||||
const detailTaskID = "a3c9f507-7473-4fa6-8d71-8786c34c6301"
|
||||
|
||||
func TestTaskDetailRequiresAdminBeforeLookup(t *testing.T) {
|
||||
details := &recordingDetailStore{detail: taskDetailFixture()}
|
||||
router, _ := newRouterWithDependencies(t, &memoryStore{}, details, emptyEvidenceStore{}, deviceauth.RejectAllAuthenticator{})
|
||||
response := serve(router, http.MethodGet, "/tasks/"+detailTaskID, nil, nil)
|
||||
if response.Code != http.StatusSeeOther || !strings.HasPrefix(response.Header().Get("Location"), "/login?return_to=") || details.calls != 0 {
|
||||
t.Fatalf("anonymous detail = %d/%q, calls=%d", response.Code, response.Header().Get("Location"), details.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTaskDetailFullPageAndDrawerShareAuditContent(t *testing.T) {
|
||||
details := &recordingDetailStore{detail: taskDetailFixture()}
|
||||
router, _ := newRouterWithDependencies(t, &memoryStore{}, details, emptyEvidenceStore{}, deviceauth.RejectAllAuthenticator{})
|
||||
cookie := authenticate(t, router)
|
||||
full := serve(router, http.MethodGet, "/tasks/"+detailTaskID, nil, cookie)
|
||||
if full.Code != http.StatusOK || !strings.Contains(full.Body.String(), "<!doctype html>") || !strings.Contains(full.Body.String(), `data-task-detail-content`) {
|
||||
t.Fatalf("full detail = %d/%q", full.Code, full.Body.String())
|
||||
}
|
||||
request := httptest.NewRequest(http.MethodGet, "/tasks/"+detailTaskID, nil)
|
||||
request.AddCookie(cookie)
|
||||
request.Header.Set("X-CMBuyer-View", "drawer")
|
||||
request.Header.Set("Accept", "text/html")
|
||||
request.Header.Set("Sec-Fetch-Site", "same-origin")
|
||||
fragment := httptest.NewRecorder()
|
||||
router.ServeHTTP(fragment, request)
|
||||
if fragment.Code != http.StatusOK || strings.Contains(fragment.Body.String(), "<!doctype html>") || !strings.Contains(fragment.Body.String(), `data-task-detail-content`) {
|
||||
t.Fatalf("fragment detail = %d/%q", fragment.Code, fragment.Body.String())
|
||||
}
|
||||
for _, text := range []string{"测试<script>", "订单已创建,系统尚未付款", "SKU_PANEL_GATE_1", "/evidence/b3c9f507-7473-4fa6-8d71-8786c34c6301", "暂无规格、价格或数量读数", "本页没有重试、再次提交或付款动作"} {
|
||||
if !strings.Contains(full.Body.String(), text) || !strings.Contains(fragment.Body.String(), text) {
|
||||
t.Fatalf("shared detail missing %q", text)
|
||||
}
|
||||
}
|
||||
if strings.Contains(full.Body.String(), "<script>") || strings.Contains(fragment.Body.String(), "<script>") {
|
||||
t.Fatal("task title was not HTML escaped")
|
||||
}
|
||||
if got := fragment.Header().Get("Vary"); got != "X-CMBuyer-View, Accept, Sec-Fetch-Site" {
|
||||
t.Fatalf("fragment Vary = %q", got)
|
||||
}
|
||||
if details.calls != 2 {
|
||||
t.Fatalf("detail store calls = %d, want 2", details.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTaskDetailRejectsForgedFragmentAndMissingTask(t *testing.T) {
|
||||
details := &recordingDetailStore{err: taskdetail.ErrNotFound}
|
||||
router, _ := newRouterWithDependencies(t, &memoryStore{}, details, emptyEvidenceStore{}, deviceauth.RejectAllAuthenticator{})
|
||||
cookie := authenticate(t, router)
|
||||
for name, headers := range map[string]map[string]string{
|
||||
"unknown view": {"X-CMBuyer-View": "xml", "Accept": "text/html"},
|
||||
"missing fetch site": {"X-CMBuyer-View": "drawer", "Accept": "text/html"},
|
||||
"cross-site drawer": {"X-CMBuyer-View": "drawer", "Accept": "text/html", "Sec-Fetch-Site": "cross-site"},
|
||||
"wrong accept": {"X-CMBuyer-View": "drawer", "Accept": "application/json", "Sec-Fetch-Site": "same-origin"},
|
||||
"html quality zero": {"X-CMBuyer-View": "drawer", "Accept": "text/html;q=0, application/json", "Sec-Fetch-Site": "same-origin"},
|
||||
"html substring mime": {"X-CMBuyer-View": "drawer", "Accept": "application/nottext/html", "Sec-Fetch-Site": "same-origin"},
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
request := httptest.NewRequest(http.MethodGet, "/tasks/"+detailTaskID, nil)
|
||||
request.AddCookie(cookie)
|
||||
for key, value := range headers {
|
||||
request.Header.Set(key, value)
|
||||
}
|
||||
response := httptest.NewRecorder()
|
||||
router.ServeHTTP(response, request)
|
||||
if response.Code < 400 || response.Code >= 500 || response.Body.Len() != 0 {
|
||||
t.Fatalf("forged fragment = %d/%q", response.Code, response.Body.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
missing := serve(router, http.MethodGet, "/tasks/not-a-uuid", nil, cookie)
|
||||
if missing.Code != http.StatusNotFound || missing.Body.Len() != 0 {
|
||||
t.Fatalf("missing detail = %d/%q", missing.Code, missing.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
type recordingDetailStore struct {
|
||||
detail taskdetail.Detail
|
||||
err error
|
||||
calls int
|
||||
}
|
||||
|
||||
func (store *recordingDetailStore) Get(context.Context, string) (taskdetail.Detail, error) {
|
||||
store.calls++
|
||||
return store.detail, store.err
|
||||
}
|
||||
|
||||
func taskDetailFixture() taskdetail.Detail {
|
||||
started := time.Date(2026, 8, 4, 1, 2, 3, 0, time.UTC)
|
||||
return taskdetail.Detail{
|
||||
Task: taskdetail.Task{ID: detailTaskID, Source: "MANUAL", Title: "测试<script>", GoodsID: "937122477375", SKUColor: "黑色", SKUSize: "M", Quantity: 2, MaxTotalPrice: "30.00", Status: "WAITING_PAYMENT", Version: 3, CreatedAt: started, UpdatedAt: started},
|
||||
Authorizations: []taskdetail.Authorization{{ID: "c3c9f507-7473-4fa6-8d71-8786c34c6301", Status: "FENCED", CreatedBy: "admin", TotalPriceCap: "30.00", TaskVersion: 2, CreatedAt: started, ExpiresAt: started.Add(time.Hour)}},
|
||||
Attempts: []taskdetail.Attempt{{ID: "d3c9f507-7473-4fa6-8d71-8786c34c6301", AuthorizationID: "c3c9f507-7473-4fa6-8d71-8786c34c6301", Status: "CLAIMED", ClaimGeneration: 1, StartedAt: started}},
|
||||
Evidence: []taskdetail.Evidence{{ID: "b3c9f507-7473-4fa6-8d71-8786c34c6301", AttemptID: "d3c9f507-7473-4fa6-8d71-8786c34c6301", Kind: "SKU_PANEL_GATE_1", PrivacyTier: "INTERNAL_RAW", SHA256: strings.Repeat("a", 64), ByteSize: 100, ContentType: "image/png", Width: 100, Height: 200, CapturedAt: started}},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
//go:build !windows
|
||||
|
||||
package evidence
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
)
|
||||
|
||||
func syncDirectory(path string) error {
|
||||
directory, err := os.Open(path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("open directory for durability sync: %w", err)
|
||||
}
|
||||
defer directory.Close()
|
||||
if err := directory.Sync(); err != nil {
|
||||
return fmt.Errorf("sync directory metadata: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
//go:build windows
|
||||
|
||||
package evidence
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
// syncDirectory uses an explicit directory handle because os.Open(...).Sync is not a portable
|
||||
// Windows directory durability boundary. Any unsupported filesystem or access failure is fatal:
|
||||
// callers must not make the corresponding evidence row visible in SQLite.
|
||||
func syncDirectory(path string) error {
|
||||
pathPointer, err := syscall.UTF16PtrFromString(path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("encode directory path for durability sync: %w", err)
|
||||
}
|
||||
handle, err := syscall.CreateFile(
|
||||
pathPointer,
|
||||
syscall.GENERIC_WRITE,
|
||||
syscall.FILE_SHARE_READ|syscall.FILE_SHARE_WRITE|syscall.FILE_SHARE_DELETE,
|
||||
nil,
|
||||
syscall.OPEN_EXISTING,
|
||||
syscall.FILE_FLAG_BACKUP_SEMANTICS,
|
||||
0,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("open directory for durability sync: %w", err)
|
||||
}
|
||||
defer syscall.CloseHandle(handle)
|
||||
if err := syscall.FlushFileBuffers(handle); err != nil {
|
||||
return fmt.Errorf("flush directory metadata: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,545 @@
|
||||
// Package evidence stores INTERNAL_RAW PNG assets outside the public web tree.
|
||||
package evidence
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"database/sql"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"image/png"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"cmbuyer/admin/internal/deviceauth"
|
||||
core "cmbuyer/admin/internal/evidence"
|
||||
)
|
||||
|
||||
var pngSignature = []byte{0x89, 'P', 'N', 'G', 0x0d, 0x0a, 0x1a, 0x0a}
|
||||
|
||||
type Store struct {
|
||||
database *sql.DB
|
||||
root string
|
||||
now func() time.Time
|
||||
random io.Reader
|
||||
syncDirectory func(string) error
|
||||
syncFile func(*os.File) error
|
||||
renameFile func(string, string) error
|
||||
commitTx func(*sql.Tx) error
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
func NewStore(database *sql.DB, root string) (*Store, error) {
|
||||
return newStore(database, root, syncDirectory)
|
||||
}
|
||||
|
||||
func newStore(database *sql.DB, root string, directorySync func(string) error) (*Store, error) {
|
||||
if database == nil {
|
||||
return nil, errors.New("evidence database is required")
|
||||
}
|
||||
if directorySync == nil {
|
||||
return nil, errors.New("evidence directory sync is required")
|
||||
}
|
||||
if root == "" || !filepath.IsAbs(root) {
|
||||
return nil, errors.New("evidence root must be an absolute path")
|
||||
}
|
||||
absolute, err := filepath.Abs(filepath.Clean(root))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("resolve evidence root: %w", err)
|
||||
}
|
||||
if filepath.Dir(absolute) == absolute {
|
||||
return nil, errors.New("evidence root cannot be a filesystem root")
|
||||
}
|
||||
if err := ensureDurableDirectory(absolute, 0o700, directorySync); err != nil {
|
||||
return nil, fmt.Errorf("create evidence root: %w", err)
|
||||
}
|
||||
// A prior startup may have created the root and then failed its parent sync.
|
||||
// Existence is therefore never accepted as proof that the directory entry is durable.
|
||||
if err := directorySync(filepath.Dir(absolute)); err != nil {
|
||||
return nil, fmt.Errorf("persist evidence root directory: %w", err)
|
||||
}
|
||||
if err := os.Chmod(absolute, 0o700); err != nil {
|
||||
return nil, fmt.Errorf("protect evidence root: %w", err)
|
||||
}
|
||||
staging := filepath.Join(absolute, ".staging")
|
||||
if err := ensureDurableDirectory(staging, 0o700, directorySync); err != nil {
|
||||
return nil, fmt.Errorf("create evidence staging directory: %w", err)
|
||||
}
|
||||
if err := directorySync(absolute); err != nil {
|
||||
return nil, fmt.Errorf("persist evidence staging directory: %w", err)
|
||||
}
|
||||
if err := os.Chmod(staging, 0o700); err != nil {
|
||||
return nil, fmt.Errorf("protect evidence staging directory: %w", err)
|
||||
}
|
||||
if _, err := database.Exec("SELECT storage_key FROM evidence_assets LIMIT 1"); err != nil {
|
||||
return nil, fmt.Errorf("evidence migration is not available: %w", err)
|
||||
}
|
||||
return &Store{
|
||||
database: database,
|
||||
root: absolute,
|
||||
now: time.Now,
|
||||
random: rand.Reader,
|
||||
syncDirectory: directorySync,
|
||||
syncFile: func(file *os.File) error { return file.Sync() },
|
||||
renameFile: os.Rename,
|
||||
commitTx: func(transaction *sql.Tx) error { return transaction.Commit() },
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (store *Store) Stage(reader io.Reader, contentType string) (staged core.StagedFile, resultErr error) {
|
||||
if reader == nil || contentType != core.PNGContentType {
|
||||
return core.StagedFile{}, core.ErrInvalid
|
||||
}
|
||||
temporary, err := os.CreateTemp(filepath.Join(store.root, ".staging"), "upload-*.png")
|
||||
if err != nil {
|
||||
return core.StagedFile{}, err
|
||||
}
|
||||
staged.Path = temporary.Name()
|
||||
defer func() {
|
||||
if resultErr != nil {
|
||||
_ = temporary.Close()
|
||||
_ = os.Remove(staged.Path)
|
||||
}
|
||||
}()
|
||||
if err := temporary.Chmod(0o600); err != nil {
|
||||
return core.StagedFile{}, err
|
||||
}
|
||||
hasher := sha256.New()
|
||||
written, err := io.Copy(io.MultiWriter(temporary, hasher), io.LimitReader(reader, core.MaxFileBytes+1))
|
||||
if err != nil {
|
||||
return core.StagedFile{}, err
|
||||
}
|
||||
if written > core.MaxFileBytes {
|
||||
return core.StagedFile{}, core.ErrTooLarge
|
||||
}
|
||||
if written == 0 {
|
||||
return core.StagedFile{}, core.ErrInvalid
|
||||
}
|
||||
if err := temporary.Sync(); err != nil {
|
||||
return core.StagedFile{}, err
|
||||
}
|
||||
if err := temporary.Close(); err != nil {
|
||||
return core.StagedFile{}, err
|
||||
}
|
||||
|
||||
imageFile, err := os.Open(staged.Path)
|
||||
if err != nil {
|
||||
return core.StagedFile{}, err
|
||||
}
|
||||
defer imageFile.Close()
|
||||
width, height, err := validatePNG(imageFile)
|
||||
if err != nil {
|
||||
return core.StagedFile{}, err
|
||||
}
|
||||
|
||||
staged.SHA256 = hex.EncodeToString(hasher.Sum(nil))
|
||||
staged.ByteSize = written
|
||||
staged.ContentType = core.PNGContentType
|
||||
staged.Width = width
|
||||
staged.Height = height
|
||||
return staged, nil
|
||||
}
|
||||
|
||||
func (store *Store) Discard(staged core.StagedFile) {
|
||||
if store.isStagedPath(staged.Path) {
|
||||
_ = os.Remove(staged.Path)
|
||||
}
|
||||
}
|
||||
|
||||
func (store *Store) Commit(ctx context.Context, principal deviceauth.Principal, metadata core.UploadMetadata, staged core.StagedFile) (core.Asset, bool, error) {
|
||||
if !store.isStagedPath(staged.Path) || !validPrincipal(principal) || !validMetadata(metadata) || metadata.SHA256 != staged.SHA256 || staged.ContentType != core.PNGContentType || staged.ByteSize < 1 || staged.ByteSize > core.MaxFileBytes || staged.Width < 1 || staged.Height < 1 || staged.Width > core.MaxImageSide || staged.Height > core.MaxImageSide || int64(staged.Width)*int64(staged.Height) > core.MaxImagePixels {
|
||||
store.Discard(staged)
|
||||
return core.Asset{}, false, core.ErrInvalid
|
||||
}
|
||||
defer store.Discard(staged)
|
||||
|
||||
store.mu.Lock()
|
||||
defer store.mu.Unlock()
|
||||
|
||||
transaction, err := store.database.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return core.Asset{}, false, err
|
||||
}
|
||||
defer transaction.Rollback()
|
||||
|
||||
existing, found, err := findByUploadKey(ctx, transaction, principal.ID, metadata.UploadKey)
|
||||
if err != nil {
|
||||
return core.Asset{}, false, err
|
||||
}
|
||||
if found {
|
||||
if !sameUpload(existing, principal, metadata, staged) {
|
||||
return core.Asset{}, false, core.ErrConflict
|
||||
}
|
||||
if err := store.verifyStoredFile(existing); err != nil {
|
||||
return core.Asset{}, false, err
|
||||
}
|
||||
if err := store.commitTx(transaction); err != nil {
|
||||
return core.Asset{}, false, err
|
||||
}
|
||||
return existing, true, nil
|
||||
}
|
||||
|
||||
var attemptCount int
|
||||
if err := transaction.QueryRowContext(ctx, "SELECT COUNT(*) FROM purchase_attempts WHERE task_id = ? AND id = ?", metadata.TaskID, metadata.AttemptID).Scan(&attemptCount); err != nil {
|
||||
return core.Asset{}, false, err
|
||||
}
|
||||
if attemptCount != 1 {
|
||||
return core.Asset{}, false, core.ErrInvalid
|
||||
}
|
||||
|
||||
storageKey := storageKey(metadata.SHA256)
|
||||
finalPath, err := store.pathForKey(storageKey)
|
||||
if err != nil {
|
||||
return core.Asset{}, false, err
|
||||
}
|
||||
finalDirectory := filepath.Dir(finalPath)
|
||||
if err := ensureDurableDirectory(finalDirectory, 0o700, store.syncDirectory); err != nil {
|
||||
return core.Asset{}, false, err
|
||||
}
|
||||
// Always repeat the shard-parent boundary. If an earlier attempt created this
|
||||
// directory and its parent sync failed, a retry must not trust mere existence.
|
||||
if err := store.syncDirectory(store.root); err != nil {
|
||||
return core.Asset{}, false, fmt.Errorf("persist evidence shard directory: %w", err)
|
||||
}
|
||||
if err := os.Chmod(finalDirectory, 0o700); err != nil {
|
||||
return core.Asset{}, false, err
|
||||
}
|
||||
if info, statErr := os.Stat(finalPath); statErr == nil {
|
||||
if !info.Mode().IsRegular() || info.Size() != staged.ByteSize || fileSHA256(finalPath) != staged.SHA256 {
|
||||
return core.Asset{}, false, errors.New("stored evidence content does not match its key")
|
||||
}
|
||||
} else if !errors.Is(statErr, os.ErrNotExist) {
|
||||
return core.Asset{}, false, statErr
|
||||
} else {
|
||||
publishPath, err := store.preparePublishFile(staged, finalDirectory)
|
||||
if err != nil {
|
||||
return core.Asset{}, false, err
|
||||
}
|
||||
defer os.Remove(publishPath)
|
||||
if err := store.renameFile(publishPath, finalPath); err != nil {
|
||||
return core.Asset{}, false, err
|
||||
}
|
||||
}
|
||||
// The publication file was fsynced in this shard before its same-directory rename.
|
||||
// Persist the final directory entry before SQLite can expose a referencing row.
|
||||
// A directory sync failure is deliberately fatal; the unreachable file may remain
|
||||
// as an orphan, but no evidence_assets row may be committed for it.
|
||||
if err := store.syncDirectory(finalDirectory); err != nil {
|
||||
return core.Asset{}, false, fmt.Errorf("persist evidence directory entry: %w", err)
|
||||
}
|
||||
|
||||
id, err := newUUID(store.random)
|
||||
if err != nil {
|
||||
return core.Asset{}, false, err
|
||||
}
|
||||
now := store.now().UTC()
|
||||
asset := core.Asset{
|
||||
ID: id, TaskID: metadata.TaskID, AttemptID: metadata.AttemptID,
|
||||
Kind: metadata.Kind, PrivacyTier: metadata.PrivacyTier, SHA256: staged.SHA256,
|
||||
ByteSize: staged.ByteSize, ContentType: staged.ContentType, Width: staged.Width, Height: staged.Height,
|
||||
CapturedAt: metadata.CapturedAt.UTC(), UploadedByDeviceID: principal.ID,
|
||||
StorageKey: storageKey, CreatedAt: now,
|
||||
}
|
||||
_, err = transaction.ExecContext(ctx, `INSERT INTO evidence_assets
|
||||
(id, upload_key, task_id, attempt_id, kind, privacy_tier, sha256, byte_size, content_type, width_px, height_px, storage_key, uploaded_by_device_id, captured_at, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
asset.ID, metadata.UploadKey, asset.TaskID, asset.AttemptID, asset.Kind, asset.PrivacyTier,
|
||||
asset.SHA256, asset.ByteSize, asset.ContentType, asset.Width, asset.Height, asset.StorageKey,
|
||||
asset.UploadedByDeviceID, asset.CapturedAt.Format(time.RFC3339Nano), asset.CreatedAt.Format(time.RFC3339Nano))
|
||||
if err != nil {
|
||||
return core.Asset{}, false, err
|
||||
}
|
||||
if err := store.commitTx(transaction); err != nil {
|
||||
return core.Asset{}, false, err
|
||||
}
|
||||
return asset, false, nil
|
||||
}
|
||||
|
||||
func (store *Store) Open(ctx context.Context, id string) (core.Asset, io.ReadSeekCloser, error) {
|
||||
if !validUUID(id) {
|
||||
return core.Asset{}, nil, core.ErrNotFound
|
||||
}
|
||||
asset, found, err := findByID(ctx, store.database, id)
|
||||
if err != nil {
|
||||
return core.Asset{}, nil, err
|
||||
}
|
||||
if !found || asset.StorageKey != storageKey(asset.SHA256) {
|
||||
return core.Asset{}, nil, core.ErrNotFound
|
||||
}
|
||||
path, err := store.pathForKey(asset.StorageKey)
|
||||
if err != nil {
|
||||
return core.Asset{}, nil, core.ErrNotFound
|
||||
}
|
||||
file, err := os.Open(path)
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return core.Asset{}, nil, core.ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return core.Asset{}, nil, err
|
||||
}
|
||||
info, err := file.Stat()
|
||||
if err != nil || !info.Mode().IsRegular() || info.Size() != asset.ByteSize {
|
||||
_ = file.Close()
|
||||
if err != nil {
|
||||
return core.Asset{}, nil, err
|
||||
}
|
||||
return core.Asset{}, nil, core.ErrNotFound
|
||||
}
|
||||
return asset, file, nil
|
||||
}
|
||||
|
||||
func (store *Store) verifyStoredFile(asset core.Asset) error {
|
||||
path, err := store.pathForKey(asset.StorageKey)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
info, err := os.Stat(path)
|
||||
if err != nil || !info.Mode().IsRegular() || info.Size() != asset.ByteSize || fileSHA256(path) != asset.SHA256 {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return errors.New("stored evidence file is invalid")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (store *Store) isStagedPath(path string) bool {
|
||||
if path == "" {
|
||||
return false
|
||||
}
|
||||
relative, err := filepath.Rel(filepath.Join(store.root, ".staging"), filepath.Clean(path))
|
||||
return err == nil && relative != "." && relative != "" && relative != ".." && !strings.HasPrefix(relative, ".."+string(filepath.Separator)) && !filepath.IsAbs(relative)
|
||||
}
|
||||
|
||||
func (store *Store) pathForKey(key string) (string, error) {
|
||||
path := filepath.Join(store.root, filepath.FromSlash(key))
|
||||
relative, err := filepath.Rel(store.root, path)
|
||||
if err != nil || relative == "." || relative == "" || relative == ".." || strings.HasPrefix(relative, ".."+string(filepath.Separator)) || filepath.IsAbs(relative) {
|
||||
return "", errors.New("invalid evidence storage key")
|
||||
}
|
||||
return path, nil
|
||||
}
|
||||
|
||||
func (store *Store) preparePublishFile(staged core.StagedFile, directory string) (path string, resultErr error) {
|
||||
source, err := os.Open(staged.Path)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer source.Close()
|
||||
|
||||
temporary, err := os.CreateTemp(directory, ".publish-*.png")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
temporaryPath := temporary.Name()
|
||||
path = temporaryPath
|
||||
defer func() {
|
||||
if resultErr != nil {
|
||||
_ = temporary.Close()
|
||||
_ = os.Remove(temporaryPath)
|
||||
}
|
||||
}()
|
||||
if err := temporary.Chmod(0o600); err != nil {
|
||||
return "", err
|
||||
}
|
||||
hasher := sha256.New()
|
||||
written, err := io.Copy(io.MultiWriter(temporary, hasher), source)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if written != staged.ByteSize || hex.EncodeToString(hasher.Sum(nil)) != staged.SHA256 {
|
||||
return "", errors.New("staged evidence changed before publication")
|
||||
}
|
||||
width, height, err := validatePNG(temporary)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if width != staged.Width || height != staged.Height {
|
||||
return "", errors.New("staged evidence dimensions changed before publication")
|
||||
}
|
||||
if err := store.syncFile(temporary); err != nil {
|
||||
return "", fmt.Errorf("sync evidence publication file: %w", err)
|
||||
}
|
||||
if err := temporary.Close(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return path, nil
|
||||
}
|
||||
|
||||
func validatePNG(reader io.ReadSeeker) (int, int, error) {
|
||||
if _, err := reader.Seek(0, io.SeekStart); err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
signature := make([]byte, len(pngSignature))
|
||||
if _, err := io.ReadFull(reader, signature); err != nil || string(signature) != string(pngSignature) {
|
||||
return 0, 0, core.ErrInvalid
|
||||
}
|
||||
if _, err := reader.Seek(0, io.SeekStart); err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
configuration, err := png.DecodeConfig(reader)
|
||||
if err != nil || configuration.Width < 1 || configuration.Height < 1 || configuration.Width > core.MaxImageSide || configuration.Height > core.MaxImageSide || int64(configuration.Width)*int64(configuration.Height) > core.MaxImagePixels {
|
||||
return 0, 0, core.ErrInvalid
|
||||
}
|
||||
if _, err := reader.Seek(0, io.SeekStart); err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
if _, err := png.Decode(reader); err != nil {
|
||||
return 0, 0, core.ErrInvalid
|
||||
}
|
||||
var trailing [1]byte
|
||||
if count, err := reader.Read(trailing[:]); count != 0 || !errors.Is(err, io.EOF) {
|
||||
return 0, 0, core.ErrInvalid
|
||||
}
|
||||
return configuration.Width, configuration.Height, nil
|
||||
}
|
||||
|
||||
func ensureDurableDirectory(path string, mode os.FileMode, syncParent func(string) error) error {
|
||||
info, err := os.Stat(path)
|
||||
if err == nil {
|
||||
if !info.IsDir() {
|
||||
return fmt.Errorf("path exists but is not a directory: %s", path)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if !errors.Is(err, os.ErrNotExist) {
|
||||
return err
|
||||
}
|
||||
|
||||
parent := filepath.Dir(path)
|
||||
if parent == path {
|
||||
return fmt.Errorf("cannot create filesystem root as a managed directory: %s", path)
|
||||
}
|
||||
if err := ensureDurableDirectory(parent, mode, syncParent); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.Mkdir(path, mode); err != nil && !errors.Is(err, os.ErrExist) {
|
||||
return err
|
||||
}
|
||||
info, err = os.Stat(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !info.IsDir() {
|
||||
return fmt.Errorf("path exists but is not a directory: %s", path)
|
||||
}
|
||||
if err := os.Chmod(path, mode); err != nil {
|
||||
return err
|
||||
}
|
||||
// Syncing the parent makes creation of this directory durable. This also covers
|
||||
// a concurrent creator: returning success without the parent sync could otherwise
|
||||
// allow the following database transaction to outrun the directory entry.
|
||||
if err := syncParent(parent); err != nil {
|
||||
return fmt.Errorf("persist directory creation for %s: %w", path, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func storageKey(hash string) string { return hash[:2] + "/" + hash + ".png" }
|
||||
|
||||
func validMetadata(metadata core.UploadMetadata) bool {
|
||||
return validUUID(metadata.UploadKey) && validUUID(metadata.TaskID) && validUUID(metadata.AttemptID) && metadata.Kind == core.KindSKUPanelGate1 && metadata.PrivacyTier == core.PrivacyInternalRaw && validSHA256(metadata.SHA256) && !metadata.CapturedAt.IsZero() && metadata.CapturedAt.Location() == time.UTC
|
||||
}
|
||||
|
||||
func validPrincipal(principal deviceauth.Principal) bool {
|
||||
return deviceauth.ValidDeviceID(principal.ID)
|
||||
}
|
||||
|
||||
func validSHA256(value string) bool {
|
||||
if len(value) != 64 {
|
||||
return false
|
||||
}
|
||||
for _, character := range value {
|
||||
if !(character >= '0' && character <= '9' || character >= 'a' && character <= 'f') {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func validUUID(value string) bool {
|
||||
if len(value) != 36 {
|
||||
return false
|
||||
}
|
||||
for index, character := range value {
|
||||
if index == 8 || index == 13 || index == 18 || index == 23 {
|
||||
if character != '-' {
|
||||
return false
|
||||
}
|
||||
continue
|
||||
}
|
||||
if !(character >= '0' && character <= '9' || character >= 'a' && character <= 'f') {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return value[14] == '4' && (value[19] == '8' || value[19] == '9' || value[19] == 'a' || value[19] == 'b')
|
||||
}
|
||||
|
||||
func newUUID(reader io.Reader) (string, error) {
|
||||
bytes := make([]byte, 16)
|
||||
if _, err := io.ReadFull(reader, bytes); err != nil {
|
||||
return "", err
|
||||
}
|
||||
bytes[6] = (bytes[6] & 0x0f) | 0x40
|
||||
bytes[8] = (bytes[8] & 0x3f) | 0x80
|
||||
encoded := hex.EncodeToString(bytes)
|
||||
return encoded[:8] + "-" + encoded[8:12] + "-" + encoded[12:16] + "-" + encoded[16:20] + "-" + encoded[20:], nil
|
||||
}
|
||||
|
||||
func fileSHA256(path string) string {
|
||||
file, err := os.Open(path)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
defer file.Close()
|
||||
hasher := sha256.New()
|
||||
if _, err := io.Copy(hasher, file); err != nil {
|
||||
return ""
|
||||
}
|
||||
return hex.EncodeToString(hasher.Sum(nil))
|
||||
}
|
||||
|
||||
type rowScanner interface{ Scan(...any) error }
|
||||
|
||||
func findByUploadKey(ctx context.Context, query interface {
|
||||
QueryRowContext(context.Context, string, ...any) *sql.Row
|
||||
}, deviceID, uploadKey string) (core.Asset, bool, error) {
|
||||
return scanAsset(query.QueryRowContext(ctx, `SELECT id, task_id, attempt_id, kind, privacy_tier, sha256, byte_size, content_type, width_px, height_px, storage_key, uploaded_by_device_id, captured_at, created_at FROM evidence_assets WHERE uploaded_by_device_id = ? AND upload_key = ?`, deviceID, uploadKey))
|
||||
}
|
||||
|
||||
func findByID(ctx context.Context, query interface {
|
||||
QueryRowContext(context.Context, string, ...any) *sql.Row
|
||||
}, id string) (core.Asset, bool, error) {
|
||||
return scanAsset(query.QueryRowContext(ctx, `SELECT id, task_id, attempt_id, kind, privacy_tier, sha256, byte_size, content_type, width_px, height_px, storage_key, uploaded_by_device_id, captured_at, created_at FROM evidence_assets WHERE id = ?`, id))
|
||||
}
|
||||
|
||||
func scanAsset(row rowScanner) (core.Asset, bool, error) {
|
||||
var asset core.Asset
|
||||
var captured, created string
|
||||
err := row.Scan(&asset.ID, &asset.TaskID, &asset.AttemptID, &asset.Kind, &asset.PrivacyTier, &asset.SHA256, &asset.ByteSize, &asset.ContentType, &asset.Width, &asset.Height, &asset.StorageKey, &asset.UploadedByDeviceID, &captured, &created)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return core.Asset{}, false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return core.Asset{}, false, err
|
||||
}
|
||||
asset.CapturedAt, err = time.Parse(time.RFC3339Nano, captured)
|
||||
if err != nil {
|
||||
return core.Asset{}, false, err
|
||||
}
|
||||
asset.CreatedAt, err = time.Parse(time.RFC3339Nano, created)
|
||||
if err != nil {
|
||||
return core.Asset{}, false, err
|
||||
}
|
||||
return asset, true, nil
|
||||
}
|
||||
|
||||
func sameUpload(asset core.Asset, principal deviceauth.Principal, metadata core.UploadMetadata, staged core.StagedFile) bool {
|
||||
return asset.TaskID == metadata.TaskID && asset.AttemptID == metadata.AttemptID && asset.Kind == metadata.Kind && asset.PrivacyTier == metadata.PrivacyTier && asset.SHA256 == metadata.SHA256 && asset.ByteSize == staged.ByteSize && asset.ContentType == staged.ContentType && asset.Width == staged.Width && asset.Height == staged.Height && asset.UploadedByDeviceID == principal.ID && asset.CapturedAt.Equal(metadata.CapturedAt)
|
||||
}
|
||||
@@ -0,0 +1,554 @@
|
||||
package evidence
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"database/sql"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"image"
|
||||
"image/color"
|
||||
"image/png"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"cmbuyer/admin/internal/deviceauth"
|
||||
core "cmbuyer/admin/internal/evidence"
|
||||
"cmbuyer/admin/internal/migrations"
|
||||
"cmbuyer/admin/internal/storage/sqlite"
|
||||
)
|
||||
|
||||
const (
|
||||
testTaskID = "13c9f507-7473-4fa6-8d71-8786c34c6301"
|
||||
testAuthID = "23c9f507-7473-4fa6-8d71-8786c34c6301"
|
||||
testAttemptID = "33c9f507-7473-4fa6-8d71-8786c34c6301"
|
||||
testUploadKey = "43c9f507-7473-4fa6-8d71-8786c34c6301"
|
||||
testDeviceID = "53c9f507-7473-4fa6-8d71-8786c34c6301"
|
||||
)
|
||||
|
||||
func TestStageCommitReplayAndOpen(t *testing.T) {
|
||||
database, store := newTestStore(t)
|
||||
insertAttemptFixture(t, database)
|
||||
pngBytes := makePNG(t, 8, 6)
|
||||
hash := sha256Hex(pngBytes)
|
||||
metadata := core.UploadMetadata{UploadKey: testUploadKey, TaskID: testTaskID, AttemptID: testAttemptID, Kind: core.KindSKUPanelGate1, PrivacyTier: core.PrivacyInternalRaw, SHA256: hash, CapturedAt: time.Date(2026, 8, 4, 1, 2, 3, 0, time.UTC)}
|
||||
principal := deviceauth.Principal{ID: testDeviceID}
|
||||
|
||||
staged, err := store.Stage(bytes.NewReader(pngBytes), core.PNGContentType)
|
||||
if err != nil {
|
||||
t.Fatalf("Stage: %v", err)
|
||||
}
|
||||
asset, replayed, err := store.Commit(context.Background(), principal, metadata, staged)
|
||||
if err != nil || replayed {
|
||||
t.Fatalf("Commit = replayed %t, err %v", replayed, err)
|
||||
}
|
||||
if asset.SHA256 != hash || asset.ByteSize != int64(len(pngBytes)) || asset.Width != 8 || asset.Height != 6 || asset.StorageKey != hash[:2]+"/"+hash+".png" {
|
||||
t.Fatalf("asset = %#v", asset)
|
||||
}
|
||||
opened, reader, err := store.Open(context.Background(), asset.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("Open: %v", err)
|
||||
}
|
||||
got, err := io.ReadAll(reader)
|
||||
_ = reader.Close()
|
||||
if err != nil || !bytes.Equal(got, pngBytes) || opened.ID != asset.ID {
|
||||
t.Fatalf("opened asset changed: bytes=%t asset=%#v err=%v", bytes.Equal(got, pngBytes), opened, err)
|
||||
}
|
||||
|
||||
replayStage, err := store.Stage(bytes.NewReader(pngBytes), core.PNGContentType)
|
||||
if err != nil {
|
||||
t.Fatalf("stage replay: %v", err)
|
||||
}
|
||||
replayedAsset, replayed, err := store.Commit(context.Background(), principal, metadata, replayStage)
|
||||
if err != nil || !replayed || replayedAsset.ID != asset.ID {
|
||||
t.Fatalf("replay = %#v, %t, %v", replayedAsset, replayed, err)
|
||||
}
|
||||
|
||||
conflictStage, err := store.Stage(bytes.NewReader(pngBytes), core.PNGContentType)
|
||||
if err != nil {
|
||||
t.Fatalf("stage conflict: %v", err)
|
||||
}
|
||||
conflicting := metadata
|
||||
conflicting.CapturedAt = conflicting.CapturedAt.Add(time.Second)
|
||||
if _, _, err := store.Commit(context.Background(), principal, conflicting, conflictStage); !errors.Is(err, core.ErrConflict) {
|
||||
t.Fatalf("conflicting replay error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConcurrentReplayCreatesOneAsset(t *testing.T) {
|
||||
database, store := newTestStore(t)
|
||||
insertAttemptFixture(t, database)
|
||||
pngBytes := makePNG(t, 3, 2)
|
||||
metadata := core.UploadMetadata{UploadKey: testUploadKey, TaskID: testTaskID, AttemptID: testAttemptID, Kind: core.KindSKUPanelGate1, PrivacyTier: core.PrivacyInternalRaw, SHA256: sha256Hex(pngBytes), CapturedAt: time.Date(2026, 8, 4, 1, 2, 3, 0, time.UTC)}
|
||||
staged := make([]core.StagedFile, 2)
|
||||
for index := range staged {
|
||||
var err error
|
||||
staged[index], err = store.Stage(bytes.NewReader(pngBytes), core.PNGContentType)
|
||||
if err != nil {
|
||||
t.Fatalf("Stage %d: %v", index, err)
|
||||
}
|
||||
}
|
||||
var wait sync.WaitGroup
|
||||
wait.Add(2)
|
||||
assets := make([]core.Asset, 2)
|
||||
replays := make([]bool, 2)
|
||||
errorsSeen := make([]error, 2)
|
||||
for index := range staged {
|
||||
go func(index int) {
|
||||
defer wait.Done()
|
||||
assets[index], replays[index], errorsSeen[index] = store.Commit(context.Background(), deviceauth.Principal{ID: testDeviceID}, metadata, staged[index])
|
||||
}(index)
|
||||
}
|
||||
wait.Wait()
|
||||
if errorsSeen[0] != nil || errorsSeen[1] != nil || assets[0].ID != assets[1].ID || replays[0] == replays[1] {
|
||||
t.Fatalf("concurrent commits assets=%#v replays=%#v errors=%#v", assets, replays, errorsSeen)
|
||||
}
|
||||
var count int
|
||||
if err := database.QueryRow("SELECT COUNT(*) FROM evidence_assets").Scan(&count); err != nil || count != 1 {
|
||||
t.Fatalf("asset count = %d, err %v", count, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlatformDirectorySync(t *testing.T) {
|
||||
if err := syncDirectory(t.TempDir()); err != nil {
|
||||
t.Fatalf("syncDirectory must either establish the durability boundary or fail closed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewStoreRetriesRootParentSyncWhenRootAlreadyExists(t *testing.T) {
|
||||
database, _ := newTestStore(t)
|
||||
parent := t.TempDir()
|
||||
root := filepath.Join(parent, "retry-root")
|
||||
injected := errors.New("injected root parent sync failure")
|
||||
if _, err := newStore(database, root, func(string) error { return injected }); !errors.Is(err, injected) {
|
||||
t.Fatalf("first newStore error = %v, want injected root sync failure", err)
|
||||
}
|
||||
if info, err := os.Stat(root); err != nil || !info.IsDir() {
|
||||
t.Fatalf("failed parent sync must leave root for retry: info=%v err=%v", info, err)
|
||||
}
|
||||
|
||||
var paths []string
|
||||
store, err := newStore(database, root, func(path string) error {
|
||||
paths = append(paths, path)
|
||||
return syncDirectory(path)
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("retry newStore: %v", err)
|
||||
}
|
||||
if store == nil || len(paths) == 0 || paths[0] != parent {
|
||||
t.Fatalf("retry sync paths = %#v, want root parent %q first", paths, parent)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCommitSyncsShardAndRenameBeforeDatabaseWrite(t *testing.T) {
|
||||
database, store := newTestStore(t)
|
||||
insertAttemptFixture(t, database)
|
||||
pngBytes := makePNG(t, 4, 3)
|
||||
hash := sha256Hex(pngBytes)
|
||||
metadata := testMetadata(hash)
|
||||
staged, err := store.Stage(bytes.NewReader(pngBytes), core.PNGContentType)
|
||||
if err != nil {
|
||||
t.Fatalf("Stage: %v", err)
|
||||
}
|
||||
finalPath, err := store.pathForKey(storageKey(hash))
|
||||
if err != nil {
|
||||
t.Fatalf("final path: %v", err)
|
||||
}
|
||||
finalDirectory := filepath.Dir(finalPath)
|
||||
var events []string
|
||||
store.syncDirectory = func(path string) error {
|
||||
var count int
|
||||
if err := database.QueryRow("SELECT COUNT(*) FROM evidence_assets").Scan(&count); err != nil {
|
||||
t.Fatalf("count evidence before directory sync: %v", err)
|
||||
}
|
||||
if count != 0 {
|
||||
t.Fatalf("database row became visible before directory sync: %d", count)
|
||||
}
|
||||
switch path {
|
||||
case store.root:
|
||||
events = append(events, "sync-root")
|
||||
if path != store.root {
|
||||
t.Fatalf("shard parent sync path = %q, want evidence root %q", path, store.root)
|
||||
}
|
||||
if info, err := os.Stat(finalDirectory); err != nil || !info.IsDir() {
|
||||
t.Fatalf("shard directory must exist before parent sync: info=%v err=%v", info, err)
|
||||
}
|
||||
if _, err := os.Stat(finalPath); !errors.Is(err, os.ErrNotExist) {
|
||||
t.Fatalf("final file exists before publication: %v", err)
|
||||
}
|
||||
case finalDirectory:
|
||||
events = append(events, "sync-shard")
|
||||
if info, err := os.Stat(finalPath); err != nil || !info.Mode().IsRegular() {
|
||||
t.Fatalf("renamed file must exist before shard sync: info=%v err=%v", info, err)
|
||||
}
|
||||
default:
|
||||
t.Fatalf("unexpected extra directory sync: %q", path)
|
||||
}
|
||||
return syncDirectory(path)
|
||||
}
|
||||
store.syncFile = func(file *os.File) error {
|
||||
if filepath.Dir(file.Name()) != finalDirectory || !strings.HasPrefix(filepath.Base(file.Name()), ".publish-") {
|
||||
t.Fatalf("publication temp is not inside shard: %q", file.Name())
|
||||
}
|
||||
events = append(events, "sync-file")
|
||||
return file.Sync()
|
||||
}
|
||||
store.renameFile = func(oldPath, newPath string) error {
|
||||
if filepath.Dir(oldPath) != filepath.Dir(newPath) || newPath != finalPath {
|
||||
t.Fatalf("rename is not same-directory publication: %q -> %q", oldPath, newPath)
|
||||
}
|
||||
events = append(events, "rename")
|
||||
return os.Rename(oldPath, newPath)
|
||||
}
|
||||
|
||||
if _, replayed, err := store.Commit(context.Background(), deviceauth.Principal{ID: testDeviceID}, metadata, staged); err != nil || replayed {
|
||||
t.Fatalf("Commit = replayed %t, err %v", replayed, err)
|
||||
}
|
||||
if got, want := strings.Join(events, ","), "sync-root,sync-root,sync-file,rename,sync-shard"; got != want {
|
||||
t.Fatalf("durability order = %q, want %q", got, want)
|
||||
}
|
||||
assertEvidenceCount(t, database, 1)
|
||||
assertNoPublishTemps(t, finalDirectory)
|
||||
}
|
||||
|
||||
func TestCommitDirectorySyncFailuresNeverWriteDatabase(t *testing.T) {
|
||||
for _, failAt := range []int{1, 2, 3} {
|
||||
t.Run(map[int]string{1: "new shard parent", 2: "unconditional shard parent", 3: "rename target"}[failAt], func(t *testing.T) {
|
||||
database, store := newTestStore(t)
|
||||
insertAttemptFixture(t, database)
|
||||
pngBytes := makePNG(t, 4, 3)
|
||||
hash := sha256Hex(pngBytes)
|
||||
staged, err := store.Stage(bytes.NewReader(pngBytes), core.PNGContentType)
|
||||
if err != nil {
|
||||
t.Fatalf("Stage: %v", err)
|
||||
}
|
||||
finalPath, err := store.pathForKey(storageKey(hash))
|
||||
if err != nil {
|
||||
t.Fatalf("final path: %v", err)
|
||||
}
|
||||
injected := errors.New("injected directory sync failure")
|
||||
calls := 0
|
||||
store.syncDirectory = func(path string) error {
|
||||
calls++
|
||||
if calls == failAt {
|
||||
return injected
|
||||
}
|
||||
return syncDirectory(path)
|
||||
}
|
||||
|
||||
if _, _, err := store.Commit(context.Background(), deviceauth.Principal{ID: testDeviceID}, testMetadata(hash), staged); !errors.Is(err, injected) {
|
||||
t.Fatalf("Commit error = %v, want injected sync failure", err)
|
||||
}
|
||||
if calls != failAt {
|
||||
t.Fatalf("sync calls = %d, want %d", calls, failAt)
|
||||
}
|
||||
assertEvidenceCount(t, database, 0)
|
||||
_, statErr := os.Stat(finalPath)
|
||||
if failAt < 3 && !errors.Is(statErr, os.ErrNotExist) {
|
||||
t.Fatalf("file exists before rename durability boundary: %v", statErr)
|
||||
}
|
||||
if failAt == 3 && statErr != nil {
|
||||
t.Fatalf("post-rename sync failure may leave an orphan file, stat error = %v", statErr)
|
||||
}
|
||||
assertNoPublishTemps(t, filepath.Dir(finalPath))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCommitRetriesShardParentSyncAfterPriorFailure(t *testing.T) {
|
||||
database, store := newTestStore(t)
|
||||
insertAttemptFixture(t, database)
|
||||
pngBytes := makePNG(t, 4, 3)
|
||||
hash := sha256Hex(pngBytes)
|
||||
finalPath, err := store.pathForKey(storageKey(hash))
|
||||
if err != nil {
|
||||
t.Fatalf("final path: %v", err)
|
||||
}
|
||||
firstStage, err := store.Stage(bytes.NewReader(pngBytes), core.PNGContentType)
|
||||
if err != nil {
|
||||
t.Fatalf("first Stage: %v", err)
|
||||
}
|
||||
injected := errors.New("injected first shard parent sync failure")
|
||||
store.syncDirectory = func(string) error { return injected }
|
||||
if _, _, err := store.Commit(context.Background(), deviceauth.Principal{ID: testDeviceID}, testMetadata(hash), firstStage); !errors.Is(err, injected) {
|
||||
t.Fatalf("first Commit error = %v", err)
|
||||
}
|
||||
if info, err := os.Stat(filepath.Dir(finalPath)); err != nil || !info.IsDir() {
|
||||
t.Fatalf("failed first sync must leave the created shard for retry: info=%v err=%v", info, err)
|
||||
}
|
||||
assertEvidenceCount(t, database, 0)
|
||||
|
||||
secondStage, err := store.Stage(bytes.NewReader(pngBytes), core.PNGContentType)
|
||||
if err != nil {
|
||||
t.Fatalf("second Stage: %v", err)
|
||||
}
|
||||
var paths []string
|
||||
store.syncDirectory = func(path string) error {
|
||||
paths = append(paths, path)
|
||||
return syncDirectory(path)
|
||||
}
|
||||
if _, replayed, err := store.Commit(context.Background(), deviceauth.Principal{ID: testDeviceID}, testMetadata(hash), secondStage); err != nil || replayed {
|
||||
t.Fatalf("retry Commit = replayed %t, err %v", replayed, err)
|
||||
}
|
||||
if len(paths) != 2 || paths[0] != store.root || paths[1] != filepath.Dir(finalPath) {
|
||||
t.Fatalf("retry sync paths = %#v, want root then shard", paths)
|
||||
}
|
||||
assertEvidenceCount(t, database, 1)
|
||||
assertNoPublishTemps(t, filepath.Dir(finalPath))
|
||||
}
|
||||
|
||||
func TestCommitPublicationFailuresCleanTempAndNeverWriteDatabase(t *testing.T) {
|
||||
for _, name := range []string{"file sync", "rename"} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
database, store := newTestStore(t)
|
||||
insertAttemptFixture(t, database)
|
||||
pngBytes := makePNG(t, 4, 3)
|
||||
hash := sha256Hex(pngBytes)
|
||||
staged, err := store.Stage(bytes.NewReader(pngBytes), core.PNGContentType)
|
||||
if err != nil {
|
||||
t.Fatalf("Stage: %v", err)
|
||||
}
|
||||
finalPath, err := store.pathForKey(storageKey(hash))
|
||||
if err != nil {
|
||||
t.Fatalf("final path: %v", err)
|
||||
}
|
||||
injected := errors.New("injected publication failure")
|
||||
if name == "file sync" {
|
||||
store.syncFile = func(*os.File) error { return injected }
|
||||
} else {
|
||||
store.renameFile = func(string, string) error { return injected }
|
||||
}
|
||||
if _, _, err := store.Commit(context.Background(), deviceauth.Principal{ID: testDeviceID}, testMetadata(hash), staged); !errors.Is(err, injected) {
|
||||
t.Fatalf("Commit error = %v, want injected publication failure", err)
|
||||
}
|
||||
if _, err := os.Stat(finalPath); !errors.Is(err, os.ErrNotExist) {
|
||||
t.Fatalf("final file exists after failed publication: %v", err)
|
||||
}
|
||||
assertNoPublishTemps(t, filepath.Dir(finalPath))
|
||||
assertEvidenceCount(t, database, 0)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCommitDatabaseFailuresAfterDurableRenameLeaveOnlyOrphan(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
inject func(*testing.T, *sql.DB, *Store, error)
|
||||
}{
|
||||
{
|
||||
name: "insert",
|
||||
inject: func(t *testing.T, database *sql.DB, _ *Store, _ error) {
|
||||
t.Helper()
|
||||
if _, err := database.Exec(`CREATE TRIGGER fail_evidence_insert BEFORE INSERT ON evidence_assets BEGIN SELECT RAISE(ABORT, 'injected insert failure'); END`); err != nil {
|
||||
t.Fatalf("create insert failure trigger: %v", err)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "commit",
|
||||
inject: func(_ *testing.T, _ *sql.DB, store *Store, injected error) {
|
||||
store.commitTx = func(*sql.Tx) error { return injected }
|
||||
},
|
||||
},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
database, store := newTestStore(t)
|
||||
insertAttemptFixture(t, database)
|
||||
pngBytes := makePNG(t, 4, 3)
|
||||
hash := sha256Hex(pngBytes)
|
||||
staged, err := store.Stage(bytes.NewReader(pngBytes), core.PNGContentType)
|
||||
if err != nil {
|
||||
t.Fatalf("Stage: %v", err)
|
||||
}
|
||||
finalPath, err := store.pathForKey(storageKey(hash))
|
||||
if err != nil {
|
||||
t.Fatalf("final path: %v", err)
|
||||
}
|
||||
injected := errors.New("injected database failure")
|
||||
test.inject(t, database, store, injected)
|
||||
syncCalls := 0
|
||||
store.syncDirectory = func(path string) error {
|
||||
syncCalls++
|
||||
return syncDirectory(path)
|
||||
}
|
||||
|
||||
if _, _, err := store.Commit(context.Background(), deviceauth.Principal{ID: testDeviceID}, testMetadata(hash), staged); err == nil {
|
||||
t.Fatal("Commit unexpectedly succeeded")
|
||||
}
|
||||
if syncCalls != 3 {
|
||||
t.Fatalf("database failure occurred before both durability syncs: sync calls = %d", syncCalls)
|
||||
}
|
||||
if info, err := os.Stat(finalPath); err != nil || !info.Mode().IsRegular() {
|
||||
t.Fatalf("durable rename may leave only an orphan file: info=%v err=%v", info, err)
|
||||
}
|
||||
assertNoPublishTemps(t, filepath.Dir(finalPath))
|
||||
assertEvidenceCount(t, database, 0)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestStageRejectsUnsafeContent(t *testing.T) {
|
||||
_, store := newTestStore(t)
|
||||
largePNG := makePNG(t, core.MaxImageSide+1, 1)
|
||||
pngWithXML := append(makePNG(t, 1, 1), []byte("<hierarchy/>")...)
|
||||
for name, test := range map[string]struct {
|
||||
reader io.Reader
|
||||
contentType string
|
||||
}{
|
||||
"wrong content type": {reader: bytes.NewReader(makePNG(t, 1, 1)), contentType: "application/octet-stream"},
|
||||
"xml": {reader: bytes.NewBufferString("<hierarchy/>"), contentType: core.PNGContentType},
|
||||
"png with xml tail": {reader: bytes.NewReader(pngWithXML), contentType: core.PNGContentType},
|
||||
"truncated png": {reader: bytes.NewReader(pngSignature), contentType: core.PNGContentType},
|
||||
"too wide": {reader: bytes.NewReader(largePNG), contentType: core.PNGContentType},
|
||||
"too many bytes": {reader: io.LimitReader(zeroReader{}, core.MaxFileBytes+1), contentType: core.PNGContentType},
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
staged, err := store.Stage(test.reader, test.contentType)
|
||||
if !errors.Is(err, core.ErrInvalid) && !errors.Is(err, core.ErrTooLarge) {
|
||||
store.Discard(staged)
|
||||
t.Fatalf("Stage error = %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCommitRequiresAttemptOwnedByTaskAndLowercaseHash(t *testing.T) {
|
||||
database, store := newTestStore(t)
|
||||
insertAttemptFixture(t, database)
|
||||
pngBytes := makePNG(t, 2, 2)
|
||||
base := core.UploadMetadata{UploadKey: testUploadKey, TaskID: testTaskID, AttemptID: testAttemptID, Kind: core.KindSKUPanelGate1, PrivacyTier: core.PrivacyInternalRaw, SHA256: sha256Hex(pngBytes), CapturedAt: time.Date(2026, 8, 4, 1, 2, 3, 0, time.UTC)}
|
||||
for name, mutate := range map[string]func(*core.UploadMetadata){
|
||||
"unknown attempt": func(value *core.UploadMetadata) { value.AttemptID = "53c9f507-7473-4fa6-8d71-8786c34c6301" },
|
||||
"uppercase hash": func(value *core.UploadMetadata) {
|
||||
value.SHA256 = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"
|
||||
},
|
||||
"wrong kind": func(value *core.UploadMetadata) { value.Kind = "ORDER_CONFIRM" },
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
staged, err := store.Stage(bytes.NewReader(pngBytes), core.PNGContentType)
|
||||
if err != nil {
|
||||
t.Fatalf("Stage: %v", err)
|
||||
}
|
||||
metadata := base
|
||||
mutate(&metadata)
|
||||
if _, _, err := store.Commit(context.Background(), deviceauth.Principal{ID: testDeviceID}, metadata, staged); !errors.Is(err, core.ErrInvalid) {
|
||||
t.Fatalf("Commit error = %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
var count int
|
||||
if err := database.QueryRow("SELECT COUNT(*) FROM evidence_assets").Scan(&count); err != nil || count != 0 {
|
||||
t.Fatalf("invalid commits created %d assets, err %v", count, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewStoreRejectsRelativeAndFilesystemRootPaths(t *testing.T) {
|
||||
database, _ := newTestStore(t)
|
||||
if _, err := NewStore(database, "relative-evidence"); err == nil {
|
||||
t.Fatal("relative evidence root succeeded")
|
||||
}
|
||||
volumeRoot := filepath.VolumeName(t.TempDir()) + string(filepath.Separator)
|
||||
if _, err := NewStore(database, volumeRoot); err == nil {
|
||||
t.Fatal("filesystem root succeeded")
|
||||
}
|
||||
}
|
||||
|
||||
type zeroReader struct{}
|
||||
|
||||
func (zeroReader) Read(buffer []byte) (int, error) {
|
||||
for index := range buffer {
|
||||
buffer[index] = 0
|
||||
}
|
||||
return len(buffer), nil
|
||||
}
|
||||
|
||||
func newTestStore(t *testing.T) (*sql.DB, *Store) {
|
||||
t.Helper()
|
||||
database, err := sqlite.Open(filepath.Join(t.TempDir(), "evidence.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("open database: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = database.Close() })
|
||||
_, file, _, ok := runtime.Caller(0)
|
||||
if !ok {
|
||||
t.Fatal("locate test")
|
||||
}
|
||||
directory := filepath.Join(filepath.Dir(file), "..", "..", "..", "migrations")
|
||||
if err := migrations.Up(context.Background(), database, directory); err != nil {
|
||||
t.Fatalf("migrate database: %v", err)
|
||||
}
|
||||
store, err := NewStore(database, filepath.Join(t.TempDir(), "assets"))
|
||||
if err != nil {
|
||||
t.Fatalf("NewStore: %v", err)
|
||||
}
|
||||
return database, store
|
||||
}
|
||||
|
||||
func insertAttemptFixture(t *testing.T, database *sql.DB) {
|
||||
t.Helper()
|
||||
timestamp := "2026-08-04T00:00:00Z"
|
||||
if _, err := database.Exec(`INSERT INTO tasks (id, source, title, goods_id, sku_color, sku_size, quantity, max_total_price, status, version, created_at, updated_at) VALUES (?, 'MANUAL', 'task', '123', 'black', 'M', 1, '1.00', 'DRAFT', 1, ?, ?)`, testTaskID, timestamp, timestamp); err != nil {
|
||||
t.Fatalf("insert task: %v", err)
|
||||
}
|
||||
if _, err := database.Exec(`INSERT INTO order_authorizations (id, task_id, task_version, start_key, goods_id, sku_color, sku_size, quantity, total_price_cap, status, created_by, created_at, expires_at) VALUES (?, ?, 1, 'start', '123', 'black', 'M', 1, '1.00', 'ACTIVE', 'admin', ?, ?)`, testAuthID, testTaskID, timestamp, timestamp); err != nil {
|
||||
t.Fatalf("insert authorization: %v", err)
|
||||
}
|
||||
if _, err := database.Exec(`INSERT INTO purchase_attempts (id, task_id, authorization_id, claim_generation, status, started_at) VALUES (?, ?, ?, 1, 'CLAIMED', ?)`, testAttemptID, testTaskID, testAuthID, timestamp); err != nil {
|
||||
t.Fatalf("insert attempt: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func testMetadata(hash string) core.UploadMetadata {
|
||||
return core.UploadMetadata{
|
||||
UploadKey: testUploadKey, TaskID: testTaskID, AttemptID: testAttemptID,
|
||||
Kind: core.KindSKUPanelGate1, PrivacyTier: core.PrivacyInternalRaw, SHA256: hash,
|
||||
CapturedAt: time.Date(2026, 8, 4, 1, 2, 3, 0, time.UTC),
|
||||
}
|
||||
}
|
||||
|
||||
func assertEvidenceCount(t *testing.T, database *sql.DB, want int) {
|
||||
t.Helper()
|
||||
var count int
|
||||
if err := database.QueryRow("SELECT COUNT(*) FROM evidence_assets").Scan(&count); err != nil {
|
||||
t.Fatalf("count evidence assets: %v", err)
|
||||
}
|
||||
if count != want {
|
||||
t.Fatalf("evidence asset count = %d, want %d", count, want)
|
||||
}
|
||||
}
|
||||
|
||||
func assertNoPublishTemps(t *testing.T, directory string) {
|
||||
t.Helper()
|
||||
entries, err := os.ReadDir(directory)
|
||||
if err != nil {
|
||||
t.Fatalf("read shard directory: %v", err)
|
||||
}
|
||||
for _, entry := range entries {
|
||||
if strings.HasPrefix(entry.Name(), ".publish-") {
|
||||
t.Fatalf("publication temp leaked: %q", entry.Name())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func makePNG(t *testing.T, width, height int) []byte {
|
||||
t.Helper()
|
||||
imageData := image.NewNRGBA(image.Rect(0, 0, width, height))
|
||||
imageData.Set(0, 0, color.NRGBA{R: 12, G: 34, B: 56, A: 255})
|
||||
var buffer bytes.Buffer
|
||||
if err := png.Encode(&buffer, imageData); err != nil {
|
||||
t.Fatalf("encode PNG: %v", err)
|
||||
}
|
||||
return buffer.Bytes()
|
||||
}
|
||||
|
||||
func sha256Hex(value []byte) string {
|
||||
hash := sha256.Sum256(value)
|
||||
return hex.EncodeToString(hash[:])
|
||||
}
|
||||
@@ -3,11 +3,33 @@ package sqlite
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
|
||||
_ "github.com/mattn/go-sqlite3"
|
||||
"github.com/mattn/go-sqlite3"
|
||||
)
|
||||
|
||||
// Open 打开 SQLite 数据源;调用方负责其 schema 与生命周期。
|
||||
const driverName = "cmbuyer-sqlite3"
|
||||
|
||||
func init() {
|
||||
sql.Register(driverName, &sqlite3.SQLiteDriver{
|
||||
ConnectHook: func(connection *sqlite3.SQLiteConn) error {
|
||||
_, err := connection.Exec("PRAGMA foreign_keys = ON", nil)
|
||||
return err
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// Open 打开 SQLite 数据源并逐连接启用外键,避免连接池配置遗漏而绕过授权与任务的引用约束。
|
||||
func Open(dataSourceName string) (*sql.DB, error) {
|
||||
return sql.Open("sqlite3", dataSourceName)
|
||||
database, err := sql.Open(driverName, dataSourceName)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := database.Ping(); err != nil {
|
||||
_ = database.Close()
|
||||
return nil, fmt.Errorf("ping SQLite database: %w", err)
|
||||
}
|
||||
|
||||
return database, nil
|
||||
}
|
||||
|
||||
@@ -21,4 +21,15 @@ func TestOpen(t *testing.T) {
|
||||
if err := database.PingContext(context.Background()); err != nil {
|
||||
t.Fatalf("ping SQLite database: %v", err)
|
||||
}
|
||||
|
||||
database.SetMaxIdleConns(0)
|
||||
for attempt := 0; attempt < 2; attempt++ {
|
||||
var foreignKeysEnabled int
|
||||
if err := database.QueryRow("PRAGMA foreign_keys").Scan(&foreignKeysEnabled); err != nil {
|
||||
t.Fatalf("read SQLite foreign key setting: %v", err)
|
||||
}
|
||||
if foreignKeysEnabled != 1 {
|
||||
t.Fatalf("SQLite foreign_keys = %d, want 1", foreignKeysEnabled)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
// Package taskdetail provides a read-only audit projection for one task.
|
||||
package taskdetail
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"time"
|
||||
)
|
||||
|
||||
var ErrNotFound = errors.New("task detail not found")
|
||||
|
||||
type Store interface {
|
||||
Get(context.Context, string) (Detail, error)
|
||||
}
|
||||
|
||||
type Detail struct {
|
||||
Task Task
|
||||
Authorizations []Authorization
|
||||
Attempts []Attempt
|
||||
Submissions []Submission
|
||||
Evidence []Evidence
|
||||
}
|
||||
|
||||
type Task struct {
|
||||
ID, Source, Title, GoodsID, SKUColor, SKUSize, MaxTotalPrice, Status string
|
||||
Quantity, Version int
|
||||
CreatedAt, UpdatedAt time.Time
|
||||
}
|
||||
|
||||
type Authorization struct {
|
||||
ID, Status, CreatedBy, TotalPriceCap string
|
||||
TaskVersion int
|
||||
CreatedAt, ExpiresAt time.Time
|
||||
}
|
||||
|
||||
type Attempt struct {
|
||||
ID, AuthorizationID, Status string
|
||||
ClaimGeneration int
|
||||
Gate1UnitPrice *string
|
||||
Gate2UnitPrice *string
|
||||
QuantityRead *int
|
||||
ConfirmAmount *string
|
||||
FailureCode *string
|
||||
StartedAt time.Time
|
||||
FinishedAt *time.Time
|
||||
}
|
||||
|
||||
type Submission struct {
|
||||
ID, AuthorizationID, AttemptID, Status string
|
||||
Gate1UnitPrice, Gate2UnitPrice, ConfirmAmount string
|
||||
QuantityRead int
|
||||
CreatedAt time.Time
|
||||
ResolvedAt *time.Time
|
||||
}
|
||||
|
||||
type Evidence struct {
|
||||
ID, AttemptID, Kind, PrivacyTier, SHA256, ContentType string
|
||||
ByteSize, Width, Height int64
|
||||
CapturedAt time.Time
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
package taskdetail
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
type SQLiteStore struct{ database *sql.DB }
|
||||
|
||||
func NewSQLiteStore(database *sql.DB) (*SQLiteStore, error) {
|
||||
if database == nil {
|
||||
return nil, errors.New("task detail database is required")
|
||||
}
|
||||
if _, err := database.Exec("SELECT storage_key FROM evidence_assets LIMIT 1"); err != nil {
|
||||
return nil, fmt.Errorf("task detail migration is not available: %w", err)
|
||||
}
|
||||
return &SQLiteStore{database: database}, nil
|
||||
}
|
||||
|
||||
func (store *SQLiteStore) Get(ctx context.Context, id string) (Detail, error) {
|
||||
if !validUUID(id) {
|
||||
return Detail{}, ErrNotFound
|
||||
}
|
||||
tx, err := store.database.BeginTx(ctx, &sql.TxOptions{ReadOnly: true})
|
||||
if err != nil {
|
||||
return Detail{}, err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
var detail Detail
|
||||
var created, updated string
|
||||
err = tx.QueryRowContext(ctx, `SELECT id, source, title, goods_id, sku_color, sku_size, quantity, max_total_price, status, version, created_at, updated_at FROM tasks WHERE id = ?`, id).Scan(
|
||||
&detail.Task.ID, &detail.Task.Source, &detail.Task.Title, &detail.Task.GoodsID, &detail.Task.SKUColor, &detail.Task.SKUSize,
|
||||
&detail.Task.Quantity, &detail.Task.MaxTotalPrice, &detail.Task.Status, &detail.Task.Version, &created, &updated,
|
||||
)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return Detail{}, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return Detail{}, err
|
||||
}
|
||||
if detail.Task.CreatedAt, err = parseTime(created); err != nil {
|
||||
return Detail{}, err
|
||||
}
|
||||
if detail.Task.UpdatedAt, err = parseTime(updated); err != nil {
|
||||
return Detail{}, err
|
||||
}
|
||||
if detail.Authorizations, err = readAuthorizations(ctx, tx, id); err != nil {
|
||||
return Detail{}, err
|
||||
}
|
||||
if detail.Attempts, err = readAttempts(ctx, tx, id); err != nil {
|
||||
return Detail{}, err
|
||||
}
|
||||
if detail.Submissions, err = readSubmissions(ctx, tx, id); err != nil {
|
||||
return Detail{}, err
|
||||
}
|
||||
if detail.Evidence, err = readEvidence(ctx, tx, id); err != nil {
|
||||
return Detail{}, err
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return Detail{}, err
|
||||
}
|
||||
return detail, nil
|
||||
}
|
||||
|
||||
func readAuthorizations(ctx context.Context, tx *sql.Tx, taskID string) ([]Authorization, error) {
|
||||
rows, err := tx.QueryContext(ctx, `SELECT id, task_version, total_price_cap, status, created_by, created_at, expires_at FROM order_authorizations WHERE task_id = ? ORDER BY created_at DESC, id DESC`, taskID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
result := []Authorization{}
|
||||
for rows.Next() {
|
||||
var item Authorization
|
||||
var created, expires string
|
||||
if err := rows.Scan(&item.ID, &item.TaskVersion, &item.TotalPriceCap, &item.Status, &item.CreatedBy, &created, &expires); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if item.CreatedAt, err = parseTime(created); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if item.ExpiresAt, err = parseTime(expires); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result = append(result, item)
|
||||
}
|
||||
return result, rows.Err()
|
||||
}
|
||||
|
||||
func readAttempts(ctx context.Context, tx *sql.Tx, taskID string) ([]Attempt, error) {
|
||||
rows, err := tx.QueryContext(ctx, `SELECT id, authorization_id, claim_generation, status, gate1_unit_price, gate2_unit_price, quantity_read, confirm_amount, failure_code, started_at, finished_at FROM purchase_attempts WHERE task_id = ? ORDER BY started_at DESC, id DESC`, taskID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
result := []Attempt{}
|
||||
for rows.Next() {
|
||||
var item Attempt
|
||||
var gate1, gate2, confirm, failure, finished sql.NullString
|
||||
var quantity sql.NullInt64
|
||||
var started string
|
||||
if err := rows.Scan(&item.ID, &item.AuthorizationID, &item.ClaimGeneration, &item.Status, &gate1, &gate2, &quantity, &confirm, &failure, &started, &finished); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
item.Gate1UnitPrice, item.Gate2UnitPrice, item.ConfirmAmount, item.FailureCode = stringPointer(gate1), stringPointer(gate2), stringPointer(confirm), stringPointer(failure)
|
||||
if quantity.Valid {
|
||||
value := int(quantity.Int64)
|
||||
item.QuantityRead = &value
|
||||
}
|
||||
if item.StartedAt, err = parseTime(started); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if finished.Valid {
|
||||
value, parseErr := parseTime(finished.String)
|
||||
if parseErr != nil {
|
||||
return nil, parseErr
|
||||
}
|
||||
item.FinishedAt = &value
|
||||
}
|
||||
result = append(result, item)
|
||||
}
|
||||
return result, rows.Err()
|
||||
}
|
||||
|
||||
func readSubmissions(ctx context.Context, tx *sql.Tx, taskID string) ([]Submission, error) {
|
||||
rows, err := tx.QueryContext(ctx, `SELECT id, authorization_id, attempt_id, status, gate1_unit_price, gate2_unit_price, quantity_read, confirm_amount, created_at, resolved_at FROM order_submissions WHERE task_id = ? ORDER BY created_at DESC, id DESC`, taskID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
result := []Submission{}
|
||||
for rows.Next() {
|
||||
var item Submission
|
||||
var created string
|
||||
var resolved sql.NullString
|
||||
if err := rows.Scan(&item.ID, &item.AuthorizationID, &item.AttemptID, &item.Status, &item.Gate1UnitPrice, &item.Gate2UnitPrice, &item.QuantityRead, &item.ConfirmAmount, &created, &resolved); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if item.CreatedAt, err = parseTime(created); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if resolved.Valid {
|
||||
value, parseErr := parseTime(resolved.String)
|
||||
if parseErr != nil {
|
||||
return nil, parseErr
|
||||
}
|
||||
item.ResolvedAt = &value
|
||||
}
|
||||
result = append(result, item)
|
||||
}
|
||||
return result, rows.Err()
|
||||
}
|
||||
|
||||
func readEvidence(ctx context.Context, tx *sql.Tx, taskID string) ([]Evidence, error) {
|
||||
rows, err := tx.QueryContext(ctx, `SELECT id, attempt_id, kind, privacy_tier, sha256, byte_size, content_type, width_px, height_px, captured_at FROM evidence_assets WHERE task_id = ? ORDER BY captured_at, created_at, id`, taskID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
result := []Evidence{}
|
||||
for rows.Next() {
|
||||
var item Evidence
|
||||
var captured string
|
||||
if err := rows.Scan(&item.ID, &item.AttemptID, &item.Kind, &item.PrivacyTier, &item.SHA256, &item.ByteSize, &item.ContentType, &item.Width, &item.Height, &captured); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if item.CapturedAt, err = parseTime(captured); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result = append(result, item)
|
||||
}
|
||||
return result, rows.Err()
|
||||
}
|
||||
|
||||
func parseTime(value string) (time.Time, error) { return time.Parse(time.RFC3339Nano, value) }
|
||||
|
||||
func stringPointer(value sql.NullString) *string {
|
||||
if !value.Valid {
|
||||
return nil
|
||||
}
|
||||
copy := value.String
|
||||
return ©
|
||||
}
|
||||
|
||||
func validUUID(value string) bool {
|
||||
if len(value) != 36 {
|
||||
return false
|
||||
}
|
||||
for index, character := range value {
|
||||
if index == 8 || index == 13 || index == 18 || index == 23 {
|
||||
if character != '-' {
|
||||
return false
|
||||
}
|
||||
continue
|
||||
}
|
||||
if !(character >= '0' && character <= '9' || character >= 'a' && character <= 'f') {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return value[14] == '4' && (value[19] == '8' || value[19] == '9' || value[19] == 'a' || value[19] == 'b')
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
package taskdetail
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"cmbuyer/admin/internal/migrations"
|
||||
"cmbuyer/admin/internal/storage/sqlite"
|
||||
)
|
||||
|
||||
const (
|
||||
detailTask = "a3c9f507-7473-4fa6-8d71-8786c34c6301"
|
||||
detailAuth = "b3c9f507-7473-4fa6-8d71-8786c34c6301"
|
||||
detailTry = "c3c9f507-7473-4fa6-8d71-8786c34c6301"
|
||||
)
|
||||
|
||||
func TestSQLiteStoreReturnsOnlyPersistedAuditFacts(t *testing.T) {
|
||||
database := openDetailDatabase(t)
|
||||
timestamp := "2026-08-04T00:00:00Z"
|
||||
if _, err := database.Exec(`INSERT INTO tasks (id, source, title, goods_id, sku_color, sku_size, quantity, max_total_price, status, version, created_at, updated_at) VALUES (?, 'MANUAL', 'shirt', '123', 'black', 'M', 2, '30.00', 'CLAIMED', 3, ?, ?)`, detailTask, timestamp, timestamp); err != nil {
|
||||
t.Fatalf("insert task: %v", err)
|
||||
}
|
||||
if _, err := database.Exec(`INSERT INTO order_authorizations (id, task_id, task_version, start_key, goods_id, sku_color, sku_size, quantity, total_price_cap, status, created_by, created_at, expires_at) VALUES (?, ?, 2, 'start', '123', 'black', 'M', 2, '30.00', 'CLAIMED', 'admin', ?, ?)`, detailAuth, detailTask, timestamp, timestamp); err != nil {
|
||||
t.Fatalf("insert authorization: %v", err)
|
||||
}
|
||||
if _, err := database.Exec(`INSERT INTO purchase_attempts (id, task_id, authorization_id, claim_generation, status, started_at) VALUES (?, ?, ?, 1, 'CLAIMED', ?)`, detailTry, detailTask, detailAuth, timestamp); err != nil {
|
||||
t.Fatalf("insert attempt: %v", err)
|
||||
}
|
||||
hash := strings.Repeat("a", 64)
|
||||
if _, err := database.Exec(`INSERT INTO evidence_assets (id, upload_key, task_id, attempt_id, kind, privacy_tier, sha256, byte_size, content_type, width_px, height_px, storage_key, uploaded_by_device_id, captured_at, created_at) VALUES ('d3c9f507-7473-4fa6-8d71-8786c34c6301', 'upload', ?, ?, 'SKU_PANEL_GATE_1', 'INTERNAL_RAW', ?, 100, 'image/png', 10, 20, ?, 'device', ?, ?)`, detailTask, detailTry, hash, "aa/"+hash+".png", timestamp, timestamp); err != nil {
|
||||
t.Fatalf("insert evidence: %v", err)
|
||||
}
|
||||
store, err := NewSQLiteStore(database)
|
||||
if err != nil {
|
||||
t.Fatalf("NewSQLiteStore: %v", err)
|
||||
}
|
||||
detail, err := store.Get(context.Background(), detailTask)
|
||||
if err != nil {
|
||||
t.Fatalf("Get: %v", err)
|
||||
}
|
||||
if detail.Task.ID != detailTask || detail.Task.Status != "CLAIMED" || len(detail.Authorizations) != 1 || len(detail.Attempts) != 1 || len(detail.Evidence) != 1 || len(detail.Submissions) != 0 {
|
||||
t.Fatalf("detail = %#v", detail)
|
||||
}
|
||||
if detail.Attempts[0].Gate1UnitPrice != nil || detail.Attempts[0].FailureCode != nil {
|
||||
t.Fatalf("missing attempt facts were fabricated: %#v", detail.Attempts[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestSQLiteStoreFailsClosedForMalformedAndMissingIDs(t *testing.T) {
|
||||
database := openDetailDatabase(t)
|
||||
store, err := NewSQLiteStore(database)
|
||||
if err != nil {
|
||||
t.Fatalf("NewSQLiteStore: %v", err)
|
||||
}
|
||||
for _, id := range []string{"../database", "not-a-uuid", "a3c9f507-7473-1fa6-8d71-8786c34c6301"} {
|
||||
if _, err := store.Get(context.Background(), id); !errors.Is(err, ErrNotFound) {
|
||||
t.Fatalf("Get(%q) error = %v", id, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func openDetailDatabase(t *testing.T) *sql.DB {
|
||||
t.Helper()
|
||||
database, err := sqlite.Open(filepath.Join(t.TempDir(), "details.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("open database: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = database.Close() })
|
||||
_, file, _, ok := runtime.Caller(0)
|
||||
if !ok {
|
||||
t.Fatal("locate migration directory")
|
||||
}
|
||||
if err := migrations.Up(context.Background(), database, filepath.Join(filepath.Dir(file), "..", "..", "migrations")); err != nil {
|
||||
t.Fatalf("migrate database: %v", err)
|
||||
}
|
||||
return database
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
package tasks
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"math"
|
||||
"math/big"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
_ "time/tzdata"
|
||||
)
|
||||
|
||||
const maxStartItems = 100
|
||||
|
||||
var (
|
||||
ErrStartConflict = errors.New("purchase start conflicts with current task state")
|
||||
ErrInvalidStart = errors.New("invalid purchase start request")
|
||||
)
|
||||
|
||||
type StartPolicy struct {
|
||||
AuthorizationTTL time.Duration
|
||||
MaxQuantity int
|
||||
MaxTotalPrice string
|
||||
}
|
||||
type StartItem struct {
|
||||
TaskID string `json:"task_id"`
|
||||
ExpectedTaskVersion int `json:"expected_task_version"`
|
||||
}
|
||||
type StartCommand struct {
|
||||
StartKey string `json:"start_key"`
|
||||
Tasks []StartItem `json:"tasks"`
|
||||
}
|
||||
type AuthorizedTask struct {
|
||||
TaskID string `json:"task_id"`
|
||||
TaskVersion int `json:"task_version"`
|
||||
AuthorizationID string `json:"authorization_id"`
|
||||
ExpiresAt time.Time `json:"expires_at"`
|
||||
}
|
||||
type StartResult struct {
|
||||
StartKey string `json:"start_key"`
|
||||
AuthorizedCount int `json:"authorized_count"`
|
||||
Tasks []AuthorizedTask `json:"tasks"`
|
||||
PaymentAutomated bool `json:"payment_automated"`
|
||||
}
|
||||
type TaskFilter struct{ Keyword, Status, CreatedFrom, CreatedTo string }
|
||||
type TaskRow struct {
|
||||
ID, Title, GoodsID, SKUColor, SKUSize, MaxTotalPrice, Status string
|
||||
Quantity, Version int
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
func normalizeCents(value string) (string, *big.Int, bool) {
|
||||
if value == "" || strings.TrimSpace(value) != value {
|
||||
return "", nil, false
|
||||
}
|
||||
parts := strings.Split(value, ".")
|
||||
if len(parts) != 2 || len(parts[0]) == 0 || len(parts[1]) != 2 || (len(parts[0]) > 1 && parts[0][0] == '0') {
|
||||
return "", nil, false
|
||||
}
|
||||
for _, part := range parts {
|
||||
for _, ch := range part {
|
||||
if ch < '0' || ch > '9' {
|
||||
return "", nil, false
|
||||
}
|
||||
}
|
||||
}
|
||||
cents := new(big.Int)
|
||||
if _, ok := cents.SetString(parts[0]+parts[1], 10); !ok || cents.Sign() <= 0 {
|
||||
return "", nil, false
|
||||
}
|
||||
return value, cents, true
|
||||
}
|
||||
|
||||
func startItems(command StartCommand) ([]StartItem, error) {
|
||||
if !validUUID(command.StartKey) || len(command.Tasks) == 0 || len(command.Tasks) > maxStartItems {
|
||||
return nil, ErrInvalidStart
|
||||
}
|
||||
items := append([]StartItem(nil), command.Tasks...)
|
||||
sort.Slice(items, func(i, j int) bool { return items[i].TaskID < items[j].TaskID })
|
||||
for i, item := range items {
|
||||
if !validUUID(item.TaskID) || item.ExpectedTaskVersion <= 0 || item.ExpectedTaskVersion == math.MaxInt || (i > 0 && item.TaskID == items[i-1].TaskID) {
|
||||
return nil, ErrInvalidStart
|
||||
}
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func validTaskStatus(value string) bool {
|
||||
if value == "" {
|
||||
return true
|
||||
}
|
||||
for _, status := range []string{"DRAFT", "PENDING", "CLAIMED", "ORDERING", "NEEDS_MANUAL", "WAITING_PAYMENT", "RECONCILIATION_REQUIRED", "SUCCEEDED", "FAILED", "CANCELED"} {
|
||||
if value == status {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func ShanghaiRange(from, to string) (time.Time, time.Time, error) {
|
||||
if from == "" && to == "" {
|
||||
return time.Time{}, time.Time{}, nil
|
||||
}
|
||||
location, err := time.LoadLocation("Asia/Shanghai")
|
||||
if err != nil {
|
||||
return time.Time{}, time.Time{}, err
|
||||
}
|
||||
parse := func(value string) (time.Time, error) { return time.ParseInLocation("2006-01-02", value, location) }
|
||||
var start, end time.Time
|
||||
if from != "" {
|
||||
start, err = parse(from)
|
||||
if err != nil {
|
||||
return time.Time{}, time.Time{}, ErrInvalidStart
|
||||
}
|
||||
start = start.UTC()
|
||||
}
|
||||
if to != "" {
|
||||
end, err = parse(to)
|
||||
if err != nil {
|
||||
return time.Time{}, time.Time{}, ErrInvalidStart
|
||||
}
|
||||
end = end.AddDate(0, 0, 1).UTC()
|
||||
}
|
||||
if !start.IsZero() && !end.IsZero() && !start.Before(end) {
|
||||
return time.Time{}, time.Time{}, ErrInvalidStart
|
||||
}
|
||||
return start, end, nil
|
||||
}
|
||||
@@ -0,0 +1,424 @@
|
||||
package tasks
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math"
|
||||
"reflect"
|
||||
"sort"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"cmbuyer/admin/internal/migrations"
|
||||
)
|
||||
|
||||
var fixedStartTime = time.Date(2026, 8, 4, 9, 2, 3, 456000000, time.FixedZone("UTC+8", 8*60*60))
|
||||
|
||||
func TestStartPurchasesPersistsCompleteSnapshotsForOneAndHundredTasks(t *testing.T) {
|
||||
for _, count := range []int{1, 100} {
|
||||
t.Run(fmt.Sprintf("%d tasks", count), func(t *testing.T) {
|
||||
database := migratedDatabase(t)
|
||||
store := configuredStartStore(t, database)
|
||||
store.now = func() time.Time { return fixedStartTime }
|
||||
items := make([]StartItem, 0, count)
|
||||
wantDrafts := make(map[string]Draft, count)
|
||||
for index := 1; index <= count; index++ {
|
||||
id := startTestUUID(index)
|
||||
draft := Draft{
|
||||
ID: id,
|
||||
Title: fmt.Sprintf("task-%03d", index),
|
||||
GoodsID: fmt.Sprintf("937122%06d", index),
|
||||
SKUColor: fmt.Sprintf("color-%03d", index),
|
||||
SKUSize: fmt.Sprintf("size-%03d", index),
|
||||
Quantity: index%10 + 1,
|
||||
MaxTotalPrice: fmt.Sprintf("%d.%02d", index+10, index%100),
|
||||
}
|
||||
if _, err := store.CreateDraft(context.Background(), draft); err != nil {
|
||||
t.Fatalf("create draft %d: %v", index, err)
|
||||
}
|
||||
items = append(items, StartItem{TaskID: id, ExpectedTaskVersion: 1})
|
||||
wantDrafts[id] = draft
|
||||
}
|
||||
sort.Slice(items, func(i, j int) bool { return items[i].TaskID > items[j].TaskID })
|
||||
command := StartCommand{StartKey: startTestUUID(1001 + count), Tasks: items}
|
||||
|
||||
result, err := store.StartPurchases(context.Background(), command, "authenticated-admin")
|
||||
if err != nil {
|
||||
t.Fatalf("StartPurchases: %v", err)
|
||||
}
|
||||
if result.StartKey != command.StartKey || result.AuthorizedCount != count || result.PaymentAutomated || len(result.Tasks) != count {
|
||||
t.Fatalf("result = %#v", result)
|
||||
}
|
||||
wantCreated := fixedStartTime.UTC()
|
||||
wantExpires := wantCreated.Add(15 * time.Minute)
|
||||
seenAuthorizationIDs := map[string]bool{}
|
||||
for index, authorized := range result.Tasks {
|
||||
if index > 0 && result.Tasks[index-1].TaskID >= authorized.TaskID {
|
||||
t.Fatalf("result is not in canonical task order: %#v", result.Tasks)
|
||||
}
|
||||
if authorized.TaskVersion != 2 || !authorized.ExpiresAt.Equal(wantExpires) || !validUUID(authorized.AuthorizationID) || seenAuthorizationIDs[authorized.AuthorizationID] {
|
||||
t.Fatalf("authorized task = %#v", authorized)
|
||||
}
|
||||
seenAuthorizationIDs[authorized.AuthorizationID] = true
|
||||
want := wantDrafts[authorized.TaskID]
|
||||
var taskStatus, taskUpdated, authTaskID, authStartKey, goodsID, color, size, priceCap, authStatus, createdBy, createdAt, expiresAt string
|
||||
var taskVersion, authTaskVersion, quantity int
|
||||
err := database.QueryRow(`
|
||||
SELECT t.status,t.version,t.updated_at,
|
||||
a.task_id,a.task_version,a.start_key,a.goods_id,a.sku_color,a.sku_size,a.quantity,a.total_price_cap,a.status,a.created_by,a.created_at,a.expires_at
|
||||
FROM tasks t JOIN order_authorizations a ON a.task_id=t.id WHERE a.id=?`, authorized.AuthorizationID).
|
||||
Scan(&taskStatus, &taskVersion, &taskUpdated, &authTaskID, &authTaskVersion, &authStartKey, &goodsID, &color, &size, &quantity, &priceCap, &authStatus, &createdBy, &createdAt, &expiresAt)
|
||||
if err != nil {
|
||||
t.Fatalf("read authorization snapshot: %v", err)
|
||||
}
|
||||
if taskStatus != "PENDING" || taskVersion != 2 || taskUpdated != wantCreated.Format(time.RFC3339Nano) ||
|
||||
authTaskID != want.ID || authTaskVersion != 2 || authStartKey != command.StartKey ||
|
||||
goodsID != want.GoodsID || color != want.SKUColor || size != want.SKUSize || quantity != want.Quantity || priceCap != want.MaxTotalPrice ||
|
||||
authStatus != "ACTIVE" || createdBy != "authenticated-admin" || createdAt != wantCreated.Format(time.RFC3339Nano) || expiresAt != wantExpires.Format(time.RFC3339Nano) {
|
||||
t.Fatalf("stored task/authorization mismatch for %s", want.ID)
|
||||
}
|
||||
}
|
||||
var distinctCreated, distinctExpires int
|
||||
if err := database.QueryRow(`SELECT COUNT(DISTINCT created_at), COUNT(DISTINCT expires_at) FROM order_authorizations WHERE start_key=?`, command.StartKey).Scan(&distinctCreated, &distinctExpires); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if distinctCreated != 1 || distinctExpires != 1 {
|
||||
t.Fatalf("batch timestamps are not shared: created=%d expires=%d", distinctCreated, distinctExpires)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestStartPurchasesRejectsInvalidCommandsAndPolicyWithoutWrites(t *testing.T) {
|
||||
validItem := StartItem{TaskID: startTestUUID(1), ExpectedTaskVersion: 1}
|
||||
hundredOne := make([]StartItem, 101)
|
||||
for index := range hundredOne {
|
||||
hundredOne[index] = StartItem{TaskID: startTestUUID(index + 1), ExpectedTaskVersion: 1}
|
||||
}
|
||||
for name, command := range map[string]StartCommand{
|
||||
"invalid start key": {StartKey: "not-a-uuid", Tasks: []StartItem{validItem}},
|
||||
"empty tasks": {StartKey: startTestUUID(1001)},
|
||||
"over batch limit": {StartKey: startTestUUID(1001), Tasks: hundredOne},
|
||||
"invalid task id": {StartKey: startTestUUID(1001), Tasks: []StartItem{{TaskID: "1", ExpectedTaskVersion: 1}}},
|
||||
"duplicate task": {StartKey: startTestUUID(1001), Tasks: []StartItem{validItem, validItem}},
|
||||
"zero version": {StartKey: startTestUUID(1001), Tasks: []StartItem{{TaskID: validItem.TaskID}}},
|
||||
"overflow version": {StartKey: startTestUUID(1001), Tasks: []StartItem{{TaskID: validItem.TaskID, ExpectedTaskVersion: math.MaxInt}}},
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
database := migratedDatabase(t)
|
||||
store := configuredStartStore(t, database)
|
||||
_, err := store.StartPurchases(context.Background(), command, "admin")
|
||||
if !errors.Is(err, ErrInvalidStart) {
|
||||
t.Fatalf("error = %v, want ErrInvalidStart", err)
|
||||
}
|
||||
assertAuthorizationCount(t, database, 0)
|
||||
})
|
||||
}
|
||||
|
||||
for name, mutate := range map[string]func(*SQLiteStore){
|
||||
"zero ttl": func(store *SQLiteStore) { store.policy.AuthorizationTTL = 0 },
|
||||
"zero quantity": func(store *SQLiteStore) { store.policy.MaxQuantity = 0 },
|
||||
"bad max price": func(store *SQLiteStore) { store.policy.MaxTotalPrice = "999" },
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
database := migratedDatabase(t)
|
||||
store := configuredStartStore(t, database)
|
||||
createStartDraft(t, store, validItem.TaskID)
|
||||
mutate(store)
|
||||
_, err := store.StartPurchases(context.Background(), StartCommand{StartKey: startTestUUID(1001), Tasks: []StartItem{validItem}}, "admin")
|
||||
if !errors.Is(err, ErrInvalidStart) {
|
||||
t.Fatalf("error = %v, want ErrInvalidStart", err)
|
||||
}
|
||||
assertDraftUnchanged(t, database, validItem.TaskID)
|
||||
assertAuthorizationCount(t, database, 0)
|
||||
})
|
||||
}
|
||||
|
||||
database := migratedDatabase(t)
|
||||
store := configuredStartStore(t, database)
|
||||
createStartDraft(t, store, validItem.TaskID)
|
||||
_, err := store.StartPurchases(context.Background(), StartCommand{StartKey: startTestUUID(1001), Tasks: []StartItem{validItem}}, "")
|
||||
if !errors.Is(err, ErrInvalidStart) {
|
||||
t.Fatalf("empty created_by error = %v", err)
|
||||
}
|
||||
assertDraftUnchanged(t, database, validItem.TaskID)
|
||||
}
|
||||
|
||||
func TestStartPurchasesRejectsEveryTaskConflictWithoutAuthorization(t *testing.T) {
|
||||
for name, mutate := range map[string]func(*testing.T, *SQLiteStore, string, *StartItem){
|
||||
"missing": func(_ *testing.T, _ *SQLiteStore, _ string, item *StartItem) {
|
||||
item.TaskID = startTestUUID(99)
|
||||
},
|
||||
"not draft": func(t *testing.T, store *SQLiteStore, id string, _ *StartItem) {
|
||||
execTestSQL(t, store.database, `UPDATE tasks SET status='PENDING' WHERE id=?`, id)
|
||||
},
|
||||
"version mismatch": func(_ *testing.T, _ *SQLiteStore, _ string, item *StartItem) {
|
||||
item.ExpectedTaskVersion = 2
|
||||
},
|
||||
"empty goods id": func(t *testing.T, store *SQLiteStore, id string, _ *StartItem) {
|
||||
execTestSQL(t, store.database, `UPDATE tasks SET goods_id='' WHERE id=?`, id)
|
||||
},
|
||||
"nondigit goods id": func(t *testing.T, store *SQLiteStore, id string, _ *StartItem) {
|
||||
execTestSQL(t, store.database, `UPDATE tasks SET goods_id='937x' WHERE id=?`, id)
|
||||
},
|
||||
"empty color": func(t *testing.T, store *SQLiteStore, id string, _ *StartItem) {
|
||||
execTestSQL(t, store.database, `UPDATE tasks SET sku_color='' WHERE id=?`, id)
|
||||
},
|
||||
"empty size": func(t *testing.T, store *SQLiteStore, id string, _ *StartItem) {
|
||||
execTestSQL(t, store.database, `UPDATE tasks SET sku_size='' WHERE id=?`, id)
|
||||
},
|
||||
"quantity over policy": func(_ *testing.T, store *SQLiteStore, _ string, _ *StartItem) {
|
||||
store.policy.MaxQuantity = 1
|
||||
},
|
||||
"noncanonical price one decimal": func(t *testing.T, store *SQLiteStore, id string, _ *StartItem) {
|
||||
execTestSQL(t, store.database, `UPDATE tasks SET max_total_price='12.8' WHERE id=?`, id)
|
||||
},
|
||||
"noncanonical leading zero": func(t *testing.T, store *SQLiteStore, id string, _ *StartItem) {
|
||||
execTestSQL(t, store.database, `UPDATE tasks SET max_total_price='012.80' WHERE id=?`, id)
|
||||
},
|
||||
"price over policy": func(_ *testing.T, store *SQLiteStore, _ string, _ *StartItem) {
|
||||
store.policy.MaxTotalPrice = "12.79"
|
||||
},
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
database := migratedDatabase(t)
|
||||
store := configuredStartStore(t, database)
|
||||
id := startTestUUID(1)
|
||||
createStartDraft(t, store, id)
|
||||
item := StartItem{TaskID: id, ExpectedTaskVersion: 1}
|
||||
mutate(t, store, id, &item)
|
||||
_, err := store.StartPurchases(context.Background(), StartCommand{StartKey: startTestUUID(1001), Tasks: []StartItem{item}}, "admin")
|
||||
if !errors.Is(err, ErrStartConflict) {
|
||||
t.Fatalf("error = %v, want ErrStartConflict", err)
|
||||
}
|
||||
assertAuthorizationCount(t, database, 0)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestStartPurchasesRollsBackWholeBatchForLateConflictAndSQLFailure(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
breakBatch func(*testing.T, *SQLiteStore, string)
|
||||
}{
|
||||
{name: "late validation conflict", breakBatch: func(t *testing.T, store *SQLiteStore, secondID string) {
|
||||
execTestSQL(t, store.database, `UPDATE tasks SET sku_size='' WHERE id=?`, secondID)
|
||||
}},
|
||||
{name: "late SQL failure", breakBatch: func(t *testing.T, store *SQLiteStore, secondID string) {
|
||||
statement := fmt.Sprintf(`CREATE TRIGGER reject_second_authorization BEFORE INSERT ON order_authorizations WHEN NEW.task_id='%s' BEGIN SELECT RAISE(ABORT, 'test failure'); END`, secondID)
|
||||
execTestSQL(t, store.database, statement)
|
||||
}},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
database := migratedDatabase(t)
|
||||
store := configuredStartStore(t, database)
|
||||
firstID, secondID := startTestUUID(1), startTestUUID(2)
|
||||
createStartDraft(t, store, firstID)
|
||||
createStartDraft(t, store, secondID)
|
||||
test.breakBatch(t, store, secondID)
|
||||
_, err := store.StartPurchases(context.Background(), StartCommand{StartKey: startTestUUID(1001), Tasks: []StartItem{{TaskID: firstID, ExpectedTaskVersion: 1}, {TaskID: secondID, ExpectedTaskVersion: 1}}}, "admin")
|
||||
if err == nil {
|
||||
t.Fatal("StartPurchases unexpectedly succeeded")
|
||||
}
|
||||
assertDraftUnchanged(t, database, firstID)
|
||||
var secondStatus string
|
||||
var secondVersion int
|
||||
if err := database.QueryRow(`SELECT status,version FROM tasks WHERE id=?`, secondID).Scan(&secondStatus, &secondVersion); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if secondStatus != "DRAFT" || secondVersion != 1 {
|
||||
t.Fatalf("second task = %s/v%d, want DRAFT/v1", secondStatus, secondVersion)
|
||||
}
|
||||
assertAuthorizationCount(t, database, 0)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestStartPurchasesReplayIsStableAndRejectsDifferentOrIncompleteSets(t *testing.T) {
|
||||
database := migratedDatabase(t)
|
||||
store := configuredStartStore(t, database)
|
||||
firstID, secondID, thirdID := startTestUUID(1), startTestUUID(2), startTestUUID(3)
|
||||
for _, id := range []string{firstID, secondID, thirdID} {
|
||||
createStartDraft(t, store, id)
|
||||
}
|
||||
command := StartCommand{StartKey: startTestUUID(1001), Tasks: []StartItem{{TaskID: secondID, ExpectedTaskVersion: 1}, {TaskID: firstID, ExpectedTaskVersion: 1}}}
|
||||
first, err := store.StartPurchases(context.Background(), command, "admin")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
command.Tasks[0], command.Tasks[1] = command.Tasks[1], command.Tasks[0]
|
||||
replay, err := store.StartPurchases(context.Background(), command, "admin")
|
||||
if err != nil || !reflect.DeepEqual(replay, first) {
|
||||
t.Fatalf("replay = (%#v, %v), want %#v", replay, err, first)
|
||||
}
|
||||
assertAuthorizationCount(t, database, 2)
|
||||
|
||||
conflicting := []StartCommand{
|
||||
{StartKey: command.StartKey, Tasks: command.Tasks[:1]},
|
||||
{StartKey: command.StartKey, Tasks: []StartItem{{TaskID: firstID, ExpectedTaskVersion: 2}, {TaskID: secondID, ExpectedTaskVersion: 1}}},
|
||||
{StartKey: command.StartKey, Tasks: []StartItem{{TaskID: firstID, ExpectedTaskVersion: 1}, {TaskID: secondID, ExpectedTaskVersion: 1}, {TaskID: thirdID, ExpectedTaskVersion: 1}}},
|
||||
}
|
||||
for _, changed := range conflicting {
|
||||
if _, err := store.StartPurchases(context.Background(), changed, "admin"); !errors.Is(err, ErrStartConflict) {
|
||||
t.Fatalf("different payload error = %v", err)
|
||||
}
|
||||
}
|
||||
assertAuthorizationCount(t, database, 2)
|
||||
assertDraftUnchanged(t, database, thirdID)
|
||||
|
||||
execTestSQL(t, database, `DELETE FROM order_authorizations WHERE task_id=?`, secondID)
|
||||
if _, err := store.StartPurchases(context.Background(), command, "admin"); !errors.Is(err, ErrStartConflict) {
|
||||
t.Fatalf("incomplete replay error = %v", err)
|
||||
}
|
||||
assertAuthorizationCount(t, database, 1)
|
||||
}
|
||||
|
||||
func TestStartPurchasesConcurrentReplayAndVersionRace(t *testing.T) {
|
||||
t.Run("same key replays one stable result", func(t *testing.T) {
|
||||
database := migratedDatabase(t)
|
||||
store := configuredStartStore(t, database)
|
||||
id := startTestUUID(1)
|
||||
createStartDraft(t, store, id)
|
||||
command := StartCommand{StartKey: startTestUUID(1001), Tasks: []StartItem{{TaskID: id, ExpectedTaskVersion: 1}}}
|
||||
const callers = 16
|
||||
start := make(chan struct{})
|
||||
results := make(chan StartResult, callers)
|
||||
errorsChannel := make(chan error, callers)
|
||||
var group sync.WaitGroup
|
||||
for range callers {
|
||||
group.Add(1)
|
||||
go func() {
|
||||
defer group.Done()
|
||||
<-start
|
||||
result, err := store.StartPurchases(context.Background(), command, "admin")
|
||||
if err != nil {
|
||||
errorsChannel <- err
|
||||
return
|
||||
}
|
||||
results <- result
|
||||
}()
|
||||
}
|
||||
close(start)
|
||||
group.Wait()
|
||||
close(results)
|
||||
close(errorsChannel)
|
||||
for err := range errorsChannel {
|
||||
t.Fatalf("concurrent replay: %v", err)
|
||||
}
|
||||
var want StartResult
|
||||
for result := range results {
|
||||
if want.StartKey == "" {
|
||||
want = result
|
||||
} else if !reflect.DeepEqual(result, want) {
|
||||
t.Fatalf("unstable replay: %#v != %#v", result, want)
|
||||
}
|
||||
}
|
||||
assertAuthorizationCount(t, database, 1)
|
||||
var version int
|
||||
if err := database.QueryRow(`SELECT version FROM tasks WHERE id=?`, id).Scan(&version); err != nil || version != 2 {
|
||||
t.Fatalf("task version = %d, err=%v", version, err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("different keys race one expected version", func(t *testing.T) {
|
||||
database := migratedDatabase(t)
|
||||
store := configuredStartStore(t, database)
|
||||
id := startTestUUID(1)
|
||||
createStartDraft(t, store, id)
|
||||
start := make(chan struct{})
|
||||
errorsChannel := make(chan error, 2)
|
||||
var group sync.WaitGroup
|
||||
for _, key := range []string{startTestUUID(1001), startTestUUID(1002)} {
|
||||
group.Add(1)
|
||||
go func(startKey string) {
|
||||
defer group.Done()
|
||||
<-start
|
||||
_, err := store.StartPurchases(context.Background(), StartCommand{StartKey: startKey, Tasks: []StartItem{{TaskID: id, ExpectedTaskVersion: 1}}}, "admin")
|
||||
errorsChannel <- err
|
||||
}(key)
|
||||
}
|
||||
close(start)
|
||||
group.Wait()
|
||||
close(errorsChannel)
|
||||
successes, conflicts := 0, 0
|
||||
for err := range errorsChannel {
|
||||
switch {
|
||||
case err == nil:
|
||||
successes++
|
||||
case errors.Is(err, ErrStartConflict):
|
||||
conflicts++
|
||||
default:
|
||||
t.Fatalf("unexpected race error: %v", err)
|
||||
}
|
||||
}
|
||||
if successes != 1 || conflicts != 1 {
|
||||
t.Fatalf("success/conflict = %d/%d, want 1/1", successes, conflicts)
|
||||
}
|
||||
assertAuthorizationCount(t, database, 1)
|
||||
})
|
||||
}
|
||||
|
||||
func TestSQLiteStoreRejectsV1SchemaAtStartup(t *testing.T) {
|
||||
database := openDatabase(t)
|
||||
if err := migrations.Run(context.Background(), database, migrationDirectory(t), "up-by-one"); err != nil {
|
||||
t.Fatalf("migrate to v1: %v", err)
|
||||
}
|
||||
if _, err := NewSQLiteStore(database); err == nil {
|
||||
t.Fatal("NewSQLiteStore accepted the v1 two-pass schema")
|
||||
}
|
||||
}
|
||||
|
||||
func configuredStartStore(t *testing.T, database *sql.DB) *SQLiteStore {
|
||||
t.Helper()
|
||||
store, err := NewSQLiteStore(database)
|
||||
if err != nil {
|
||||
t.Fatalf("NewSQLiteStore: %v", err)
|
||||
}
|
||||
store.SetStartPolicy(StartPolicy{AuthorizationTTL: 15 * time.Minute, MaxQuantity: 10, MaxTotalPrice: "999.99"})
|
||||
return store
|
||||
}
|
||||
|
||||
func createStartDraft(t *testing.T, store *SQLiteStore, id string) {
|
||||
t.Helper()
|
||||
draft := Draft{ID: id, Title: "test", GoodsID: "937122477375", SKUColor: "黑色", SKUSize: "M", Quantity: 2, MaxTotalPrice: "12.80"}
|
||||
if _, err := store.CreateDraft(context.Background(), draft); err != nil {
|
||||
t.Fatalf("CreateDraft: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func startTestUUID(number int) string {
|
||||
return fmt.Sprintf("%08x-1234-4abc-a123-%012x", number, number)
|
||||
}
|
||||
|
||||
func assertAuthorizationCount(t *testing.T, database *sql.DB, want int) {
|
||||
t.Helper()
|
||||
var got int
|
||||
if err := database.QueryRow(`SELECT COUNT(*) FROM order_authorizations`).Scan(&got); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got != want {
|
||||
t.Fatalf("authorization count = %d, want %d", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func assertDraftUnchanged(t *testing.T, database *sql.DB, id string) {
|
||||
t.Helper()
|
||||
var status string
|
||||
var version int
|
||||
if err := database.QueryRow(`SELECT status,version FROM tasks WHERE id=?`, id).Scan(&status, &version); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if status != "DRAFT" || version != 1 {
|
||||
t.Fatalf("task %s = %s/v%d, want DRAFT/v1", id, status, version)
|
||||
}
|
||||
}
|
||||
|
||||
func execTestSQL(t *testing.T, database *sql.DB, statement string, arguments ...any) {
|
||||
t.Helper()
|
||||
if _, err := database.Exec(statement, arguments...); err != nil {
|
||||
t.Fatalf("execute test SQL: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
package tasks
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"cmbuyer/admin/internal/domain"
|
||||
)
|
||||
|
||||
// ErrInvalidFilter 表示任务筛选值无效,路由应按字段重新渲染而不是泄露内部错误。
|
||||
var ErrInvalidFilter = errors.New("invalid task filter")
|
||||
|
||||
// SetStartPolicy is called during startup; policy is explicit because authorization limits must not be implicit defaults.
|
||||
func (store *SQLiteStore) SetStartPolicy(policy StartPolicy) { store.policy = policy }
|
||||
|
||||
func (store *SQLiteStore) ListTasks(ctx context.Context, filter TaskFilter) ([]TaskRow, error) {
|
||||
if !ValidateTaskFilter(filter).Valid() {
|
||||
return nil, ErrInvalidFilter
|
||||
}
|
||||
from, to, err := ShanghaiRange(filter.CreatedFrom, filter.CreatedTo)
|
||||
if err != nil {
|
||||
return nil, ErrInvalidFilter
|
||||
}
|
||||
clauses, args := []string{"1=1"}, []any{}
|
||||
if filter.Status != "" {
|
||||
clauses = append(clauses, "status = ?")
|
||||
args = append(args, filter.Status)
|
||||
}
|
||||
if filter.Keyword != "" {
|
||||
escaped := strings.NewReplacer("\\", "\\\\", "%", "\\%", "_", "\\_").Replace(filter.Keyword)
|
||||
clauses = append(clauses, "(title LIKE ? ESCAPE '\\' OR goods_id LIKE ? ESCAPE '\\')")
|
||||
args = append(args, "%"+escaped+"%", "%"+escaped+"%")
|
||||
}
|
||||
if !from.IsZero() {
|
||||
clauses = append(clauses, "julianday(created_at) >= julianday(?)")
|
||||
args = append(args, from.Format(time.RFC3339Nano))
|
||||
}
|
||||
if !to.IsZero() {
|
||||
clauses = append(clauses, "julianday(created_at) < julianday(?)")
|
||||
args = append(args, to.Format(time.RFC3339Nano))
|
||||
}
|
||||
rows, err := store.database.QueryContext(ctx, "SELECT id,title,goods_id,sku_color,sku_size,quantity,max_total_price,status,version,created_at FROM tasks WHERE "+strings.Join(clauses, " AND ")+" ORDER BY julianday(created_at) DESC,rowid DESC", args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
result := []TaskRow{}
|
||||
for rows.Next() {
|
||||
var item TaskRow
|
||||
var created string
|
||||
if err := rows.Scan(&item.ID, &item.Title, &item.GoodsID, &item.SKUColor, &item.SKUSize, &item.Quantity, &item.MaxTotalPrice, &item.Status, &item.Version, &created); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
item.CreatedAt, err = time.Parse(time.RFC3339Nano, created)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result = append(result, item)
|
||||
}
|
||||
return result, rows.Err()
|
||||
}
|
||||
|
||||
// ValidateTaskFilter 返回可关联到字段的错误,使服务端页面拒绝篡改参数时仍能保留输入值。
|
||||
func ValidateTaskFilter(filter TaskFilter) Errors {
|
||||
validation := Errors{}
|
||||
if !validTaskStatus(filter.Status) {
|
||||
validation["status"] = "请选择有效的任务状态。"
|
||||
}
|
||||
location, err := time.LoadLocation("Asia/Shanghai")
|
||||
if err != nil {
|
||||
validation["created_from"] = "日期筛选暂不可用,请稍后重试。"
|
||||
validation["created_to"] = "日期筛选暂不可用,请稍后重试。"
|
||||
return validation
|
||||
}
|
||||
parseDate := func(field, value string) (time.Time, bool) {
|
||||
if value == "" {
|
||||
return time.Time{}, true
|
||||
}
|
||||
parsed, parseErr := time.ParseInLocation("2006-01-02", value, location)
|
||||
if parseErr != nil {
|
||||
validation[field] = "请输入有效日期。"
|
||||
return time.Time{}, false
|
||||
}
|
||||
return parsed, true
|
||||
}
|
||||
from, fromOK := parseDate("created_from", filter.CreatedFrom)
|
||||
to, toOK := parseDate("created_to", filter.CreatedTo)
|
||||
if fromOK && toOK && !from.IsZero() && !to.IsZero() && from.After(to) {
|
||||
validation["created_to"] = "结束日期不能早于开始日期。"
|
||||
}
|
||||
return validation
|
||||
}
|
||||
|
||||
func (store *SQLiteStore) StartPurchases(ctx context.Context, command StartCommand, createdBy string) (StartResult, error) {
|
||||
items, err := startItems(command)
|
||||
if err != nil || createdBy == "" {
|
||||
return StartResult{}, ErrInvalidStart
|
||||
}
|
||||
if store.policy.AuthorizationTTL <= 0 || store.policy.MaxQuantity <= 0 {
|
||||
return StartResult{}, ErrInvalidStart
|
||||
}
|
||||
_, ceiling, ok := normalizeCents(store.policy.MaxTotalPrice)
|
||||
if !ok {
|
||||
return StartResult{}, ErrInvalidStart
|
||||
}
|
||||
writeCtx, cancel := context.WithTimeout(ctx, sqliteWriteTimeout)
|
||||
defer cancel()
|
||||
select {
|
||||
case store.writeGate <- struct{}{}:
|
||||
defer func() { <-store.writeGate }()
|
||||
case <-writeCtx.Done():
|
||||
return StartResult{}, writeCtx.Err()
|
||||
}
|
||||
tx, err := store.database.BeginTx(writeCtx, nil)
|
||||
if err != nil {
|
||||
return StartResult{}, err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
// Replay precedes any DRAFT check. One service process serializes this check with creation; SQLite uniqueness remains the cross-transaction backstop.
|
||||
result, found, err := replayStart(writeCtx, tx, command.StartKey, items)
|
||||
if err != nil {
|
||||
return StartResult{}, err
|
||||
}
|
||||
if found {
|
||||
if err := tx.Commit(); err != nil {
|
||||
return StartResult{}, err
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
now := store.now().UTC()
|
||||
expires := now.Add(store.policy.AuthorizationTTL)
|
||||
result = StartResult{StartKey: command.StartKey, AuthorizedCount: len(items), Tasks: make([]AuthorizedTask, 0, len(items)), PaymentAutomated: false}
|
||||
for _, item := range items {
|
||||
var title, goods, color, size, price, status string
|
||||
var quantity, version int
|
||||
if err := tx.QueryRowContext(writeCtx, "SELECT title,goods_id,sku_color,sku_size,quantity,max_total_price,status,version FROM tasks WHERE id=?", item.TaskID).Scan(&title, &goods, &color, &size, &quantity, &price, &status, &version); err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
return StartResult{}, ErrStartConflict
|
||||
}
|
||||
return StartResult{}, err
|
||||
}
|
||||
if status != "DRAFT" || version != item.ExpectedTaskVersion || !goodsIDValid(goods) || color == "" || size == "" || quantity < 1 || quantity > store.policy.MaxQuantity {
|
||||
return StartResult{}, ErrStartConflict
|
||||
}
|
||||
canonical, cents, ok := normalizeCents(price)
|
||||
if !ok || canonical != price || cents.Cmp(ceiling) > 0 {
|
||||
return StartResult{}, ErrStartConflict
|
||||
}
|
||||
if _, err := domain.TransitionTask(domain.TaskStatusDraft, domain.TaskStatusPending); err != nil {
|
||||
return StartResult{}, err
|
||||
}
|
||||
id, err := NewCreateKey()
|
||||
if err != nil {
|
||||
return StartResult{}, err
|
||||
}
|
||||
next := version + 1
|
||||
if _, err = tx.ExecContext(writeCtx, "INSERT INTO order_authorizations (id,task_id,task_version,start_key,goods_id,sku_color,sku_size,quantity,total_price_cap,status,created_by,created_at,expires_at) VALUES (?,?,?,?,?,?,?,?,?,'ACTIVE',?,?,?)", id, item.TaskID, next, command.StartKey, goods, color, size, quantity, price, createdBy, now.Format(time.RFC3339Nano), expires.Format(time.RFC3339Nano)); err != nil {
|
||||
return StartResult{}, err
|
||||
}
|
||||
updated, err := tx.ExecContext(writeCtx, "UPDATE tasks SET status='PENDING',version=version+1,updated_at=? WHERE id=? AND status='DRAFT' AND version=?", now.Format(time.RFC3339Nano), item.TaskID, version)
|
||||
if err != nil {
|
||||
return StartResult{}, err
|
||||
}
|
||||
affected, err := updated.RowsAffected()
|
||||
if err != nil {
|
||||
return StartResult{}, err
|
||||
}
|
||||
if affected != 1 {
|
||||
return StartResult{}, ErrStartConflict
|
||||
}
|
||||
result.Tasks = append(result.Tasks, AuthorizedTask{TaskID: item.TaskID, TaskVersion: next, AuthorizationID: id, ExpiresAt: expires})
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return StartResult{}, err
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func goodsIDValid(value string) bool {
|
||||
if value == "" {
|
||||
return false
|
||||
}
|
||||
for _, ch := range value {
|
||||
if ch < '0' || ch > '9' {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func replayStart(ctx context.Context, tx *sql.Tx, startKey string, items []StartItem) (StartResult, bool, error) {
|
||||
rows, err := tx.QueryContext(ctx, "SELECT id,task_id,task_version,expires_at FROM order_authorizations WHERE start_key=? ORDER BY task_id", startKey)
|
||||
if err != nil {
|
||||
return StartResult{}, false, err
|
||||
}
|
||||
defer rows.Close()
|
||||
result := StartResult{StartKey: startKey, PaymentAutomated: false}
|
||||
for rows.Next() {
|
||||
var item AuthorizedTask
|
||||
var expires string
|
||||
if err := rows.Scan(&item.AuthorizationID, &item.TaskID, &item.TaskVersion, &expires); err != nil {
|
||||
return StartResult{}, false, err
|
||||
}
|
||||
item.ExpiresAt, err = time.Parse(time.RFC3339Nano, expires)
|
||||
if err != nil {
|
||||
return StartResult{}, false, err
|
||||
}
|
||||
result.Tasks = append(result.Tasks, item)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return StartResult{}, false, err
|
||||
}
|
||||
if len(result.Tasks) == 0 {
|
||||
return StartResult{}, false, nil
|
||||
}
|
||||
if len(result.Tasks) != len(items) {
|
||||
return StartResult{}, false, ErrStartConflict
|
||||
}
|
||||
for i := range items {
|
||||
if result.Tasks[i].TaskID != items[i].TaskID || result.Tasks[i].TaskVersion-1 != items[i].ExpectedTaskVersion {
|
||||
return StartResult{}, false, ErrStartConflict
|
||||
}
|
||||
}
|
||||
result.AuthorizedCount = len(result.Tasks)
|
||||
return result, true, nil
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
package tasks
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestListTasksTreatsLikeMetacharactersLiterally(t *testing.T) {
|
||||
database := migratedDatabase(t)
|
||||
store, err := NewSQLiteStore(database)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
created := "2026-08-04T01:00:00Z"
|
||||
insertTaskRow(t, database, "percent", "100%纯棉", "100", "DRAFT", created)
|
||||
insertTaskRow(t, database, "underscore", "尺码_A", "101", "DRAFT", created)
|
||||
insertTaskRow(t, database, "backslash", `路径\名称`, "102", "DRAFT", created)
|
||||
insertTaskRow(t, database, "plain", "普通商品", "103", "DRAFT", created)
|
||||
|
||||
for _, test := range []struct {
|
||||
keyword string
|
||||
wantID string
|
||||
}{
|
||||
{keyword: "%", wantID: "percent"},
|
||||
{keyword: "_", wantID: "underscore"},
|
||||
{keyword: `\`, wantID: "backslash"},
|
||||
} {
|
||||
t.Run(test.wantID, func(t *testing.T) {
|
||||
rows, err := store.ListTasks(context.Background(), TaskFilter{Keyword: test.keyword})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(rows) != 1 || rows[0].ID != test.wantID {
|
||||
t.Fatalf("keyword %q rows = %#v, want only %q", test.keyword, rows, test.wantID)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestListTasksSupportsEveryStatusAndEmptyMeansAll(t *testing.T) {
|
||||
database := migratedDatabase(t)
|
||||
store, err := NewSQLiteStore(database)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
statuses := []string{"DRAFT", "PENDING", "CLAIMED", "ORDERING", "NEEDS_MANUAL", "WAITING_PAYMENT", "RECONCILIATION_REQUIRED", "SUCCEEDED", "FAILED", "CANCELED"}
|
||||
for index, status := range statuses {
|
||||
insertTaskRow(t, database, status, status, "200", status, time.Date(2026, 8, 4, 1, 0, index, 0, time.UTC).Format(time.RFC3339Nano))
|
||||
}
|
||||
|
||||
all, err := store.ListTasks(context.Background(), TaskFilter{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(all) != len(statuses) {
|
||||
t.Fatalf("all-status rows = %d, want %d", len(all), len(statuses))
|
||||
}
|
||||
for _, status := range statuses {
|
||||
rows, err := store.ListTasks(context.Background(), TaskFilter{Status: status})
|
||||
if err != nil {
|
||||
t.Fatalf("status %s: %v", status, err)
|
||||
}
|
||||
if len(rows) != 1 || rows[0].Status != status {
|
||||
t.Fatalf("status %s rows = %#v", status, rows)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestListTasksUsesShanghaiHalfOpenDateRange(t *testing.T) {
|
||||
database := migratedDatabase(t)
|
||||
store, err := NewSQLiteStore(database)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
insertTaskRow(t, database, "before", "before", "300", "DRAFT", "2026-08-03T15:59:59Z")
|
||||
insertTaskRow(t, database, "at-start", "at-start", "301", "DRAFT", "2026-08-03T16:00:00Z")
|
||||
insertTaskRow(t, database, "before-end", "before-end", "302", "DRAFT", "2026-08-04T15:59:59Z")
|
||||
insertTaskRow(t, database, "at-end", "at-end", "303", "DRAFT", "2026-08-04T16:00:00Z")
|
||||
|
||||
rows, err := store.ListTasks(context.Background(), TaskFilter{CreatedFrom: "2026-08-04", CreatedTo: "2026-08-04"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(rows) != 2 || rows[0].ID != "before-end" || rows[1].ID != "at-start" {
|
||||
t.Fatalf("Shanghai day rows = %#v, want [before-end at-start]", rows)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListTasksBreaksEqualTimestampsByDescendingRowID(t *testing.T) {
|
||||
database := migratedDatabase(t)
|
||||
store, err := NewSQLiteStore(database)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
created := "2026-08-04T01:02:03Z"
|
||||
insertTaskRow(t, database, "first", "first", "400", "DRAFT", created)
|
||||
insertTaskRow(t, database, "second", "second", "401", "DRAFT", created)
|
||||
|
||||
rows, err := store.ListTasks(context.Background(), TaskFilter{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(rows) != 2 || rows[0].ID != "second" || rows[1].ID != "first" {
|
||||
t.Fatalf("equal-time rows = %#v, want descending rowid", rows)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListTasksRejectsInvalidStatusAndDates(t *testing.T) {
|
||||
store, err := NewSQLiteStore(migratedDatabase(t))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for name, filter := range map[string]TaskFilter{
|
||||
"status": {Status: "UNKNOWN"},
|
||||
"from date": {CreatedFrom: "2026-02-30"},
|
||||
"to date": {CreatedTo: "04/08/2026"},
|
||||
"reverse range": {CreatedFrom: "2026-08-05", CreatedTo: "2026-08-04"},
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
rows, err := store.ListTasks(context.Background(), filter)
|
||||
if !errors.Is(err, ErrInvalidFilter) || rows != nil {
|
||||
t.Fatalf("ListTasks(%#v) = (%#v, %v), want ErrInvalidFilter", filter, rows, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestStartPurchasesIsAtomicAndReplaysSameSet(t *testing.T) {
|
||||
database := migratedDatabase(t)
|
||||
store, err := NewSQLiteStore(database)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
store.SetStartPolicy(StartPolicy{AuthorizationTTL: time.Hour, MaxQuantity: 10, MaxTotalPrice: "999.99"})
|
||||
store.now = func() time.Time { return time.Date(2026, 8, 4, 1, 2, 3, 0, time.UTC) }
|
||||
for _, draft := range []Draft{testDraft(testKey, "one"), testDraft("b3c9f507-7473-4fa6-8d71-8786c34c6301", "two")} {
|
||||
if _, err := store.CreateDraft(context.Background(), draft); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
command := StartCommand{StartKey: "c3c9f507-7473-4fa6-8d71-8786c34c6301", Tasks: []StartItem{{TaskID: "b3c9f507-7473-4fa6-8d71-8786c34c6301", ExpectedTaskVersion: 1}, {TaskID: testKey, ExpectedTaskVersion: 1}}}
|
||||
first, err := store.StartPurchases(context.Background(), command, "admin")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if first.AuthorizedCount != 2 || first.PaymentAutomated {
|
||||
t.Fatalf("start result=%#v", first)
|
||||
}
|
||||
command.Tasks[0], command.Tasks[1] = command.Tasks[1], command.Tasks[0]
|
||||
replay, err := store.StartPurchases(context.Background(), command, "admin")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if replay.Tasks[0].AuthorizationID != first.Tasks[0].AuthorizationID || replay.Tasks[1].AuthorizationID != first.Tasks[1].AuthorizationID {
|
||||
t.Fatalf("replay=%#v first=%#v", replay, first)
|
||||
}
|
||||
var pending, auths int
|
||||
if err := database.QueryRow(`SELECT COUNT(*) FROM tasks WHERE status='PENDING' AND version=2`).Scan(&pending); err != nil || pending != 2 {
|
||||
t.Fatalf("pending=%d err=%v", pending, err)
|
||||
}
|
||||
if err := database.QueryRow(`SELECT COUNT(*) FROM order_authorizations WHERE status='ACTIVE' AND created_by='admin'`).Scan(&auths); err != nil || auths != 2 {
|
||||
t.Fatalf("auths=%d err=%v", auths, err)
|
||||
}
|
||||
_, err = store.StartPurchases(context.Background(), StartCommand{StartKey: command.StartKey, Tasks: command.Tasks[:1]}, "admin")
|
||||
if !errors.Is(err, ErrStartConflict) {
|
||||
t.Fatalf("subset err=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestShanghaiRangeAndMoneyAreFailClosed(t *testing.T) {
|
||||
start, end, err := ShanghaiRange("2026-08-04", "2026-08-04")
|
||||
if err != nil || start.Format(time.RFC3339) != "2026-08-03T16:00:00Z" || end.Format(time.RFC3339) != "2026-08-04T16:00:00Z" {
|
||||
t.Fatalf("range=(%s,%s,%v)", start, end, err)
|
||||
}
|
||||
for _, value := range []string{"0.01", "12.80", "999999999999999999999999.99"} {
|
||||
if _, _, ok := normalizeCents(value); !ok {
|
||||
t.Fatalf("money %q rejected", value)
|
||||
}
|
||||
}
|
||||
for _, value := range []string{"1", "01.20", "0.00", "1.234", "1.", " 1.00", "1e2"} {
|
||||
if _, _, ok := normalizeCents(value); ok {
|
||||
t.Fatalf("money %q accepted", value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func insertTaskRow(t *testing.T, database *sql.DB, id, title, goodsID, status, createdAt string) {
|
||||
t.Helper()
|
||||
if _, err := database.Exec(`INSERT INTO tasks (id, source, title, goods_id, sku_color, sku_size, quantity, max_total_price, status, version, created_at, updated_at) VALUES (?, 'MANUAL', ?, ?, '黑色', 'M', 2, '12.80', ?, 1, ?, ?)`, id, title, goodsID, status, createdAt, createdAt); err != nil {
|
||||
t.Fatalf("insert task %s: %v", id, err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
package tasks
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
const sqliteWriteTimeout = 2 * time.Second
|
||||
|
||||
type Store interface {
|
||||
CreateDraft(context.Context, Draft) (Draft, error)
|
||||
ListDrafts(context.Context) ([]Draft, error)
|
||||
ListTasks(context.Context, TaskFilter) ([]TaskRow, error)
|
||||
StartPurchases(context.Context, StartCommand, string) (StartResult, error)
|
||||
}
|
||||
type SQLiteStore struct {
|
||||
database *sql.DB
|
||||
now func() time.Time
|
||||
writeGate chan struct{}
|
||||
policy StartPolicy
|
||||
}
|
||||
|
||||
func NewSQLiteStore(database *sql.DB) (*SQLiteStore, error) {
|
||||
if database == nil {
|
||||
return nil, errors.New("database is required")
|
||||
}
|
||||
if _, err := database.Exec("SELECT task_version, start_key, total_price_cap FROM order_authorizations LIMIT 1"); err != nil {
|
||||
return nil, fmt.Errorf("tasks migration is not available: %w", err)
|
||||
}
|
||||
if _, err := database.Exec("SELECT 1 FROM purchase_attempts LIMIT 1"); err != nil {
|
||||
return nil, fmt.Errorf("single-pass migration is not available: %w", err)
|
||||
}
|
||||
return &SQLiteStore{database: database, now: time.Now, writeGate: make(chan struct{}, 1)}, nil
|
||||
}
|
||||
|
||||
func (store *SQLiteStore) CreateDraft(ctx context.Context, draft Draft) (Draft, error) {
|
||||
writeContext, cancel := context.WithTimeout(ctx, sqliteWriteTimeout)
|
||||
defer cancel()
|
||||
// SQLite permits one writer at a time. Serializing this store's short create
|
||||
// transaction prevents concurrent retries of one create key from surfacing as busy.
|
||||
select {
|
||||
case store.writeGate <- struct{}{}:
|
||||
defer func() { <-store.writeGate }()
|
||||
case <-writeContext.Done():
|
||||
return Draft{}, writeContext.Err()
|
||||
}
|
||||
draft.CreatedAt = store.now().UTC()
|
||||
transaction, err := store.database.BeginTx(writeContext, nil)
|
||||
if err != nil {
|
||||
return Draft{}, err
|
||||
}
|
||||
defer transaction.Rollback()
|
||||
_, err = transaction.ExecContext(writeContext, `INSERT INTO tasks (id, source, title, goods_id, sku_color, sku_size, quantity, max_total_price, status, version, created_at, updated_at) VALUES (?, 'MANUAL', ?, ?, ?, ?, ?, ?, 'DRAFT', 1, ?, ?)`, draft.ID, draft.Title, draft.GoodsID, draft.SKUColor, draft.SKUSize, draft.Quantity, draft.MaxTotalPrice, draft.CreatedAt.Format(time.RFC3339Nano), draft.CreatedAt.Format(time.RFC3339Nano))
|
||||
if err == nil {
|
||||
if err := transaction.Commit(); err != nil {
|
||||
return Draft{}, err
|
||||
}
|
||||
return draft, nil
|
||||
}
|
||||
existing, found, currentPhase, lookupErr := findDraft(writeContext, transaction, draft.ID)
|
||||
if lookupErr != nil {
|
||||
return Draft{}, lookupErr
|
||||
}
|
||||
if found && currentPhase && samePayload(existing, draft) {
|
||||
if err := transaction.Commit(); err != nil {
|
||||
return Draft{}, err
|
||||
}
|
||||
return existing, nil
|
||||
}
|
||||
if found {
|
||||
return Draft{}, ErrCreateKeyConflict
|
||||
}
|
||||
return Draft{}, err
|
||||
}
|
||||
|
||||
func (store *SQLiteStore) ListDrafts(ctx context.Context) ([]Draft, error) {
|
||||
// rowid makes equal timestamps deterministic: SQLite assigns it in insertion order,
|
||||
// whereas UUID v4 is deliberately not time-sortable.
|
||||
rows, err := store.database.QueryContext(ctx, `SELECT id, title, goods_id, sku_color, sku_size, quantity, max_total_price, created_at FROM tasks WHERE source = 'MANUAL' AND status = 'DRAFT' ORDER BY created_at DESC, rowid DESC`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
result := []Draft{}
|
||||
for rows.Next() {
|
||||
draft, err := scanDraft(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result = append(result, draft)
|
||||
}
|
||||
return result, rows.Err()
|
||||
}
|
||||
|
||||
func findDraft(ctx context.Context, transaction *sql.Tx, id string) (Draft, bool, bool, error) {
|
||||
row := transaction.QueryRowContext(ctx, `SELECT id, title, goods_id, sku_color, sku_size, quantity, max_total_price, created_at, source, status, version FROM tasks WHERE id = ?`, id)
|
||||
var draft Draft
|
||||
var created, source, status string
|
||||
var version int
|
||||
err := row.Scan(&draft.ID, &draft.Title, &draft.GoodsID, &draft.SKUColor, &draft.SKUSize, &draft.Quantity, &draft.MaxTotalPrice, &created, &source, &status, &version)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return Draft{}, false, false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return Draft{}, false, false, err
|
||||
}
|
||||
parsed, err := time.Parse(time.RFC3339Nano, created)
|
||||
if err != nil {
|
||||
return Draft{}, false, false, err
|
||||
}
|
||||
draft.CreatedAt = parsed
|
||||
return draft, true, source == "MANUAL" && status == "DRAFT" && version == 1, nil
|
||||
}
|
||||
|
||||
type scanner interface{ Scan(...any) error }
|
||||
|
||||
func scanDraft(row scanner) (Draft, error) {
|
||||
var draft Draft
|
||||
var created string
|
||||
if err := row.Scan(&draft.ID, &draft.Title, &draft.GoodsID, &draft.SKUColor, &draft.SKUSize, &draft.Quantity, &draft.MaxTotalPrice, &created); err != nil {
|
||||
return Draft{}, err
|
||||
}
|
||||
parsed, err := time.Parse(time.RFC3339Nano, created)
|
||||
if err != nil {
|
||||
return Draft{}, err
|
||||
}
|
||||
draft.CreatedAt = parsed
|
||||
return draft, nil
|
||||
}
|
||||
func samePayload(left, right Draft) bool {
|
||||
return left.ID == right.ID && left.Title == right.Title && left.GoodsID == right.GoodsID && left.SKUColor == right.SKUColor && left.SKUSize == right.SKUSize && left.Quantity == right.Quantity && left.MaxTotalPrice == right.MaxTotalPrice
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
// Package tasks 定义手工 DRAFT 任务的校验与窄仓储边界。
|
||||
package tasks
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
maxTitleLength = 120
|
||||
maxSKUText = 80
|
||||
)
|
||||
|
||||
var ErrCreateKeyConflict = errors.New("create key conflicts with a different task")
|
||||
|
||||
type Draft struct {
|
||||
ID string
|
||||
Title string
|
||||
GoodsID string
|
||||
SKUColor string
|
||||
SKUSize string
|
||||
Quantity int
|
||||
MaxTotalPrice string
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
type Form struct{ CreateKey, Title, ProductURL, SKUColor, SKUSize, Quantity, MaxTotalPrice string }
|
||||
type Errors map[string]string
|
||||
|
||||
func (errors Errors) Valid() bool { return len(errors) == 0 }
|
||||
|
||||
// Validate trims and normalizes a user form. It never reads a product page or derives price data.
|
||||
func Validate(form Form) (Draft, Errors) {
|
||||
draft := Draft{ID: strings.TrimSpace(form.CreateKey), Title: strings.TrimSpace(form.Title), SKUColor: strings.TrimSpace(form.SKUColor), SKUSize: strings.TrimSpace(form.SKUSize)}
|
||||
errors := Errors{}
|
||||
if !validUUID(draft.ID) {
|
||||
errors["create_key"] = "创建请求已过期,请重新打开表单。"
|
||||
}
|
||||
if draft.Title == "" || len([]rune(draft.Title)) > maxTitleLength {
|
||||
errors["title"] = "任务名称不能为空,且不能超过 120 个字符。"
|
||||
}
|
||||
if draft.SKUColor == "" || len([]rune(draft.SKUColor)) > maxSKUText {
|
||||
errors["sku_color"] = "颜色分类不能为空,且不能超过 80 个字符。"
|
||||
}
|
||||
if draft.SKUSize == "" || len([]rune(draft.SKUSize)) > maxSKUText {
|
||||
errors["sku_size"] = "尺码不能为空,且不能超过 80 个字符。"
|
||||
}
|
||||
goodsID, ok := CanonicalGoodsID(strings.TrimSpace(form.ProductURL))
|
||||
if !ok {
|
||||
errors["product_url"] = "请输入唯一的 canonical 商品链接。"
|
||||
} else {
|
||||
draft.GoodsID = goodsID
|
||||
}
|
||||
quantity, err := strconv.ParseInt(strings.TrimSpace(form.Quantity), 10, 0)
|
||||
if err != nil || quantity < 1 {
|
||||
errors["quantity"] = "数量必须是正整数。"
|
||||
} else {
|
||||
draft.Quantity = int(quantity)
|
||||
}
|
||||
money, ok := normalizeMoney(strings.TrimSpace(form.MaxTotalPrice))
|
||||
if !ok {
|
||||
errors["max_total_price"] = "价格上限必须大于零,且最多两位小数。"
|
||||
} else {
|
||||
draft.MaxTotalPrice = money
|
||||
}
|
||||
return draft, errors
|
||||
}
|
||||
|
||||
// CanonicalGoodsID only accepts the one verified manual-entry URL shape; untrusted query data is discarded.
|
||||
func CanonicalGoodsID(value string) (string, bool) {
|
||||
if value == "" || strings.Contains(value, "\\") || strings.Contains(value, "%") {
|
||||
return "", false
|
||||
}
|
||||
parsed, err := url.ParseRequestURI(value)
|
||||
if err != nil || parsed.Scheme != "https" || parsed.Host != "mobile.yangkeduo.com" || parsed.User != nil || parsed.Port() != "" || parsed.Path != "/goods.html" || parsed.Fragment != "" {
|
||||
return "", false
|
||||
}
|
||||
values, err := url.ParseQuery(parsed.RawQuery)
|
||||
if err != nil {
|
||||
return "", false
|
||||
}
|
||||
goodsIDs := values["goods_id"]
|
||||
if len(goodsIDs) != 1 || goodsIDs[0] == "" {
|
||||
return "", false
|
||||
}
|
||||
for _, character := range goodsIDs[0] {
|
||||
if character < '0' || character > '9' {
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
return goodsIDs[0], true
|
||||
}
|
||||
|
||||
func CanonicalURL(goodsID string) string {
|
||||
return "https://mobile.yangkeduo.com/goods.html?goods_id=" + goodsID
|
||||
}
|
||||
|
||||
func NewCreateKey() (string, error) {
|
||||
bytes := make([]byte, 16)
|
||||
if _, err := rand.Read(bytes); err != nil {
|
||||
return "", err
|
||||
}
|
||||
bytes[6] = (bytes[6] & 0x0f) | 0x40
|
||||
bytes[8] = (bytes[8] & 0x3f) | 0x80
|
||||
hexValue := hex.EncodeToString(bytes)
|
||||
return hexValue[0:8] + "-" + hexValue[8:12] + "-" + hexValue[12:16] + "-" + hexValue[16:20] + "-" + hexValue[20:32], nil
|
||||
}
|
||||
|
||||
func validUUID(value string) bool {
|
||||
if len(value) != 36 {
|
||||
return false
|
||||
}
|
||||
for index, character := range value {
|
||||
if index == 8 || index == 13 || index == 18 || index == 23 {
|
||||
if character != '-' {
|
||||
return false
|
||||
}
|
||||
continue
|
||||
}
|
||||
if !(character >= '0' && character <= '9' || character >= 'a' && character <= 'f') {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return value[14] == '4' && (value[19] == '8' || value[19] == '9' || value[19] == 'a' || value[19] == 'b')
|
||||
}
|
||||
|
||||
func normalizeMoney(value string) (string, bool) {
|
||||
parts := strings.Split(value, ".")
|
||||
if len(parts) > 2 || parts[0] == "" || len(parts) == 2 && (len(parts[1]) == 0 || len(parts[1]) > 2) {
|
||||
return "", false
|
||||
}
|
||||
for _, character := range parts[0] {
|
||||
if character < '0' || character > '9' {
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
fraction := ""
|
||||
if len(parts) == 2 {
|
||||
fraction = parts[1]
|
||||
for _, character := range fraction {
|
||||
if character < '0' || character > '9' {
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
}
|
||||
whole := strings.TrimLeft(parts[0], "0")
|
||||
if whole == "" {
|
||||
whole = "0"
|
||||
}
|
||||
if whole == "0" && strings.Trim(fraction, "0") == "" {
|
||||
return "", false
|
||||
}
|
||||
return whole + "." + (fraction + "00")[:2], true
|
||||
}
|
||||
@@ -0,0 +1,300 @@
|
||||
package tasks
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"runtime"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"cmbuyer/admin/internal/migrations"
|
||||
"cmbuyer/admin/internal/storage/sqlite"
|
||||
)
|
||||
|
||||
const testKey = "a3c9f507-7473-4fa6-8d71-8786c34c6301"
|
||||
|
||||
func TestValidateNormalizesManualDraft(t *testing.T) {
|
||||
draft, validation := Validate(Form{
|
||||
CreateKey: " " + testKey + " ",
|
||||
Title: " 夏季上衣 ",
|
||||
ProductURL: "https://mobile.yangkeduo.com/goods.html?goods_id=937122477375&utm_source=untrusted",
|
||||
SKUColor: " 黑色CHA(纯棉) ",
|
||||
SKUSize: " M(建议100-115) ",
|
||||
Quantity: "2",
|
||||
MaxTotalPrice: "00012.8",
|
||||
})
|
||||
if !validation.Valid() {
|
||||
t.Fatalf("Validate errors = %#v", validation)
|
||||
}
|
||||
if draft.ID != testKey || draft.GoodsID != "937122477375" || draft.Title != "夏季上衣" || draft.SKUColor != "黑色CHA(纯棉)" || draft.SKUSize != "M(建议100-115)" || draft.Quantity != 2 || draft.MaxTotalPrice != "12.80" {
|
||||
t.Fatalf("normalized draft = %#v", draft)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRejectsInvalidFieldsAndURLs(t *testing.T) {
|
||||
base := Form{CreateKey: testKey, Title: "title", ProductURL: "https://mobile.yangkeduo.com/goods.html?goods_id=1", SKUColor: "black", SKUSize: "M", Quantity: "1", MaxTotalPrice: "1"}
|
||||
for name, update := range map[string]func(*Form){
|
||||
"empty title": func(form *Form) { form.Title = " " },
|
||||
"long color": func(form *Form) { form.SKUColor = string(make([]rune, maxSKUText+1)) },
|
||||
"fraction quantity": func(form *Form) { form.Quantity = "1.5" },
|
||||
"zero quantity": func(form *Form) { form.Quantity = "0" },
|
||||
"too many decimals": func(form *Form) { form.MaxTotalPrice = "1.234" },
|
||||
"trailing decimal": func(form *Form) { form.MaxTotalPrice = "1." },
|
||||
"zero money": func(form *Form) { form.MaxTotalPrice = "0.00" },
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
form := base
|
||||
update(&form)
|
||||
if _, validation := Validate(form); validation.Valid() {
|
||||
t.Fatal("invalid form was accepted")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
for _, value := range []string{
|
||||
"http://mobile.yangkeduo.com/goods.html?goods_id=1",
|
||||
"https://yangkeduo.com/goods.html?goods_id=1",
|
||||
"https://mobile.yangkeduo.com:443/goods.html?goods_id=1",
|
||||
"https://user@mobile.yangkeduo.com/goods.html?goods_id=1",
|
||||
"https://mobile.yangkeduo.com/goods.html?goods_id=1#fragment",
|
||||
"https://mobile.yangkeduo.com/goods.html?goods_id=1&goods_id=2",
|
||||
"https://mobile.yangkeduo.com/goods.html?goods_id=one",
|
||||
"https://mobile.yangkeduo.com/goods.html?goods_id=%31",
|
||||
"https://mobile.yangkeduo.com/goods.html?goods_id=1%26goods_id%3D2",
|
||||
"https://mobile.yangkeduo.com/goods.html?goods_id=1;uin=bad",
|
||||
"https://mobile.yangkeduo.com/other.html?goods_id=1",
|
||||
} {
|
||||
if _, ok := CanonicalGoodsID(value); ok {
|
||||
t.Fatalf("CanonicalGoodsID accepted %q", value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeMoneyBoundaries(t *testing.T) {
|
||||
for value, want := range map[string]string{"1": "1.00", "1.2": "1.20", "000.01": "0.01", "999999999999999999": "999999999999999999.00"} {
|
||||
got, ok := normalizeMoney(value)
|
||||
if !ok || got != want {
|
||||
t.Fatalf("normalizeMoney(%q) = (%q, %t), want (%q, true)", value, got, ok, want)
|
||||
}
|
||||
}
|
||||
for _, value := range []string{"0", "0.0", "0.00", "1.", ".1", "1.000", "-1", "1e2", " 1"} {
|
||||
if got, ok := normalizeMoney(value); ok {
|
||||
t.Fatalf("normalizeMoney(%q) = %q, want rejection", value, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewCreateKeyIsUUIDv4(t *testing.T) {
|
||||
key, err := NewCreateKey()
|
||||
if err != nil {
|
||||
t.Fatalf("NewCreateKey: %v", err)
|
||||
}
|
||||
if !regexp.MustCompile(`^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$`).MatchString(key) {
|
||||
t.Fatalf("create key %q is not UUID v4", key)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSQLiteStoreRequiresMigratedDatabase(t *testing.T) {
|
||||
database := openDatabase(t)
|
||||
if _, err := NewSQLiteStore(database); err == nil {
|
||||
t.Fatal("NewSQLiteStore accepted an unmigrated database")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSQLiteStoreCreatesListsAndHandlesIdempotency(t *testing.T) {
|
||||
database := migratedDatabase(t)
|
||||
store, err := NewSQLiteStore(database)
|
||||
if err != nil {
|
||||
t.Fatalf("NewSQLiteStore: %v", err)
|
||||
}
|
||||
baseTime := time.Date(2026, 8, 4, 9, 0, 0, 0, time.UTC)
|
||||
call := 0
|
||||
store.now = func() time.Time {
|
||||
result := baseTime.Add(time.Duration(call) * time.Minute)
|
||||
call++
|
||||
return result
|
||||
}
|
||||
first := testDraft(testKey, "first")
|
||||
created, err := store.CreateDraft(context.Background(), first)
|
||||
if err != nil {
|
||||
t.Fatalf("create first draft: %v", err)
|
||||
}
|
||||
replayed, err := store.CreateDraft(context.Background(), first)
|
||||
if err != nil {
|
||||
t.Fatalf("replay first draft: %v", err)
|
||||
}
|
||||
if replayed.CreatedAt != created.CreatedAt {
|
||||
t.Fatalf("replayed CreatedAt = %s, want original %s", replayed.CreatedAt, created.CreatedAt)
|
||||
}
|
||||
second := testDraft("b3c9f507-7473-4fa6-8d71-8786c34c6301", "second")
|
||||
if _, err := store.CreateDraft(context.Background(), second); err != nil {
|
||||
t.Fatalf("create second draft: %v", err)
|
||||
}
|
||||
drafts, err := store.ListDrafts(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("list drafts: %v", err)
|
||||
}
|
||||
if len(drafts) != 2 || drafts[0].ID != second.ID || drafts[1].ID != first.ID {
|
||||
t.Fatalf("draft order = %#v, want second then first", drafts)
|
||||
}
|
||||
var source, status string
|
||||
var version int
|
||||
if err := database.QueryRow(`SELECT source, status, version FROM tasks WHERE id = ?`, first.ID).Scan(&source, &status, &version); err != nil {
|
||||
t.Fatalf("read stored task: %v", err)
|
||||
}
|
||||
if source != "MANUAL" || status != "DRAFT" || version != 1 {
|
||||
t.Fatalf("stored metadata = (%q, %q, %d)", source, status, version)
|
||||
}
|
||||
|
||||
conflicting := first
|
||||
conflicting.Title = "different"
|
||||
if _, err := store.CreateDraft(context.Background(), conflicting); !errors.Is(err, ErrCreateKeyConflict) {
|
||||
t.Fatalf("conflicting create error = %v, want ErrCreateKeyConflict", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSQLiteStoreRollsBackFailedCreate(t *testing.T) {
|
||||
database := migratedDatabase(t)
|
||||
store, err := NewSQLiteStore(database)
|
||||
if err != nil {
|
||||
t.Fatalf("NewSQLiteStore: %v", err)
|
||||
}
|
||||
if _, err := database.Exec(`CREATE TRIGGER reject_task BEFORE INSERT ON tasks BEGIN SELECT RAISE(ABORT, 'reject test insert'); END`); err != nil {
|
||||
t.Fatalf("create trigger: %v", err)
|
||||
}
|
||||
if _, err := store.CreateDraft(context.Background(), testDraft(testKey, "blocked")); err == nil {
|
||||
t.Fatal("CreateDraft succeeded despite rejecting trigger")
|
||||
}
|
||||
drafts, err := store.ListDrafts(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("list after failed create: %v", err)
|
||||
}
|
||||
if len(drafts) != 0 {
|
||||
t.Fatalf("failed create persisted drafts: %#v", drafts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSQLiteStoreUsesInsertionOrderForEqualTimesAndFiltersPhase(t *testing.T) {
|
||||
database := migratedDatabase(t)
|
||||
store, err := NewSQLiteStore(database)
|
||||
if err != nil {
|
||||
t.Fatalf("NewSQLiteStore: %v", err)
|
||||
}
|
||||
store.now = func() time.Time { return time.Date(2026, 8, 4, 9, 0, 0, 0, time.UTC) }
|
||||
first := testDraft(testKey, "first")
|
||||
second := testDraft("b3c9f507-7473-4fa6-8d71-8786c34c6301", "second")
|
||||
for _, draft := range []Draft{first, second} {
|
||||
if _, err := store.CreateDraft(context.Background(), draft); err != nil {
|
||||
t.Fatalf("create %s: %v", draft.Title, err)
|
||||
}
|
||||
}
|
||||
if _, err := database.Exec(`INSERT INTO tasks (id, source, title, goods_id, sku_color, sku_size, quantity, max_total_price, status, version, created_at, updated_at) VALUES ('excel-draft', 'EXCEL', 'other', '1', 'black', 'M', 1, '1.00', 'DRAFT', 1, '2026-08-04T10:00:00Z', '2026-08-04T10:00:00Z'), ('manual-pending', 'MANUAL', 'other', '2', 'black', 'M', 1, '1.00', 'PENDING', 1, '2026-08-04T10:00:00Z', '2026-08-04T10:00:00Z')`); err != nil {
|
||||
t.Fatalf("insert out-of-scope tasks: %v", err)
|
||||
}
|
||||
drafts, err := store.ListDrafts(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("list drafts: %v", err)
|
||||
}
|
||||
if len(drafts) != 2 || drafts[0].ID != second.ID || drafts[1].ID != first.ID {
|
||||
t.Fatalf("equal-time draft order/filter = %#v, want second then first only", drafts)
|
||||
}
|
||||
if _, err := database.Exec(`UPDATE tasks SET status = 'PENDING' WHERE id = ?`, first.ID); err != nil {
|
||||
t.Fatalf("move draft outside current phase: %v", err)
|
||||
}
|
||||
if _, err := store.CreateDraft(context.Background(), first); !errors.Is(err, ErrCreateKeyConflict) {
|
||||
t.Fatalf("replay of non-DRAFT record error = %v, want conflict", err)
|
||||
}
|
||||
third := testDraft("c3c9f507-7473-4fa6-8d71-8786c34c6301", "third")
|
||||
if _, err := database.Exec(`INSERT INTO tasks (id, source, title, goods_id, sku_color, sku_size, quantity, max_total_price, status, version, created_at, updated_at) VALUES (?, 'EXCEL', ?, ?, ?, ?, ?, ?, 'DRAFT', 1, '2026-08-04T09:00:00Z', '2026-08-04T09:00:00Z')`, third.ID, third.Title, third.GoodsID, third.SKUColor, third.SKUSize, third.Quantity, third.MaxTotalPrice); err != nil {
|
||||
t.Fatalf("insert same-payload EXCEL record: %v", err)
|
||||
}
|
||||
if _, err := store.CreateDraft(context.Background(), third); !errors.Is(err, ErrCreateKeyConflict) {
|
||||
t.Fatalf("replay of non-MANUAL record error = %v, want conflict", err)
|
||||
}
|
||||
if _, err := database.Exec(`UPDATE tasks SET version = 2, source = 'MANUAL' WHERE id = ?`, third.ID); err != nil {
|
||||
t.Fatalf("change replay record version: %v", err)
|
||||
}
|
||||
if _, err := store.CreateDraft(context.Background(), third); !errors.Is(err, ErrCreateKeyConflict) {
|
||||
t.Fatalf("replay of non-v1 record error = %v, want conflict", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSQLiteStoreConcurrentIdenticalCreateIsOneDraft(t *testing.T) {
|
||||
store, err := NewSQLiteStore(migratedDatabase(t))
|
||||
if err != nil {
|
||||
t.Fatalf("NewSQLiteStore: %v", err)
|
||||
}
|
||||
const callers = 20
|
||||
start := make(chan struct{})
|
||||
errors := make(chan error, callers)
|
||||
results := make(chan Draft, callers)
|
||||
var group sync.WaitGroup
|
||||
for range callers {
|
||||
group.Add(1)
|
||||
go func() {
|
||||
defer group.Done()
|
||||
<-start
|
||||
draft, err := store.CreateDraft(context.Background(), testDraft(testKey, "same"))
|
||||
if err != nil {
|
||||
errors <- err
|
||||
return
|
||||
}
|
||||
results <- draft
|
||||
}()
|
||||
}
|
||||
close(start)
|
||||
group.Wait()
|
||||
close(errors)
|
||||
close(results)
|
||||
for err := range errors {
|
||||
t.Fatalf("concurrent create: %v", err)
|
||||
}
|
||||
for result := range results {
|
||||
if result.ID != testKey {
|
||||
t.Fatalf("concurrent result = %#v", result)
|
||||
}
|
||||
}
|
||||
drafts, err := store.ListDrafts(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("list after concurrent create: %v", err)
|
||||
}
|
||||
if len(drafts) != 1 || drafts[0].ID != testKey {
|
||||
t.Fatalf("concurrent creates persisted %#v, want exactly one", drafts)
|
||||
}
|
||||
}
|
||||
|
||||
func testDraft(id, title string) Draft {
|
||||
return Draft{ID: id, Title: title, GoodsID: "937122477375", SKUColor: "black", SKUSize: "M", Quantity: 2, MaxTotalPrice: "12.80"}
|
||||
}
|
||||
|
||||
func openDatabase(t *testing.T) *sql.DB {
|
||||
t.Helper()
|
||||
database, err := sqlite.Open(filepath.Join(t.TempDir(), "tasks.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("open database: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = database.Close() })
|
||||
return database
|
||||
}
|
||||
|
||||
func migratedDatabase(t *testing.T) *sql.DB {
|
||||
t.Helper()
|
||||
database := openDatabase(t)
|
||||
if err := migrations.Up(context.Background(), database, migrationDirectory(t)); err != nil {
|
||||
t.Fatalf("migrate database: %v", err)
|
||||
}
|
||||
return database
|
||||
}
|
||||
|
||||
func migrationDirectory(t *testing.T) string {
|
||||
t.Helper()
|
||||
_, file, _, ok := runtime.Caller(0)
|
||||
if !ok {
|
||||
t.Fatal("locate test source")
|
||||
}
|
||||
return filepath.Join(filepath.Dir(file), "..", "..", "migrations")
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
"use strict";
|
||||
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const vm = require("node:vm");
|
||||
|
||||
const source = fs.readFileSync(path.join(__dirname, "tasks.js"), "utf8");
|
||||
|
||||
test("visible button opens the same routed detail and close restores list state", async () => {
|
||||
const harness = createDrawerHarness();
|
||||
|
||||
harness.button.listeners.click();
|
||||
await harness.flush();
|
||||
|
||||
assert.equal(harness.requests.length, 1);
|
||||
assert.equal(harness.requests[0].url, "/tasks/a3c9f507-7473-4fa6-8d71-8786c34c6301");
|
||||
assert.equal(harness.requests[0].options.headers["X-CMBuyer-View"], "drawer");
|
||||
assert.equal(harness.drawer.open, true);
|
||||
assert.equal(harness.closeButton.focused, true);
|
||||
assert.equal(harness.history.pushes.length, 1);
|
||||
assert.equal(harness.history.pushes[0].url, harness.requests[0].url);
|
||||
assert.equal(harness.history.pushes[0].state.focusTarget, "button");
|
||||
|
||||
harness.closeButton.listeners.click();
|
||||
assert.equal(harness.history.backCalls, 1);
|
||||
harness.popstate({state: {cmbuyerList: true}});
|
||||
assert.equal(harness.drawer.open, false);
|
||||
assert.equal(harness.button.focused, true);
|
||||
assert.equal(harness.row.focused, false);
|
||||
assert.equal(harness.scrolls.length, 1);
|
||||
assert.equal(harness.scrolls[0].top, 275);
|
||||
assert.equal(harness.scrolls[0].behavior, "auto");
|
||||
});
|
||||
|
||||
test("button focus target survives back, forward, and back again", async () => {
|
||||
const harness = createDrawerHarness();
|
||||
harness.button.listeners.click();
|
||||
await harness.flush();
|
||||
const drawerState = harness.history.pushes[0].state;
|
||||
|
||||
harness.popstate({state: {cmbuyerList: true}});
|
||||
assert.equal(harness.button.focusCalls, 1);
|
||||
assert.equal(harness.row.focusCalls, 0);
|
||||
|
||||
harness.popstate({state: drawerState});
|
||||
await harness.flush();
|
||||
assert.equal(harness.drawer.open, true);
|
||||
assert.equal(harness.history.pushes.length, 1);
|
||||
|
||||
harness.popstate({state: {cmbuyerList: true}});
|
||||
assert.equal(harness.drawer.open, false);
|
||||
assert.equal(harness.button.focusCalls, 2);
|
||||
assert.equal(harness.row.focusCalls, 0);
|
||||
});
|
||||
|
||||
test("failed forward retry reuses history and closes back to button in one step", async () => {
|
||||
const harness = createDrawerHarness();
|
||||
harness.button.listeners.click();
|
||||
await harness.flush();
|
||||
const drawerState = harness.history.pushes[0].state;
|
||||
harness.popstate({state: {cmbuyerList: true}});
|
||||
|
||||
harness.failNextRequest();
|
||||
harness.popstate({state: drawerState});
|
||||
await harness.flush();
|
||||
const retry = harness.body.children[1].children[0];
|
||||
retry.listeners.click();
|
||||
await harness.flush();
|
||||
|
||||
assert.equal(harness.history.pushes.length, 1);
|
||||
assert.equal(harness.drawer.open, true);
|
||||
harness.closeButton.listeners.click();
|
||||
assert.equal(harness.history.backCalls, 1);
|
||||
harness.popstate({state: {cmbuyerList: true}});
|
||||
assert.equal(harness.drawer.open, false);
|
||||
assert.equal(harness.button.focusCalls, 2);
|
||||
assert.equal(harness.row.focusCalls, 0);
|
||||
});
|
||||
|
||||
test("double click and Enter open rows but nested controls never do", async () => {
|
||||
const harness = createDrawerHarness();
|
||||
const ignored = {closest: () => ({})};
|
||||
const rowTarget = {closest: () => null};
|
||||
|
||||
harness.row.listeners.dblclick({target: ignored});
|
||||
harness.row.listeners.dblclick({target: rowTarget});
|
||||
await harness.flush();
|
||||
assert.equal(harness.requests.length, 1);
|
||||
|
||||
harness.popstate({state: {cmbuyerList: true}});
|
||||
let prevented = false;
|
||||
harness.row.listeners.keydown({key: "Enter", target: harness.row, preventDefault: () => { prevented = true; }});
|
||||
await harness.flush();
|
||||
assert.equal(prevented, true);
|
||||
assert.equal(harness.requests.length, 2);
|
||||
|
||||
harness.row.listeners.keydown({key: "Enter", target: ignored, preventDefault: () => assert.fail("nested control Enter was intercepted")});
|
||||
assert.equal(harness.requests.length, 2);
|
||||
});
|
||||
|
||||
test("browser back and forward close and reopen without duplicating history", async () => {
|
||||
const harness = createDrawerHarness();
|
||||
harness.row.listeners.keydown({key: "Enter", target: harness.row, preventDefault() {}});
|
||||
await harness.flush();
|
||||
assert.equal(harness.history.pushes.length, 1);
|
||||
|
||||
harness.popstate({state: {cmbuyerList: true}});
|
||||
assert.equal(harness.drawer.open, false);
|
||||
harness.popstate({state: {cmbuyerDrawer: true, detailURL: harness.row.dataset.detailUrl}});
|
||||
await harness.flush();
|
||||
|
||||
assert.equal(harness.drawer.open, true);
|
||||
assert.equal(harness.requests.length, 2);
|
||||
assert.equal(harness.history.pushes.length, 1);
|
||||
});
|
||||
|
||||
test("Escape follows browser history and does not mutate list URL", async () => {
|
||||
const harness = createDrawerHarness();
|
||||
harness.button.listeners.click();
|
||||
await harness.flush();
|
||||
let prevented = false;
|
||||
|
||||
harness.drawer.listeners.cancel({preventDefault: () => { prevented = true; }});
|
||||
|
||||
assert.equal(prevented, true);
|
||||
assert.equal(harness.history.backCalls, 1);
|
||||
assert.equal(harness.history.replaces[0].url, "/tasks?status=DRAFT");
|
||||
});
|
||||
|
||||
function createDrawerHarness() {
|
||||
class FakeElement {
|
||||
constructor() {
|
||||
this.listeners = {};
|
||||
this.dataset = {};
|
||||
this.open = false;
|
||||
this.focused = false;
|
||||
this.focusCalls = 0;
|
||||
this.children = [];
|
||||
this._innerHTML = "";
|
||||
}
|
||||
addEventListener(type, listener) { this.listeners[type] = listener; }
|
||||
focus() { this.focused = true; this.focusCalls++; }
|
||||
showModal() { this.open = true; }
|
||||
close() { this.open = false; }
|
||||
replaceChildren(...children) { this.children = children; this._innerHTML = ""; }
|
||||
append(...children) { this.children.push(...children); }
|
||||
setAttribute() {}
|
||||
closest() { return null; }
|
||||
set innerHTML(value) { this._innerHTML = value; }
|
||||
get innerHTML() { return this._innerHTML; }
|
||||
}
|
||||
|
||||
const body = new FakeElement();
|
||||
const closeButton = new FakeElement();
|
||||
const button = new FakeElement();
|
||||
const row = new FakeElement();
|
||||
row.dataset.detailUrl = "/tasks/a3c9f507-7473-4fa6-8d71-8786c34c6301";
|
||||
row.querySelector = (selector) => selector === "[data-open-detail]" ? button : null;
|
||||
const drawer = new FakeElement();
|
||||
drawer.querySelector = (selector) => ({"[data-detail-body]": body, "[data-close-detail]": closeButton})[selector] || null;
|
||||
|
||||
const requests = [];
|
||||
const popstateListeners = [];
|
||||
const scrolls = [];
|
||||
let failNext = false;
|
||||
const history = {
|
||||
state: null,
|
||||
pushes: [],
|
||||
replaces: [],
|
||||
backCalls: 0,
|
||||
pushState(state, _title, url) { this.state = state; this.pushes.push({state, url}); },
|
||||
replaceState(state, _title, url) { this.state = state; this.replaces.push({state, url}); },
|
||||
back() { this.backCalls++; },
|
||||
};
|
||||
const document = {
|
||||
querySelector: (selector) => selector === "[data-start-purchases]" ? null : selector === "[data-detail-drawer]" ? drawer : null,
|
||||
querySelectorAll: (selector) => selector === "[data-task-row]" ? [row] : [],
|
||||
createElement: () => new FakeElement(),
|
||||
contains: (element) => element === row || element === button,
|
||||
};
|
||||
const window = {
|
||||
location: {pathname: "/tasks", search: "?status=DRAFT"},
|
||||
history,
|
||||
scrollY: 275,
|
||||
scrollTo: (value) => scrolls.push(value),
|
||||
addEventListener(type, listener) { if (type === "popstate") popstateListeners.push(listener); },
|
||||
};
|
||||
const context = {
|
||||
AbortController,
|
||||
document,
|
||||
window,
|
||||
fetch: async (url, options) => {
|
||||
requests.push({url, options});
|
||||
if (failNext) {
|
||||
failNext = false;
|
||||
return {ok: false, headers: {get: () => "text/html"}, text: async () => ""};
|
||||
}
|
||||
return {ok: true, headers: {get: () => "text/html; charset=utf-8"}, text: async () => '<article data-task-detail-content>详情</article>'};
|
||||
},
|
||||
};
|
||||
vm.runInNewContext(source, context, {filename: "tasks.js"});
|
||||
|
||||
return {
|
||||
body, button, closeButton, drawer, history, requests, row, scrolls,
|
||||
failNextRequest: () => { failNext = true; },
|
||||
popstate: (event) => { history.state = event.state; popstateListeners.forEach((listener) => listener(event)); },
|
||||
flush: () => new Promise((resolve) => setImmediate(resolve)),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
(() => {
|
||||
"use strict";
|
||||
const form = document.querySelector("[data-start-purchases]");
|
||||
if (!form) return;
|
||||
const all = form.querySelector("[data-select-all]");
|
||||
const summary = form.querySelector("[data-selection-summary]");
|
||||
const button = form.querySelector("[data-start-button]");
|
||||
const feedback = form.querySelector("[data-start-feedback]");
|
||||
const boxes = () => [...form.querySelectorAll("input[data-task-id]")];
|
||||
let selectionFrozen = false;
|
||||
const parseCents = (value) => {
|
||||
const match = /^(0|[1-9]\d*)\.(\d{2})$/.exec(value);
|
||||
return match ? BigInt(match[1] + match[2]) : null;
|
||||
};
|
||||
const refresh = () => {
|
||||
const available = boxes();
|
||||
const selected = available.filter((box) => box.checked);
|
||||
let cents = 0n;
|
||||
let pricesValid = true;
|
||||
selected.forEach((box) => {
|
||||
const price = parseCents(box.dataset.price);
|
||||
if (price === null) pricesValid = false;
|
||||
else cents += price;
|
||||
});
|
||||
summary.textContent = `已选 ${selected.length} 条,最高总额 ¥${cents / 100n}.${(cents % 100n).toString().padStart(2, "0")}`;
|
||||
button.disabled = !selected.length || !pricesValid;
|
||||
if (!pricesValid) feedback.textContent = "所选任务金额无法安全汇总,请刷新后重选。";
|
||||
if (all) {
|
||||
all.checked = selected.length > 0 && selected.length === available.length;
|
||||
all.indeterminate = selected.length > 0 && selected.length < available.length;
|
||||
all.disabled = selectionFrozen || available.length === 0;
|
||||
}
|
||||
};
|
||||
const freezeSelection = (frozen) => {
|
||||
selectionFrozen = frozen;
|
||||
boxes().forEach((box) => { box.disabled = frozen; });
|
||||
refresh();
|
||||
};
|
||||
boxes().forEach((box) => box.addEventListener("change", refresh));
|
||||
if (all) all.addEventListener("change", () => { boxes().forEach((box) => { box.checked = all.checked; }); refresh(); });
|
||||
let frozenPayload = null;
|
||||
let inFlight = false;
|
||||
form.addEventListener("submit", async (event) => {
|
||||
event.preventDefault();
|
||||
const selected = boxes().filter((box) => box.checked);
|
||||
if (!selected.length || inFlight) return;
|
||||
const tasks = selected.map((box) => ({task_id: box.dataset.taskId, expected_task_version: Number(box.dataset.taskVersion)}));
|
||||
if (tasks.some((item) => !Number.isSafeInteger(item.expected_task_version) || item.expected_task_version < 1)) { feedback.textContent = "任务版本无效,请刷新后重选。"; return; }
|
||||
frozenPayload = frozenPayload || JSON.stringify({start_key: form.dataset.startKey, tasks});
|
||||
inFlight = true; freezeSelection(true); button.disabled = true; button.textContent = "正在授权…";
|
||||
try { const response = await fetch("/tasks/start-purchases", {method:"POST", headers:{"Content-Type":"application/json", "X-CSRF-Token":form.dataset.csrf}, body:frozenPayload});
|
||||
if (response.ok) { window.location.reload(); return; }
|
||||
if (response.status === 409) { feedback.textContent = "任务已变化,请刷新后重选。"; frozenPayload = null; freezeSelection(false); boxes().forEach((box) => { box.checked = false; }); refresh(); return; }
|
||||
if (response.status === 400 || response.status === 401 || response.status === 403) { feedback.textContent = "请求未被接受,请刷新页面后重试。"; frozenPayload = null; freezeSelection(false); return; }
|
||||
feedback.textContent = "结果暂时不明确,只能使用同一按钮原样重放。";
|
||||
} catch (_) { feedback.textContent = "网络结果不明确,请使用同一按钮原样重试。"; }
|
||||
finally { inFlight = false; button.textContent = "开始采购(只创建待付款订单)"; if (frozenPayload) button.disabled = false; }
|
||||
});
|
||||
refresh();
|
||||
})();
|
||||
|
||||
(() => {
|
||||
"use strict";
|
||||
const drawer = document.querySelector("[data-detail-drawer]");
|
||||
if (!drawer) return;
|
||||
const body = drawer.querySelector("[data-detail-body]");
|
||||
const closeButton = drawer.querySelector("[data-close-detail]");
|
||||
const rows = [...document.querySelectorAll("[data-task-row]")];
|
||||
const initialURL = window.location.pathname + window.location.search;
|
||||
let focusTrigger = null;
|
||||
let scrollPosition = window.scrollY;
|
||||
let activeRequest = null;
|
||||
|
||||
const isInteractive = (target) => Boolean(target && typeof target.closest === "function" && target.closest("a,button,input,select,textarea,label,[contenteditable=true]"));
|
||||
const showDrawer = () => {
|
||||
if (!drawer.open) drawer.showModal();
|
||||
};
|
||||
const restoreList = () => {
|
||||
if (activeRequest) {
|
||||
activeRequest.abort();
|
||||
activeRequest = null;
|
||||
}
|
||||
if (drawer.open) drawer.close();
|
||||
window.scrollTo({top: scrollPosition, behavior: "auto"});
|
||||
if (focusTrigger && document.contains(focusTrigger)) focusTrigger.focus({preventScroll: true});
|
||||
};
|
||||
const showError = (url, row, requestedFocus, pushHistory) => {
|
||||
body.replaceChildren();
|
||||
const message = document.createElement("p");
|
||||
message.className = "drawer-feedback";
|
||||
message.setAttribute("role", "alert");
|
||||
message.textContent = "任务详情加载失败。请重试,或在完整页打开。";
|
||||
const actions = document.createElement("p");
|
||||
const retry = document.createElement("button");
|
||||
retry.className = "button primary";
|
||||
retry.type = "button";
|
||||
retry.textContent = "重试";
|
||||
retry.addEventListener("click", () => loadDetail(url, row, requestedFocus, pushHistory));
|
||||
const fallback = document.createElement("a");
|
||||
fallback.className = "button";
|
||||
fallback.href = url;
|
||||
fallback.textContent = "在完整页打开";
|
||||
actions.className = "actions";
|
||||
actions.append(retry, fallback);
|
||||
body.append(message, actions);
|
||||
};
|
||||
const loadDetail = async (url, row, requestedFocus, pushHistory) => {
|
||||
if (activeRequest) activeRequest.abort();
|
||||
const requestController = new AbortController();
|
||||
activeRequest = requestController;
|
||||
focusTrigger = requestedFocus || focusTrigger;
|
||||
if (pushHistory) scrollPosition = window.scrollY;
|
||||
body.innerHTML = '<p class="drawer-feedback" role="status">正在加载任务详情…</p>';
|
||||
showDrawer();
|
||||
try {
|
||||
const response = await fetch(url, {headers: {"X-CMBuyer-View": "drawer", "Accept": "text/html"}, credentials: "same-origin", signal: requestController.signal});
|
||||
if (!response.ok || !String(response.headers.get("Content-Type") || "").toLowerCase().startsWith("text/html")) throw new Error("detail request rejected");
|
||||
const fragment = await response.text();
|
||||
if (!fragment.includes("data-task-detail-content")) throw new Error("detail fragment missing");
|
||||
body.innerHTML = fragment;
|
||||
if (pushHistory) window.history.pushState({cmbuyerDrawer: true, detailURL: url, focusTarget: requestedFocus === row ? "row" : "button"}, "", url);
|
||||
closeButton.focus();
|
||||
} catch (error) {
|
||||
if (error.name !== "AbortError") showError(url, row, requestedFocus, pushHistory);
|
||||
} finally {
|
||||
if (activeRequest === requestController) activeRequest = null;
|
||||
}
|
||||
};
|
||||
const requestClose = () => {
|
||||
if (window.history.state && window.history.state.cmbuyerDrawer) window.history.back();
|
||||
else restoreList();
|
||||
};
|
||||
|
||||
window.history.replaceState({cmbuyerList: true, listURL: initialURL}, "", initialURL);
|
||||
rows.forEach((row) => {
|
||||
const url = row.dataset.detailUrl;
|
||||
row.addEventListener("dblclick", (event) => {
|
||||
if (!isInteractive(event.target)) loadDetail(url, row, row, true);
|
||||
});
|
||||
row.addEventListener("keydown", (event) => {
|
||||
if (event.key === "Enter" && event.target === row) {
|
||||
event.preventDefault();
|
||||
loadDetail(url, row, row, true);
|
||||
}
|
||||
});
|
||||
const button = row.querySelector("[data-open-detail]");
|
||||
if (button) button.addEventListener("click", () => loadDetail(url, row, button, true));
|
||||
});
|
||||
closeButton.addEventListener("click", requestClose);
|
||||
drawer.addEventListener("cancel", (event) => {
|
||||
event.preventDefault();
|
||||
requestClose();
|
||||
});
|
||||
window.addEventListener("popstate", (event) => {
|
||||
if (event.state && event.state.cmbuyerDrawer) {
|
||||
const row = rows.find((candidate) => candidate.dataset.detailUrl === event.state.detailURL);
|
||||
if (!row) {
|
||||
restoreList();
|
||||
return;
|
||||
}
|
||||
const requestedFocus = event.state.focusTarget === "button" ? row.querySelector("[data-open-detail]") || row : row;
|
||||
loadDetail(event.state.detailURL, row, requestedFocus, false);
|
||||
return;
|
||||
}
|
||||
restoreList();
|
||||
});
|
||||
})();
|
||||
@@ -0,0 +1,138 @@
|
||||
"use strict";
|
||||
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const vm = require("node:vm");
|
||||
|
||||
const source = fs.readFileSync(path.join(__dirname, "tasks.js"), "utf8");
|
||||
|
||||
test("successful authorization sends numeric version and reloads", async () => {
|
||||
const requests = [];
|
||||
const harness = createHarness(async (_url, options) => {
|
||||
requests.push(options);
|
||||
return {ok: true, status: 200};
|
||||
});
|
||||
|
||||
await harness.submit();
|
||||
|
||||
assert.equal(requests.length, 1);
|
||||
assert.equal(requests[0].headers["Content-Type"], "application/json");
|
||||
assert.equal(requests[0].headers["X-CSRF-Token"], "csrf-token");
|
||||
const payload = JSON.parse(requests[0].body);
|
||||
assert.equal(payload.start_key, "start-key");
|
||||
assert.equal(typeof payload.tasks[0].expected_task_version, "number");
|
||||
assert.equal(payload.tasks[0].expected_task_version, 7);
|
||||
assert.equal(harness.reloads(), 1);
|
||||
});
|
||||
|
||||
test("409 clears stale selection and requires a fresh choice", async () => {
|
||||
const harness = createHarness(async () => ({ok: false, status: 409}));
|
||||
|
||||
await harness.submit();
|
||||
|
||||
assert.equal(harness.box.checked, false);
|
||||
assert.equal(harness.box.disabled, false);
|
||||
assert.equal(harness.button.disabled, true);
|
||||
assert.match(harness.feedback.textContent, /任务已变化/);
|
||||
});
|
||||
|
||||
for (const status of [400, 401, 403]) {
|
||||
test(`${status} releases the frozen payload for a page refresh`, async () => {
|
||||
const harness = createHarness(async () => ({ok: false, status}));
|
||||
|
||||
await harness.submit();
|
||||
|
||||
assert.equal(harness.box.checked, true);
|
||||
assert.equal(harness.box.disabled, false);
|
||||
assert.equal(harness.button.disabled, false);
|
||||
assert.match(harness.feedback.textContent, /刷新页面后重试/);
|
||||
});
|
||||
}
|
||||
|
||||
test("5xx retries the byte-identical frozen payload", async () => {
|
||||
const bodies = [];
|
||||
const harness = createHarness(async (_url, options) => {
|
||||
bodies.push(options.body);
|
||||
return {ok: false, status: 503};
|
||||
});
|
||||
|
||||
await harness.submit();
|
||||
assert.equal(harness.box.disabled, true);
|
||||
assert.equal(harness.button.disabled, false);
|
||||
assert.match(harness.feedback.textContent, /原样重放/);
|
||||
await harness.submit();
|
||||
|
||||
assert.equal(bodies.length, 2);
|
||||
assert.equal(bodies[1], bodies[0]);
|
||||
});
|
||||
|
||||
test("network ambiguity retries the same payload and can finish", async () => {
|
||||
const bodies = [];
|
||||
let call = 0;
|
||||
const harness = createHarness(async (_url, options) => {
|
||||
bodies.push(options.body);
|
||||
call++;
|
||||
if (call === 1) throw new Error("network result unknown");
|
||||
return {ok: true, status: 200};
|
||||
});
|
||||
|
||||
await harness.submit();
|
||||
assert.equal(harness.box.disabled, true);
|
||||
assert.match(harness.feedback.textContent, /原样重试/);
|
||||
await harness.submit();
|
||||
|
||||
assert.deepEqual(bodies, [bodies[0], bodies[0]]);
|
||||
assert.equal(harness.reloads(), 1);
|
||||
});
|
||||
|
||||
function createHarness(fetchImplementation) {
|
||||
class FakeElement {
|
||||
constructor() {
|
||||
this.dataset = {};
|
||||
this.checked = false;
|
||||
this.disabled = false;
|
||||
this.indeterminate = false;
|
||||
this.textContent = "";
|
||||
this.listeners = {};
|
||||
}
|
||||
|
||||
addEventListener(type, listener) {
|
||||
this.listeners[type] = listener;
|
||||
}
|
||||
}
|
||||
|
||||
const box = new FakeElement();
|
||||
box.checked = true;
|
||||
box.dataset = {taskId: "task-id", taskVersion: "7", price: "12.80"};
|
||||
const selectAll = new FakeElement();
|
||||
const summary = new FakeElement();
|
||||
const button = new FakeElement();
|
||||
const feedback = new FakeElement();
|
||||
const form = new FakeElement();
|
||||
form.dataset = {startKey: "start-key", csrf: "csrf-token"};
|
||||
form.querySelector = (selector) => ({
|
||||
"[data-select-all]": selectAll,
|
||||
"[data-selection-summary]": summary,
|
||||
"[data-start-button]": button,
|
||||
"[data-start-feedback]": feedback,
|
||||
})[selector] || null;
|
||||
form.querySelectorAll = (selector) => selector === "input[data-task-id]" ? [box] : [];
|
||||
|
||||
let reloadCount = 0;
|
||||
const context = {
|
||||
document: {querySelector: (selector) => selector === "[data-start-purchases]" ? form : null},
|
||||
fetch: fetchImplementation,
|
||||
window: {location: {reload: () => { reloadCount++; }}},
|
||||
};
|
||||
vm.runInNewContext(source, context, {filename: "tasks.js"});
|
||||
|
||||
return {
|
||||
box,
|
||||
button,
|
||||
feedback,
|
||||
reloads: () => reloadCount,
|
||||
submit: () => form.listeners.submit({preventDefault() {}}),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
{{define "login.html"}}
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>登录 · 采购服务</title>
|
||||
<style>
|
||||
:root { color-scheme: light; --bg:#f4f7fb; --surface:#fff; --text:#172033; --muted:#526079; --border:#cfd8e6; --primary:#155eef; --primary-hover:#0b4ed1; --primary-soft:#eaf1ff; --danger:#b42318; --danger-soft:#fef3f2; --focus:#ffbf47; --shadow:0 12px 30px rgba(23,32,51,.1); font-family:"Segoe UI","Microsoft YaHei UI",system-ui,sans-serif; }
|
||||
* { box-sizing:border-box; }
|
||||
html { min-width:320px; background:var(--bg); }
|
||||
body { min-height:100dvh; margin:0; color:var(--text); background:var(--bg); font-size:16px; line-height:1.55; }
|
||||
button,input { font:inherit; }
|
||||
:focus-visible { outline:3px solid var(--focus); outline-offset:3px; }
|
||||
.skip-link { position:fixed; z-index:10; top:8px; left:8px; padding:10px 14px; color:#fff; background:var(--text); transform:translateY(-160%); }
|
||||
.skip-link:focus { transform:translateY(0); }
|
||||
main { display:grid; min-height:100dvh; place-items:center; padding:24px 16px; }
|
||||
.card { width:min(100%,440px); padding:32px; border:1px solid var(--border); border-radius:14px; background:var(--surface); box-shadow:var(--shadow); }
|
||||
.brand { display:flex; align-items:center; gap:10px; margin:0 0 24px; font-size:1rem; font-weight:700; }
|
||||
.brand-mark { display:grid; width:32px; height:32px; place-items:center; border-radius:8px; color:#fff; background:var(--primary); font-size:.82rem; }
|
||||
h1 { margin:0; font-size:clamp(1.6rem,5vw,2rem); line-height:1.25; }
|
||||
.intro { margin:8px 0 24px; color:var(--muted); }
|
||||
.field { margin-top:16px; }
|
||||
label { display:block; margin-bottom:6px; font-weight:700; }
|
||||
input { width:100%; min-height:44px; padding:10px 12px; border:1px solid #9ba9bc; border-radius:8px; color:var(--text); background:#fff; }
|
||||
input[aria-invalid="true"] { border-color:var(--danger); box-shadow:0 0 0 1px var(--danger); }
|
||||
.hint { margin:5px 0 0; color:var(--muted); font-size:.875rem; }
|
||||
.error { margin:0 0 18px; padding:12px 14px; border-left:4px solid var(--danger); border-radius:6px; color:var(--danger); background:var(--danger-soft); font-weight:650; }
|
||||
.submit { width:100%; min-height:44px; margin-top:24px; padding:10px 16px; border:1px solid transparent; border-radius:8px; color:#fff; background:var(--primary); font-weight:700; cursor:pointer; transition:background-color 180ms ease-out; }
|
||||
.submit:hover { background:var(--primary-hover); }
|
||||
.notice { margin:20px 0 0; padding:12px 14px; border:1px solid #b9cffc; border-radius:8px; color:#29466f; background:var(--primary-soft); font-size:.9rem; }
|
||||
@media (max-width:420px) { main { padding-inline:12px; } .card { padding:24px 16px; } }
|
||||
@media (prefers-reduced-motion:reduce) { *,*::before,*::after { transition-duration:.01ms !important; animation-duration:.01ms !important; } }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<a class="skip-link" href="#main">跳到主要内容</a>
|
||||
<main id="main">
|
||||
<section class="card" aria-labelledby="login-title">
|
||||
<p class="brand"><span class="brand-mark" aria-hidden="true">采</span><span>采购服务</span></p>
|
||||
<h1 id="login-title">管理端登录</h1>
|
||||
<p class="intro">登录后进入采购任务工作台。设备身份不能使用此入口。</p>
|
||||
{{if .Error}}<p class="error" role="alert">{{.Error}}</p>{{end}}
|
||||
<form method="post" action="/login">
|
||||
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
|
||||
<input type="hidden" name="return_to" value="{{.ReturnTo}}">
|
||||
<div class="field">
|
||||
<label for="username">账号</label>
|
||||
<input id="username" name="username" type="text" value="{{.Username}}" autocomplete="username" required aria-invalid="{{if .Error}}true{{else}}false{{end}}" aria-describedby="username-hint">
|
||||
<p class="hint" id="username-hint">使用采购管理员账号登录。</p>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="password">密码</label>
|
||||
<input id="password" name="password" type="password" autocomplete="current-password" required aria-invalid="{{if .Error}}true{{else}}false{{end}}">
|
||||
</div>
|
||||
<button class="submit" type="submit">登录并继续</button>
|
||||
</form>
|
||||
<p class="notice">系统只创建待付款订单,付款始终由人完成。</p>
|
||||
</section>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
@@ -0,0 +1,38 @@
|
||||
{{define "task-detail-page.html"}}
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>{{.Detail.Task.Title}} · 任务详情 · 采购服务</title>
|
||||
<style>
|
||||
:root{--bg:#f4f7fb;--surface:#fff;--text:#172033;--muted:#526079;--border:#cfd8e6;--primary:#155eef;--danger:#b42318;--success:#067647;--focus:#ffbf47;font-family:"Segoe UI","Microsoft YaHei UI",system-ui,sans-serif}*{box-sizing:border-box}body{margin:0;color:var(--text);background:var(--bg);font-size:16px;line-height:1.55}a{color:#124cc5;text-underline-offset:3px}:focus-visible{outline:3px solid var(--focus);outline-offset:3px}.skip{position:fixed;z-index:100;top:8px;left:8px;padding:10px;color:#fff;background:#172033;transform:translateY(-160%)}.skip:focus{transform:translateY(0)}.topbar{display:flex;align-items:center;justify-content:space-between;gap:16px;min-height:64px;padding:10px clamp(16px,4vw,40px);border-bottom:1px solid var(--border);background:var(--surface)}.brand{color:var(--text);font-weight:700;text-decoration:none}.brand b{display:inline-grid;place-items:center;width:32px;height:32px;margin-right:8px;border-radius:8px;background:var(--primary);color:#fff;font-size:.82rem}.button{display:inline-flex;align-items:center;justify-content:center;min-height:44px;padding:9px 14px;border:1px solid var(--border);border-radius:8px;color:var(--text);background:#fff;font-weight:700;text-decoration:none}.detail-page{width:min(100% - 32px,1120px);margin:28px auto 48px}.detail-shell{display:grid;gap:16px}.detail-head{display:flex;align-items:flex-start;justify-content:space-between;gap:16px}.detail-head h1{margin:0;font-size:clamp(1.45rem,3vw,2rem)}.detail-head p{margin:4px 0;color:var(--muted)}.status{display:inline-block;padding:4px 10px;border-radius:999px;background:#eaf1ff;color:#173d8f;font-size:.88rem;font-weight:700;white-space:nowrap}.safety{margin:0;padding:13px 15px;border:1px solid #a9c3f7;border-left:5px solid var(--primary);border-radius:10px;background:#edf3ff}.detail-grid{display:grid;grid-template-columns:minmax(0,1fr) minmax(250px,320px);gap:16px}.detail-card{overflow:hidden;border:1px solid var(--border);border-radius:12px;background:var(--surface)}.detail-card>header,.detail-card>.detail-body{padding:16px 18px}.detail-card>header{border-bottom:1px solid var(--border)}.detail-card h2,.detail-card h3{margin:0}.detail-card header p,.empty-note{margin:4px 0 0;color:var(--muted)}.facts{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:10px;margin:0}.facts div{min-width:0;padding:11px;border:1px solid var(--border);border-radius:8px;background:#f8fafc}.facts dt{font-size:.82rem;color:var(--muted);font-weight:700}.facts dd{margin:3px 0 0;overflow-wrap:anywhere;font-weight:650}.audit-list{display:grid;gap:10px;margin:0;padding:0;list-style:none}.audit-list li{padding:12px;border:1px solid var(--border);border-radius:8px}.audit-list p{margin:4px 0}.mono{font-family:Consolas,"SFMono-Regular",monospace;overflow-wrap:anywhere}.evidence-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:14px}.evidence{margin:0}.evidence img{display:block;width:100%;height:auto;max-height:520px;object-fit:contain;border:1px solid var(--border);border-radius:8px;background:#eef2f7}.evidence figcaption{margin-top:7px;color:var(--muted);font-size:.85rem}.section-stack{display:grid;gap:16px}.privacy-note{margin:12px 0 0;color:var(--muted);font-size:.88rem}@media(max-width:760px){.detail-grid{grid-template-columns:1fr}.detail-head{display:grid}.facts{grid-template-columns:1fr}}@media(prefers-reduced-motion:reduce){*,*::before,*::after{scroll-behavior:auto!important;transition-duration:.01ms!important;animation-duration:.01ms!important}}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<a class="skip" href="#main">跳到主要内容</a>
|
||||
<header class="topbar"><a class="brand" href="/tasks"><b aria-hidden="true">采</b>采购服务</a><a class="button" href="/tasks">返回任务列表</a></header>
|
||||
<main class="detail-page" id="main">{{template "task-detail-content" .}}</main>
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
|
||||
{{define "task-detail-content"}}
|
||||
<article class="detail-shell" data-task-detail-content data-task-id="{{.Detail.Task.ID}}">
|
||||
<header class="detail-head"><div><h1>{{.Detail.Task.Title}}</h1><p>任务 <span class="mono">{{.Detail.Task.ID}}</span> · 版本 {{.Detail.Task.Version}}</p></div><span class="status">{{statusLabel .Detail.Task.Status}}</span></header>
|
||||
<p class="safety"><strong>{{taskSafetyTitle .Detail.Task.Status}}</strong> {{taskSafetyText .Detail.Task.Status}}</p>
|
||||
<div class="detail-grid">
|
||||
<div class="section-stack">
|
||||
<section class="detail-card" aria-labelledby="task-facts-title"><header><h2 id="task-facts-title">任务要求</h2><p>管理员锁定的采购边界;详情页不会触发设备动作。</p></header><div class="detail-body"><dl class="facts"><div><dt>商品</dt><dd><a href="{{canonicalURL .Detail.Task.GoodsID}}" target="_blank" rel="noopener noreferrer">goods_id {{.Detail.Task.GoodsID}}</a></dd></div><div><dt>目标规格</dt><dd>{{.Detail.Task.SKUColor}} / {{.Detail.Task.SKUSize}}</dd></div><div><dt>数量</dt><dd>{{.Detail.Task.Quantity}} 件</dd></div><div><dt>最高总价</dt><dd>¥{{.Detail.Task.MaxTotalPrice}}</dd></div><div><dt>创建时间(上海)</dt><dd><time datetime="{{shanghaiDateTime .Detail.Task.CreatedAt}}">{{shanghaiTime .Detail.Task.CreatedAt}}</time></dd></div><div><dt>更新时间(上海)</dt><dd><time datetime="{{shanghaiDateTime .Detail.Task.UpdatedAt}}">{{shanghaiTime .Detail.Task.UpdatedAt}}</time></dd></div></dl></div></section>
|
||||
|
||||
<section class="detail-card" aria-labelledby="execution-title"><header><h2 id="execution-title">设备执行事实</h2><p>只展示数据库中已存在的 attempt;T-204 不创建执行记录。</p></header><div class="detail-body">{{if .Detail.Attempts}}<ol class="audit-list">{{range .Detail.Attempts}}<li><h3>Attempt <span class="mono">{{.ID}}</span></h3><p>状态:{{attemptStatusLabel .Status}} · 领取代次 {{.ClaimGeneration}}</p><p>开始:<time datetime="{{shanghaiDateTime .StartedAt}}">{{shanghaiTime .StartedAt}}</time>{{with .FinishedAt}} · 结束:<time datetime="{{shanghaiDateTime .}}">{{shanghaiTime .}}</time>{{end}}</p>{{with .FailureCode}}<p>失败码:<span class="mono">{{.}}</span></p>{{end}}{{if or .Gate1UnitPrice .Gate2UnitPrice .QuantityRead .ConfirmAmount}}<p>已有读数:{{with .Gate1UnitPrice}}闸门一 ¥{{.}};{{end}}{{with .Gate2UnitPrice}}闸门二 ¥{{.}};{{end}}{{with .QuantityRead}}数量 {{.}};{{end}}{{with .ConfirmAmount}}确认页 ¥{{.}}{{end}}</p>{{else}}<p class="empty-note">暂无规格、价格或数量读数。</p>{{end}}</li>{{end}}</ol>{{else}}<p class="empty-note">暂无设备执行记录。</p>{{end}}</div></section>
|
||||
|
||||
<section class="detail-card" aria-labelledby="evidence-title"><header><h2 id="evidence-title">内部截图</h2><p>INTERNAL_RAW 仅供已登录管理员审计,不代表价格闸门通过或人工批准。</p></header><div class="detail-body">{{if .Detail.Evidence}}<div class="evidence-grid">{{range .Detail.Evidence}}<figure class="evidence"><img src="/evidence/{{.ID}}" width="{{.Width}}" height="{{.Height}}" loading="lazy" alt="规格面板内部审计截图,采集于 {{shanghaiTime .CapturedAt}}"><figcaption>{{evidenceKindLabel .Kind}}(<span class="mono">{{.Kind}}</span>)· {{formatBytes .ByteSize}} · <time datetime="{{shanghaiDateTime .CapturedAt}}">{{shanghaiTime .CapturedAt}}</time><br>Attempt <span class="mono">{{.AttemptID}}</span></figcaption></figure>{{end}}</div>{{else}}<p class="empty-note">暂无内部截图。只有已认证设备显式上传的 PNG 会出现在这里。</p>{{end}}<p class="privacy-note">截图可能包含页面已显示的地址或手机号;系统不提取、索引或写入日志。完整 XML、外部支付页和支付凭据不会上传。</p></div></section>
|
||||
|
||||
<section class="detail-card" aria-labelledby="submission-title"><header><h2 id="submission-title">提交围栏与结果</h2><p>只读审计;本页没有重试、再次提交或付款动作。</p></header><div class="detail-body">{{if .Detail.Submissions}}<ol class="audit-list">{{range .Detail.Submissions}}<li><h3>Submission <span class="mono">{{.ID}}</span></h3><p>状态:{{submissionStatusLabel .Status}}</p><p>闸门一 ¥{{.Gate1UnitPrice}};闸门二 ¥{{.Gate2UnitPrice}};数量 {{.QuantityRead}};确认页 ¥{{.ConfirmAmount}}</p><p>建立:<time datetime="{{shanghaiDateTime .CreatedAt}}">{{shanghaiTime .CreatedAt}}</time>{{with .ResolvedAt}} · 调和:<time datetime="{{shanghaiDateTime .}}">{{shanghaiTime .}}</time>{{end}}</p></li>{{end}}</ol>{{else}}<p class="empty-note">尚未建立提交围栏;详情页不会创建或释放围栏。</p>{{end}}</div></section>
|
||||
</div>
|
||||
|
||||
<aside class="section-stack" aria-label="任务状态摘要"><section class="detail-card"><header><h2>开始采购授权</h2><p>锁定任务字段和最高总价,不授权付款。</p></header><div class="detail-body">{{if .Detail.Authorizations}}<ol class="audit-list">{{range .Detail.Authorizations}}<li><h3>{{authorizationStatusLabel .Status}}</h3><p class="mono">{{.ID}}</p><p>任务版本 {{.TaskVersion}} · 上限 ¥{{.TotalPriceCap}}</p><p>授权人:{{.CreatedBy}}</p><p><time datetime="{{shanghaiDateTime .CreatedAt}}">{{shanghaiTime .CreatedAt}}</time> 至 <time datetime="{{shanghaiDateTime .ExpiresAt}}">{{shanghaiTime .ExpiresAt}}</time></p></li>{{end}}</ol>{{else}}<p class="empty-note">尚未开始采购,没有授权记录。</p>{{end}}</div></section><section class="detail-card"><header><h2>固定边界</h2></header><div class="detail-body"><ul><li>系统只创建待付款订单,不自动付款。</li><li>截图仅供审计,不替代实时三道价格闸门。</li><li>围栏后只能调和同一提交,禁止再次点击。</li></ul></div></section></aside>
|
||||
</div>
|
||||
</article>
|
||||
{{end}}
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,162 @@
|
||||
// Package webui 渲染采购服务当前可用的服务端页面。
|
||||
package webui
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"io"
|
||||
"time"
|
||||
|
||||
"cmbuyer/admin/internal/taskdetail"
|
||||
"cmbuyer/admin/internal/tasks"
|
||||
)
|
||||
|
||||
//go:embed templates/*.html
|
||||
var templateFiles embed.FS
|
||||
|
||||
//go:embed static/tasks.js
|
||||
var tasksScript []byte
|
||||
|
||||
var shanghaiLocation = time.FixedZone("Asia/Shanghai", 8*60*60)
|
||||
|
||||
var templates = template.Must(template.New("webui").Funcs(template.FuncMap{
|
||||
"list": func(values ...any) []any { return values },
|
||||
"statusLabel": statusLabel,
|
||||
"shanghaiDateTime": func(value time.Time) string { return value.In(shanghaiLocation).Format(time.RFC3339) },
|
||||
"shanghaiTime": func(value time.Time) string { return value.In(shanghaiLocation).Format("2006-01-02 15:04") },
|
||||
"canonicalURL": tasks.CanonicalURL,
|
||||
"formatBytes": formatBytes,
|
||||
"taskSafetyTitle": taskSafetyTitle,
|
||||
"taskSafetyText": taskSafetyText,
|
||||
"authorizationStatusLabel": authorizationStatusLabel,
|
||||
"attemptStatusLabel": attemptStatusLabel,
|
||||
"submissionStatusLabel": submissionStatusLabel,
|
||||
"evidenceKindLabel": evidenceKindLabel,
|
||||
}).ParseFS(templateFiles, "templates/*.html"))
|
||||
|
||||
// LoginData 是登录页面所需的非敏感展示数据。
|
||||
type LoginData struct {
|
||||
CSRFToken string
|
||||
ReturnTo string
|
||||
Username string
|
||||
Error string
|
||||
}
|
||||
|
||||
// TasksData 是受保护的建单与任务工作台页面所需数据。
|
||||
type TasksData struct {
|
||||
CSRFToken string
|
||||
Tasks []tasks.TaskRow
|
||||
Filter tasks.TaskFilter
|
||||
FilterErrors tasks.Errors
|
||||
HasFilter bool
|
||||
StartKey string
|
||||
Form tasks.Form
|
||||
Errors tasks.Errors
|
||||
OpenForm bool
|
||||
FullPage bool
|
||||
FocusField string
|
||||
Success bool
|
||||
}
|
||||
|
||||
type TaskDetailData struct{ Detail taskdetail.Detail }
|
||||
|
||||
// RenderLogin 写入登录页。
|
||||
func RenderLogin(writer io.Writer, data LoginData) error {
|
||||
return templates.ExecuteTemplate(writer, "login.html", data)
|
||||
}
|
||||
|
||||
// RenderTasks 写入登录后的受保护任务页。
|
||||
func RenderTasks(writer io.Writer, data TasksData) error {
|
||||
return templates.ExecuteTemplate(writer, "tasks.html", data)
|
||||
}
|
||||
|
||||
func RenderTaskDetailPage(writer io.Writer, data TaskDetailData) error {
|
||||
return templates.ExecuteTemplate(writer, "task-detail-page.html", data)
|
||||
}
|
||||
|
||||
func RenderTaskDetailFragment(writer io.Writer, data TaskDetailData) error {
|
||||
return templates.ExecuteTemplate(writer, "task-detail-content", data)
|
||||
}
|
||||
|
||||
func TasksScript() []byte { return tasksScript }
|
||||
|
||||
func statusLabel(status string) string {
|
||||
labels := map[string]string{
|
||||
"DRAFT": "待开始",
|
||||
"PENDING": "已授权待领取",
|
||||
"CLAIMED": "已领取",
|
||||
"ORDERING": "执行中",
|
||||
"NEEDS_MANUAL": "待人工处理",
|
||||
"WAITING_PAYMENT": "待付款",
|
||||
"RECONCILIATION_REQUIRED": "围栏后待调和",
|
||||
"SUCCEEDED": "已完成",
|
||||
"FAILED": "失败",
|
||||
"CANCELED": "已取消",
|
||||
}
|
||||
if label, ok := labels[status]; ok {
|
||||
return label
|
||||
}
|
||||
return "未知状态"
|
||||
}
|
||||
|
||||
func taskSafetyTitle(status string) string {
|
||||
if status == "WAITING_PAYMENT" {
|
||||
return "订单已创建,系统尚未付款。"
|
||||
}
|
||||
if status == "RECONCILIATION_REQUIRED" {
|
||||
return "订单可能已创建,只能调和同一提交。"
|
||||
}
|
||||
return "系统只创建待付款订单,不会自动付款。"
|
||||
}
|
||||
|
||||
func taskSafetyText(status string) string {
|
||||
if status == "DRAFT" {
|
||||
return "创建任务不构成授权;请回到列表勾选后开始采购。"
|
||||
}
|
||||
if status == "RECONCILIATION_REQUIRED" {
|
||||
return "围栏保持占用,禁止重新授权、再次提交或释放。"
|
||||
}
|
||||
return "截图只供内部审计,不替代实时价格闸门,也不会触发设备动作。"
|
||||
}
|
||||
|
||||
func authorizationStatusLabel(status string) string {
|
||||
labels := map[string]string{"ACTIVE": "授权有效", "CLAIMED": "已被领取", "FENCED": "提交围栏已建立", "CONSUMED": "授权已消费", "EXPIRED": "授权已过期", "ABANDONED": "授权已关闭"}
|
||||
if value, ok := labels[status]; ok {
|
||||
return value
|
||||
}
|
||||
return "未知授权状态"
|
||||
}
|
||||
|
||||
func attemptStatusLabel(status string) string {
|
||||
labels := map[string]string{"CLAIMED": "已领取", "ORDERING": "执行中", "FAILED": "围栏前失败", "FENCED": "已建立围栏", "ABANDONED": "已安全停止"}
|
||||
if value, ok := labels[status]; ok {
|
||||
return value
|
||||
}
|
||||
return "未知执行状态"
|
||||
}
|
||||
|
||||
func submissionStatusLabel(status string) string {
|
||||
labels := map[string]string{"FENCED": "围栏已建立", "SUBMITTED": "已创建待付款订单", "RECONCILIATION_REQUIRED": "结果待调和", "MANUAL_RESOLVED": "已人工调和"}
|
||||
if value, ok := labels[status]; ok {
|
||||
return value
|
||||
}
|
||||
return "未知提交状态"
|
||||
}
|
||||
|
||||
func evidenceKindLabel(kind string) string {
|
||||
if kind == "SKU_PANEL_GATE_1" {
|
||||
return "规格面板 · 闸门一"
|
||||
}
|
||||
return "内部截图"
|
||||
}
|
||||
|
||||
func formatBytes(value int64) string {
|
||||
if value >= 1<<20 {
|
||||
return fmt.Sprintf("%.1f MiB", float64(value)/(1<<20))
|
||||
}
|
||||
if value >= 1<<10 {
|
||||
return fmt.Sprintf("%.1f KiB", float64(value)/(1<<10))
|
||||
}
|
||||
return fmt.Sprintf("%d B", value)
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
-- +goose Up
|
||||
CREATE TABLE tasks (
|
||||
id TEXT PRIMARY KEY,
|
||||
source TEXT NOT NULL CHECK (source IN ('MANUAL', 'EXCEL', 'ERP')),
|
||||
source_ref TEXT,
|
||||
title TEXT NOT NULL,
|
||||
goods_id TEXT NOT NULL,
|
||||
sku_color TEXT NOT NULL,
|
||||
sku_size TEXT NOT NULL,
|
||||
quantity INTEGER NOT NULL CHECK (quantity > 0 AND typeof(quantity) = 'integer'),
|
||||
max_total_price TEXT NOT NULL CHECK (
|
||||
max_total_price <> ''
|
||||
AND max_total_price NOT GLOB '*[^0-9.]*'
|
||||
AND length(max_total_price) - length(replace(max_total_price, '.', '')) <= 1
|
||||
AND max_total_price <> '.'
|
||||
AND (instr(max_total_price, '.') = 0 OR (
|
||||
instr(max_total_price, '.') > 1
|
||||
AND length(max_total_price) > instr(max_total_price, '.')
|
||||
))
|
||||
),
|
||||
reference_asset_id TEXT,
|
||||
status TEXT NOT NULL CHECK (status IN (
|
||||
'DRAFT', 'PENDING', 'CLAIMED', 'RUNNING', 'WAITING_CONFIRMATION',
|
||||
'PENDING_RETRIAL', 'AUTHORIZED', 'ORDERING', 'WAITING_PAYMENT',
|
||||
'RECONCILIATION_REQUIRED', 'NEEDS_MANUAL', 'SUCCEEDED', 'CANCELED'
|
||||
)),
|
||||
version INTEGER NOT NULL DEFAULT 1 CHECK (version > 0 AND typeof(version) = 'integer'),
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE spec_trials (
|
||||
id TEXT PRIMARY KEY,
|
||||
task_id TEXT NOT NULL REFERENCES tasks(id),
|
||||
attempt INTEGER NOT NULL CHECK (attempt > 0 AND typeof(attempt) = 'integer'),
|
||||
product_title TEXT NOT NULL,
|
||||
selected_color TEXT NOT NULL,
|
||||
selected_size TEXT NOT NULL,
|
||||
unit_price TEXT NOT NULL CHECK (
|
||||
unit_price <> ''
|
||||
AND unit_price NOT GLOB '*[^0-9.]*'
|
||||
AND length(unit_price) - length(replace(unit_price, '.', '')) <= 1
|
||||
AND unit_price <> '.'
|
||||
AND (instr(unit_price, '.') = 0 OR (
|
||||
instr(unit_price, '.') > 1
|
||||
AND length(unit_price) > instr(unit_price, '.')
|
||||
))
|
||||
),
|
||||
total_price TEXT NOT NULL CHECK (
|
||||
total_price <> ''
|
||||
AND total_price NOT GLOB '*[^0-9.]*'
|
||||
AND length(total_price) - length(replace(total_price, '.', '')) <= 1
|
||||
AND total_price <> '.'
|
||||
AND (instr(total_price, '.') = 0 OR (
|
||||
instr(total_price, '.') > 1
|
||||
AND length(total_price) > instr(total_price, '.')
|
||||
))
|
||||
),
|
||||
evidence_sha256 TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
UNIQUE (task_id, attempt),
|
||||
UNIQUE (task_id, id)
|
||||
);
|
||||
|
||||
CREATE TABLE order_authorizations (
|
||||
id TEXT PRIMARY KEY,
|
||||
task_id TEXT NOT NULL REFERENCES tasks(id),
|
||||
spec_trial_id TEXT NOT NULL REFERENCES spec_trials(id),
|
||||
version INTEGER NOT NULL CHECK (version > 0 AND typeof(version) = 'integer'),
|
||||
goods_id TEXT NOT NULL,
|
||||
sku_color TEXT NOT NULL,
|
||||
sku_size TEXT NOT NULL,
|
||||
quantity INTEGER NOT NULL CHECK (quantity > 0 AND typeof(quantity) = 'integer'),
|
||||
authorized_unit_price TEXT NOT NULL CHECK (
|
||||
authorized_unit_price <> ''
|
||||
AND authorized_unit_price NOT GLOB '*[^0-9.]*'
|
||||
AND length(authorized_unit_price) - length(replace(authorized_unit_price, '.', '')) <= 1
|
||||
AND authorized_unit_price <> '.'
|
||||
AND (instr(authorized_unit_price, '.') = 0 OR (
|
||||
instr(authorized_unit_price, '.') > 1
|
||||
AND length(authorized_unit_price) > instr(authorized_unit_price, '.')
|
||||
))
|
||||
),
|
||||
total_price_cap TEXT NOT NULL CHECK (
|
||||
total_price_cap <> ''
|
||||
AND total_price_cap NOT GLOB '*[^0-9.]*'
|
||||
AND length(total_price_cap) - length(replace(total_price_cap, '.', '')) <= 1
|
||||
AND total_price_cap <> '.'
|
||||
AND (instr(total_price_cap, '.') = 0 OR (
|
||||
instr(total_price_cap, '.') > 1
|
||||
AND length(total_price_cap) > instr(total_price_cap, '.')
|
||||
))
|
||||
),
|
||||
note TEXT,
|
||||
status TEXT NOT NULL CHECK (status IN (
|
||||
'PENDING_DELIVERY', 'DELIVERED', 'ACKNOWLEDGED', 'EXECUTING', 'FENCED',
|
||||
'CONSUMED', 'SUPERSEDED', 'EXPIRED'
|
||||
)),
|
||||
created_by TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
expires_at TEXT NOT NULL,
|
||||
UNIQUE (task_id, version),
|
||||
UNIQUE (task_id, id),
|
||||
FOREIGN KEY (task_id, spec_trial_id) REFERENCES spec_trials(task_id, id)
|
||||
);
|
||||
|
||||
CREATE TABLE order_submissions (
|
||||
id TEXT PRIMARY KEY,
|
||||
task_id TEXT NOT NULL REFERENCES tasks(id),
|
||||
authorization_id TEXT NOT NULL REFERENCES order_authorizations(id),
|
||||
command_id TEXT NOT NULL,
|
||||
dry_run_id TEXT NOT NULL,
|
||||
status TEXT NOT NULL CHECK (status IN (
|
||||
'FENCED', 'SUBMITTED', 'RECONCILIATION_REQUIRED', 'MANUAL_RESOLVED'
|
||||
)),
|
||||
verified_unit_price TEXT NOT NULL CHECK (
|
||||
verified_unit_price <> ''
|
||||
AND verified_unit_price NOT GLOB '*[^0-9.]*'
|
||||
AND length(verified_unit_price) - length(replace(verified_unit_price, '.', '')) <= 1
|
||||
AND verified_unit_price <> '.'
|
||||
AND (instr(verified_unit_price, '.') = 0 OR (
|
||||
instr(verified_unit_price, '.') > 1
|
||||
AND length(verified_unit_price) > instr(verified_unit_price, '.')
|
||||
))
|
||||
),
|
||||
quantity_read INTEGER NOT NULL CHECK (quantity_read > 0 AND typeof(quantity_read) = 'integer'),
|
||||
confirm_page_amount TEXT NOT NULL CHECK (
|
||||
confirm_page_amount <> ''
|
||||
AND confirm_page_amount NOT GLOB '*[^0-9.]*'
|
||||
AND length(confirm_page_amount) - length(replace(confirm_page_amount, '.', '')) <= 1
|
||||
AND confirm_page_amount <> '.'
|
||||
AND (instr(confirm_page_amount, '.') = 0 OR (
|
||||
instr(confirm_page_amount, '.') > 1
|
||||
AND length(confirm_page_amount) > instr(confirm_page_amount, '.')
|
||||
))
|
||||
),
|
||||
created_at TEXT NOT NULL,
|
||||
resolved_at TEXT,
|
||||
UNIQUE (authorization_id),
|
||||
UNIQUE (command_id),
|
||||
FOREIGN KEY (task_id, authorization_id) REFERENCES order_authorizations(task_id, id)
|
||||
);
|
||||
|
||||
-- +goose Down
|
||||
DROP TABLE order_submissions;
|
||||
DROP TABLE order_authorizations;
|
||||
DROP TABLE spec_trials;
|
||||
DROP TABLE tasks;
|
||||
@@ -0,0 +1,333 @@
|
||||
-- +goose Up
|
||||
-- v1 的试选/锁旧价记录无法安全推断为单趟执行事实。先在同一事务中拒绝它们,
|
||||
-- 避免删除审计数据后再尝试猜测映射。
|
||||
CREATE TABLE single_pass_upgrade_guard (
|
||||
valid INTEGER NOT NULL CHECK (valid = 1)
|
||||
);
|
||||
|
||||
INSERT INTO single_pass_upgrade_guard (valid)
|
||||
SELECT CASE WHEN
|
||||
(SELECT COUNT(*) FROM spec_trials) = 0
|
||||
AND (SELECT COUNT(*) FROM order_authorizations) = 0
|
||||
AND (SELECT COUNT(*) FROM order_submissions) = 0
|
||||
AND (SELECT COUNT(*) FROM tasks WHERE source <> 'MANUAL' OR status <> 'DRAFT') = 0
|
||||
-- v2 的金额边界是严格正数;不把 v1 中不能无损纳入该边界的数据悄悄改写。
|
||||
AND (SELECT COUNT(*) FROM tasks WHERE
|
||||
max_total_price = ''
|
||||
OR max_total_price GLOB '*[^0-9.]*'
|
||||
OR length(max_total_price) - length(replace(max_total_price, '.', '')) > 1
|
||||
OR max_total_price = '.'
|
||||
OR (instr(max_total_price, '.') > 0 AND (
|
||||
instr(max_total_price, '.') = 1
|
||||
OR length(max_total_price) = instr(max_total_price, '.')
|
||||
OR length(max_total_price) - instr(max_total_price, '.') > 2
|
||||
))
|
||||
OR replace(replace(max_total_price, '.', ''), '0', '') = ''
|
||||
) = 0
|
||||
THEN 1 ELSE 0 END;
|
||||
|
||||
DROP TABLE single_pass_upgrade_guard;
|
||||
|
||||
ALTER TABLE tasks RENAME TO tasks_v1;
|
||||
DROP TABLE order_submissions;
|
||||
DROP TABLE order_authorizations;
|
||||
DROP TABLE spec_trials;
|
||||
|
||||
CREATE TABLE tasks (
|
||||
id TEXT PRIMARY KEY,
|
||||
source TEXT NOT NULL CHECK (source IN ('MANUAL', 'EXCEL', 'ERP')),
|
||||
source_ref TEXT,
|
||||
title TEXT NOT NULL,
|
||||
goods_id TEXT NOT NULL,
|
||||
sku_color TEXT NOT NULL,
|
||||
sku_size TEXT NOT NULL,
|
||||
quantity INTEGER NOT NULL CHECK (quantity > 0 AND typeof(quantity) = 'integer'),
|
||||
max_total_price TEXT NOT NULL CHECK (
|
||||
max_total_price <> ''
|
||||
AND max_total_price NOT GLOB '*[^0-9.]*'
|
||||
AND length(max_total_price) - length(replace(max_total_price, '.', '')) <= 1
|
||||
AND max_total_price <> '.'
|
||||
AND (instr(max_total_price, '.') = 0 OR (
|
||||
instr(max_total_price, '.') > 1
|
||||
AND length(max_total_price) > instr(max_total_price, '.')
|
||||
AND length(max_total_price) - instr(max_total_price, '.') <= 2
|
||||
))
|
||||
AND replace(replace(max_total_price, '.', ''), '0', '') <> ''
|
||||
),
|
||||
reference_asset_id TEXT,
|
||||
status TEXT NOT NULL CHECK (status IN (
|
||||
'DRAFT', 'PENDING', 'CLAIMED', 'ORDERING', 'NEEDS_MANUAL', 'WAITING_PAYMENT',
|
||||
'RECONCILIATION_REQUIRED', 'SUCCEEDED', 'FAILED', 'CANCELED'
|
||||
)),
|
||||
version INTEGER NOT NULL DEFAULT 1 CHECK (version > 0 AND typeof(version) = 'integer'),
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
INSERT INTO tasks (
|
||||
id, source, source_ref, title, goods_id, sku_color, sku_size, quantity, max_total_price,
|
||||
reference_asset_id, status, version, created_at, updated_at
|
||||
)
|
||||
SELECT
|
||||
id, source, source_ref, title, goods_id, sku_color, sku_size, quantity, max_total_price,
|
||||
reference_asset_id, status, version, created_at, updated_at
|
||||
FROM tasks_v1;
|
||||
|
||||
DROP TABLE tasks_v1;
|
||||
|
||||
CREATE TABLE order_authorizations (
|
||||
id TEXT PRIMARY KEY,
|
||||
task_id TEXT NOT NULL REFERENCES tasks(id),
|
||||
task_version INTEGER NOT NULL CHECK (task_version > 0 AND typeof(task_version) = 'integer'),
|
||||
start_key TEXT NOT NULL,
|
||||
goods_id TEXT NOT NULL,
|
||||
sku_color TEXT NOT NULL,
|
||||
sku_size TEXT NOT NULL,
|
||||
quantity INTEGER NOT NULL CHECK (quantity > 0 AND typeof(quantity) = 'integer'),
|
||||
total_price_cap TEXT NOT NULL CHECK (
|
||||
total_price_cap <> ''
|
||||
AND total_price_cap NOT GLOB '*[^0-9.]*'
|
||||
AND length(total_price_cap) - length(replace(total_price_cap, '.', '')) <= 1
|
||||
AND total_price_cap <> '.'
|
||||
AND (instr(total_price_cap, '.') = 0 OR (
|
||||
instr(total_price_cap, '.') > 1
|
||||
AND length(total_price_cap) > instr(total_price_cap, '.')
|
||||
AND length(total_price_cap) - instr(total_price_cap, '.') <= 2
|
||||
))
|
||||
AND replace(replace(total_price_cap, '.', ''), '0', '') <> ''
|
||||
),
|
||||
status TEXT NOT NULL CHECK (status IN ('ACTIVE', 'CLAIMED', 'FENCED', 'CONSUMED', 'EXPIRED', 'ABANDONED')),
|
||||
created_by TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
expires_at TEXT NOT NULL,
|
||||
UNIQUE (task_id, task_version),
|
||||
UNIQUE (start_key, task_id),
|
||||
UNIQUE (task_id, id)
|
||||
);
|
||||
|
||||
CREATE TABLE purchase_attempts (
|
||||
id TEXT PRIMARY KEY,
|
||||
task_id TEXT NOT NULL,
|
||||
authorization_id TEXT NOT NULL,
|
||||
claim_generation INTEGER NOT NULL CHECK (claim_generation > 0 AND typeof(claim_generation) = 'integer'),
|
||||
status TEXT NOT NULL CHECK (status IN ('CLAIMED', 'ORDERING', 'FAILED', 'FENCED', 'ABANDONED')),
|
||||
gate1_unit_price TEXT CHECK (
|
||||
gate1_unit_price IS NULL OR (
|
||||
gate1_unit_price <> ''
|
||||
AND gate1_unit_price NOT GLOB '*[^0-9.]*'
|
||||
AND length(gate1_unit_price) - length(replace(gate1_unit_price, '.', '')) <= 1
|
||||
AND gate1_unit_price <> '.'
|
||||
AND (instr(gate1_unit_price, '.') = 0 OR (
|
||||
instr(gate1_unit_price, '.') > 1
|
||||
AND length(gate1_unit_price) > instr(gate1_unit_price, '.')
|
||||
AND length(gate1_unit_price) - instr(gate1_unit_price, '.') <= 2
|
||||
))
|
||||
AND replace(replace(gate1_unit_price, '.', ''), '0', '') <> ''
|
||||
)
|
||||
),
|
||||
gate2_unit_price TEXT CHECK (
|
||||
gate2_unit_price IS NULL OR (
|
||||
gate2_unit_price <> ''
|
||||
AND gate2_unit_price NOT GLOB '*[^0-9.]*'
|
||||
AND length(gate2_unit_price) - length(replace(gate2_unit_price, '.', '')) <= 1
|
||||
AND gate2_unit_price <> '.'
|
||||
AND (instr(gate2_unit_price, '.') = 0 OR (
|
||||
instr(gate2_unit_price, '.') > 1
|
||||
AND length(gate2_unit_price) > instr(gate2_unit_price, '.')
|
||||
AND length(gate2_unit_price) - instr(gate2_unit_price, '.') <= 2
|
||||
))
|
||||
AND replace(replace(gate2_unit_price, '.', ''), '0', '') <> ''
|
||||
)
|
||||
),
|
||||
quantity_read INTEGER CHECK (quantity_read IS NULL OR (quantity_read > 0 AND typeof(quantity_read) = 'integer')),
|
||||
confirm_amount TEXT CHECK (
|
||||
confirm_amount IS NULL OR (
|
||||
confirm_amount <> ''
|
||||
AND confirm_amount NOT GLOB '*[^0-9.]*'
|
||||
AND length(confirm_amount) - length(replace(confirm_amount, '.', '')) <= 1
|
||||
AND confirm_amount <> '.'
|
||||
AND (instr(confirm_amount, '.') = 0 OR (
|
||||
instr(confirm_amount, '.') > 1
|
||||
AND length(confirm_amount) > instr(confirm_amount, '.')
|
||||
AND length(confirm_amount) - instr(confirm_amount, '.') <= 2
|
||||
))
|
||||
AND replace(replace(confirm_amount, '.', ''), '0', '') <> ''
|
||||
)
|
||||
),
|
||||
failure_code TEXT CHECK (failure_code IS NULL OR failure_code IN (
|
||||
'AUTHORIZATION_EXPIRED', 'LEASE_LOST', 'GATE_1_REJECTED', 'QUANTITY_MISMATCH',
|
||||
'GATE_2_REJECTED', 'GATE_3_REJECTED', 'FENCE_REJECTED', 'SAFE_ABORTED'
|
||||
)),
|
||||
started_at TEXT NOT NULL,
|
||||
finished_at TEXT,
|
||||
UNIQUE (task_id, claim_generation),
|
||||
UNIQUE (task_id, id),
|
||||
UNIQUE (task_id, authorization_id, id),
|
||||
FOREIGN KEY (task_id, authorization_id) REFERENCES order_authorizations(task_id, id)
|
||||
);
|
||||
|
||||
CREATE TABLE order_submissions (
|
||||
id TEXT PRIMARY KEY,
|
||||
task_id TEXT NOT NULL,
|
||||
authorization_id TEXT NOT NULL,
|
||||
attempt_id TEXT NOT NULL,
|
||||
status TEXT NOT NULL CHECK (status IN ('FENCED', 'SUBMITTED', 'RECONCILIATION_REQUIRED', 'MANUAL_RESOLVED')),
|
||||
gate1_unit_price TEXT NOT NULL CHECK (
|
||||
gate1_unit_price <> ''
|
||||
AND gate1_unit_price NOT GLOB '*[^0-9.]*'
|
||||
AND length(gate1_unit_price) - length(replace(gate1_unit_price, '.', '')) <= 1
|
||||
AND gate1_unit_price <> '.'
|
||||
AND (instr(gate1_unit_price, '.') = 0 OR (
|
||||
instr(gate1_unit_price, '.') > 1
|
||||
AND length(gate1_unit_price) > instr(gate1_unit_price, '.')
|
||||
AND length(gate1_unit_price) - instr(gate1_unit_price, '.') <= 2
|
||||
))
|
||||
AND replace(replace(gate1_unit_price, '.', ''), '0', '') <> ''
|
||||
),
|
||||
gate2_unit_price TEXT NOT NULL CHECK (
|
||||
gate2_unit_price <> ''
|
||||
AND gate2_unit_price NOT GLOB '*[^0-9.]*'
|
||||
AND length(gate2_unit_price) - length(replace(gate2_unit_price, '.', '')) <= 1
|
||||
AND gate2_unit_price <> '.'
|
||||
AND (instr(gate2_unit_price, '.') = 0 OR (
|
||||
instr(gate2_unit_price, '.') > 1
|
||||
AND length(gate2_unit_price) > instr(gate2_unit_price, '.')
|
||||
AND length(gate2_unit_price) - instr(gate2_unit_price, '.') <= 2
|
||||
))
|
||||
AND replace(replace(gate2_unit_price, '.', ''), '0', '') <> ''
|
||||
),
|
||||
quantity_read INTEGER NOT NULL CHECK (quantity_read > 0 AND typeof(quantity_read) = 'integer'),
|
||||
confirm_amount TEXT NOT NULL CHECK (
|
||||
confirm_amount <> ''
|
||||
AND confirm_amount NOT GLOB '*[^0-9.]*'
|
||||
AND length(confirm_amount) - length(replace(confirm_amount, '.', '')) <= 1
|
||||
AND confirm_amount <> '.'
|
||||
AND (instr(confirm_amount, '.') = 0 OR (
|
||||
instr(confirm_amount, '.') > 1
|
||||
AND length(confirm_amount) > instr(confirm_amount, '.')
|
||||
AND length(confirm_amount) - instr(confirm_amount, '.') <= 2
|
||||
))
|
||||
AND replace(replace(confirm_amount, '.', ''), '0', '') <> ''
|
||||
),
|
||||
created_at TEXT NOT NULL,
|
||||
resolved_at TEXT,
|
||||
UNIQUE (authorization_id),
|
||||
UNIQUE (attempt_id),
|
||||
FOREIGN KEY (task_id, authorization_id, attempt_id) REFERENCES purchase_attempts(task_id, authorization_id, id)
|
||||
);
|
||||
|
||||
-- +goose Down
|
||||
-- 只有尚未产生任何单趟授权或执行事实的纯 MANUAL/DRAFT 数据才能无损回到 v1。
|
||||
CREATE TABLE single_pass_downgrade_guard (
|
||||
valid INTEGER NOT NULL CHECK (valid = 1)
|
||||
);
|
||||
|
||||
INSERT INTO single_pass_downgrade_guard (valid)
|
||||
SELECT CASE WHEN
|
||||
(SELECT COUNT(*) FROM order_authorizations) = 0
|
||||
AND (SELECT COUNT(*) FROM purchase_attempts) = 0
|
||||
AND (SELECT COUNT(*) FROM order_submissions) = 0
|
||||
AND (SELECT COUNT(*) FROM tasks WHERE source <> 'MANUAL' OR status <> 'DRAFT') = 0
|
||||
THEN 1 ELSE 0 END;
|
||||
|
||||
DROP TABLE single_pass_downgrade_guard;
|
||||
|
||||
ALTER TABLE tasks RENAME TO tasks_v2;
|
||||
DROP TABLE order_submissions;
|
||||
DROP TABLE purchase_attempts;
|
||||
DROP TABLE order_authorizations;
|
||||
|
||||
CREATE TABLE tasks (
|
||||
id TEXT PRIMARY KEY,
|
||||
source TEXT NOT NULL CHECK (source IN ('MANUAL', 'EXCEL', 'ERP')),
|
||||
source_ref TEXT,
|
||||
title TEXT NOT NULL,
|
||||
goods_id TEXT NOT NULL,
|
||||
sku_color TEXT NOT NULL,
|
||||
sku_size TEXT NOT NULL,
|
||||
quantity INTEGER NOT NULL CHECK (quantity > 0 AND typeof(quantity) = 'integer'),
|
||||
max_total_price TEXT NOT NULL CHECK (
|
||||
max_total_price <> ''
|
||||
AND max_total_price NOT GLOB '*[^0-9.]*'
|
||||
AND length(max_total_price) - length(replace(max_total_price, '.', '')) <= 1
|
||||
AND max_total_price <> '.'
|
||||
AND (instr(max_total_price, '.') = 0 OR (
|
||||
instr(max_total_price, '.') > 1
|
||||
AND length(max_total_price) > instr(max_total_price, '.')
|
||||
AND length(max_total_price) - instr(max_total_price, '.') <= 2
|
||||
))
|
||||
),
|
||||
reference_asset_id TEXT,
|
||||
status TEXT NOT NULL CHECK (status IN (
|
||||
'DRAFT', 'PENDING', 'CLAIMED', 'RUNNING', 'WAITING_CONFIRMATION',
|
||||
'PENDING_RETRIAL', 'AUTHORIZED', 'ORDERING', 'WAITING_PAYMENT',
|
||||
'RECONCILIATION_REQUIRED', 'NEEDS_MANUAL', 'SUCCEEDED', 'CANCELED'
|
||||
)),
|
||||
version INTEGER NOT NULL DEFAULT 1 CHECK (version > 0 AND typeof(version) = 'integer'),
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
INSERT INTO tasks (
|
||||
id, source, source_ref, title, goods_id, sku_color, sku_size, quantity, max_total_price,
|
||||
reference_asset_id, status, version, created_at, updated_at
|
||||
)
|
||||
SELECT
|
||||
id, source, source_ref, title, goods_id, sku_color, sku_size, quantity, max_total_price,
|
||||
reference_asset_id, status, version, created_at, updated_at
|
||||
FROM tasks_v2;
|
||||
|
||||
DROP TABLE tasks_v2;
|
||||
|
||||
CREATE TABLE spec_trials (
|
||||
id TEXT PRIMARY KEY,
|
||||
task_id TEXT NOT NULL REFERENCES tasks(id),
|
||||
attempt INTEGER NOT NULL CHECK (attempt > 0 AND typeof(attempt) = 'integer'),
|
||||
product_title TEXT NOT NULL,
|
||||
selected_color TEXT NOT NULL,
|
||||
selected_size TEXT NOT NULL,
|
||||
unit_price TEXT NOT NULL CHECK (unit_price <> '' AND unit_price NOT GLOB '*[^0-9.]*' AND length(unit_price) - length(replace(unit_price, '.', '')) <= 1 AND unit_price <> '.' AND (instr(unit_price, '.') = 0 OR (instr(unit_price, '.') > 1 AND length(unit_price) > instr(unit_price, '.') AND length(unit_price) - instr(unit_price, '.') <= 2))),
|
||||
total_price TEXT NOT NULL CHECK (total_price <> '' AND total_price NOT GLOB '*[^0-9.]*' AND length(total_price) - length(replace(total_price, '.', '')) <= 1 AND total_price <> '.' AND (instr(total_price, '.') = 0 OR (instr(total_price, '.') > 1 AND length(total_price) > instr(total_price, '.') AND length(total_price) - instr(total_price, '.') <= 2))),
|
||||
evidence_sha256 TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
UNIQUE (task_id, attempt),
|
||||
UNIQUE (task_id, id)
|
||||
);
|
||||
|
||||
CREATE TABLE order_authorizations (
|
||||
id TEXT PRIMARY KEY,
|
||||
task_id TEXT NOT NULL REFERENCES tasks(id),
|
||||
spec_trial_id TEXT NOT NULL REFERENCES spec_trials(id),
|
||||
version INTEGER NOT NULL CHECK (version > 0 AND typeof(version) = 'integer'),
|
||||
goods_id TEXT NOT NULL,
|
||||
sku_color TEXT NOT NULL,
|
||||
sku_size TEXT NOT NULL,
|
||||
quantity INTEGER NOT NULL CHECK (quantity > 0 AND typeof(quantity) = 'integer'),
|
||||
authorized_unit_price TEXT NOT NULL CHECK (authorized_unit_price <> '' AND authorized_unit_price NOT GLOB '*[^0-9.]*' AND length(authorized_unit_price) - length(replace(authorized_unit_price, '.', '')) <= 1 AND authorized_unit_price <> '.' AND (instr(authorized_unit_price, '.') = 0 OR (instr(authorized_unit_price, '.') > 1 AND length(authorized_unit_price) > instr(authorized_unit_price, '.') AND length(authorized_unit_price) - instr(authorized_unit_price, '.') <= 2))),
|
||||
total_price_cap TEXT NOT NULL CHECK (total_price_cap <> '' AND total_price_cap NOT GLOB '*[^0-9.]*' AND length(total_price_cap) - length(replace(total_price_cap, '.', '')) <= 1 AND total_price_cap <> '.' AND (instr(total_price_cap, '.') = 0 OR (instr(total_price_cap, '.') > 1 AND length(total_price_cap) > instr(total_price_cap, '.') AND length(total_price_cap) - instr(total_price_cap, '.') <= 2))),
|
||||
note TEXT,
|
||||
status TEXT NOT NULL CHECK (status IN ('PENDING_DELIVERY', 'DELIVERED', 'ACKNOWLEDGED', 'EXECUTING', 'FENCED', 'CONSUMED', 'SUPERSEDED', 'EXPIRED')),
|
||||
created_by TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
expires_at TEXT NOT NULL,
|
||||
UNIQUE (task_id, version),
|
||||
UNIQUE (task_id, id),
|
||||
FOREIGN KEY (task_id, spec_trial_id) REFERENCES spec_trials(task_id, id)
|
||||
);
|
||||
|
||||
CREATE TABLE order_submissions (
|
||||
id TEXT PRIMARY KEY,
|
||||
task_id TEXT NOT NULL REFERENCES tasks(id),
|
||||
authorization_id TEXT NOT NULL REFERENCES order_authorizations(id),
|
||||
command_id TEXT NOT NULL,
|
||||
dry_run_id TEXT NOT NULL,
|
||||
status TEXT NOT NULL CHECK (status IN ('FENCED', 'SUBMITTED', 'RECONCILIATION_REQUIRED', 'MANUAL_RESOLVED')),
|
||||
verified_unit_price TEXT NOT NULL CHECK (verified_unit_price <> '' AND verified_unit_price NOT GLOB '*[^0-9.]*' AND length(verified_unit_price) - length(replace(verified_unit_price, '.', '')) <= 1 AND verified_unit_price <> '.' AND (instr(verified_unit_price, '.') = 0 OR (instr(verified_unit_price, '.') > 1 AND length(verified_unit_price) > instr(verified_unit_price, '.') AND length(verified_unit_price) - instr(verified_unit_price, '.') <= 2))),
|
||||
quantity_read INTEGER NOT NULL CHECK (quantity_read > 0 AND typeof(quantity_read) = 'integer'),
|
||||
confirm_page_amount TEXT NOT NULL CHECK (confirm_page_amount <> '' AND confirm_page_amount NOT GLOB '*[^0-9.]*' AND length(confirm_page_amount) - length(replace(confirm_page_amount, '.', '')) <= 1 AND confirm_page_amount <> '.' AND (instr(confirm_page_amount, '.') = 0 OR (instr(confirm_page_amount, '.') > 1 AND length(confirm_page_amount) > instr(confirm_page_amount, '.') AND length(confirm_page_amount) - instr(confirm_page_amount, '.') <= 2))),
|
||||
created_at TEXT NOT NULL,
|
||||
resolved_at TEXT,
|
||||
UNIQUE (authorization_id),
|
||||
UNIQUE (command_id),
|
||||
FOREIGN KEY (task_id, authorization_id) REFERENCES order_authorizations(task_id, id)
|
||||
);
|
||||
@@ -0,0 +1,53 @@
|
||||
-- +goose Up
|
||||
CREATE TABLE evidence_assets (
|
||||
id TEXT PRIMARY KEY,
|
||||
upload_key TEXT NOT NULL,
|
||||
task_id TEXT NOT NULL,
|
||||
attempt_id TEXT NOT NULL,
|
||||
kind TEXT NOT NULL CHECK (kind = 'SKU_PANEL_GATE_1'),
|
||||
privacy_tier TEXT NOT NULL CHECK (privacy_tier = 'INTERNAL_RAW'),
|
||||
sha256 TEXT NOT NULL CHECK (
|
||||
length(sha256) = 64
|
||||
AND sha256 NOT GLOB '*[^0-9a-f]*'
|
||||
),
|
||||
byte_size INTEGER NOT NULL CHECK (
|
||||
typeof(byte_size) = 'integer'
|
||||
AND byte_size > 0
|
||||
AND byte_size <= 10485760
|
||||
),
|
||||
content_type TEXT NOT NULL CHECK (content_type = 'image/png'),
|
||||
width_px INTEGER NOT NULL CHECK (
|
||||
typeof(width_px) = 'integer'
|
||||
AND width_px > 0
|
||||
AND width_px <= 8192
|
||||
),
|
||||
height_px INTEGER NOT NULL CHECK (
|
||||
typeof(height_px) = 'integer'
|
||||
AND height_px > 0
|
||||
AND height_px <= 8192
|
||||
),
|
||||
storage_key TEXT NOT NULL CHECK (
|
||||
storage_key = substr(sha256, 1, 2) || '/' || sha256 || '.png'
|
||||
),
|
||||
uploaded_by_device_id TEXT NOT NULL CHECK (trim(uploaded_by_device_id) <> ''),
|
||||
captured_at TEXT NOT NULL CHECK (trim(captured_at) <> ''),
|
||||
created_at TEXT NOT NULL CHECK (trim(created_at) <> ''),
|
||||
CHECK (width_px * height_px <= 16777216),
|
||||
UNIQUE (uploaded_by_device_id, upload_key),
|
||||
FOREIGN KEY (task_id, attempt_id) REFERENCES purchase_attempts(task_id, id)
|
||||
);
|
||||
|
||||
CREATE INDEX evidence_assets_task_time_idx
|
||||
ON evidence_assets (task_id, captured_at, created_at, id);
|
||||
|
||||
-- +goose Down
|
||||
-- 已写入的内部原图是审计事实,回滚迁移不得静默删除它们。
|
||||
CREATE TABLE evidence_downgrade_guard (
|
||||
valid INTEGER NOT NULL CHECK (valid = 1)
|
||||
);
|
||||
|
||||
INSERT INTO evidence_downgrade_guard (valid)
|
||||
SELECT CASE WHEN (SELECT COUNT(*) FROM evidence_assets) = 0 THEN 1 ELSE 0 END;
|
||||
|
||||
DROP TABLE evidence_downgrade_guard;
|
||||
DROP TABLE evidence_assets;
|
||||
@@ -0,0 +1,62 @@
|
||||
-- +goose Up
|
||||
CREATE TABLE device_credentials (
|
||||
device_id TEXT PRIMARY KEY CHECK (
|
||||
length(device_id) = 36
|
||||
AND substr(device_id, 9, 1) = '-'
|
||||
AND substr(device_id, 14, 1) = '-'
|
||||
AND substr(device_id, 19, 1) = '-'
|
||||
AND substr(device_id, 24, 1) = '-'
|
||||
AND length(replace(device_id, '-', '')) = 32
|
||||
AND replace(device_id, '-', '') NOT GLOB '*[^0-9a-f]*'
|
||||
AND substr(device_id, 15, 1) = '4'
|
||||
AND substr(device_id, 20, 1) IN ('8', '9', 'a', 'b')
|
||||
),
|
||||
display_name TEXT NOT NULL CHECK (
|
||||
display_name = trim(display_name)
|
||||
AND length(display_name) BETWEEN 1 AND 128
|
||||
),
|
||||
token_sha256 BLOB NOT NULL UNIQUE CHECK (
|
||||
typeof(token_sha256) = 'blob'
|
||||
AND length(token_sha256) = 32
|
||||
),
|
||||
status TEXT NOT NULL CHECK (status IN ('ACTIVE', 'REVOKED')),
|
||||
created_at TEXT NOT NULL CHECK (
|
||||
created_at = trim(created_at)
|
||||
AND length(created_at) >= 20
|
||||
AND substr(created_at, 11, 1) = 'T'
|
||||
AND substr(created_at, -1, 1) = 'Z'
|
||||
AND julianday(created_at) IS NOT NULL
|
||||
),
|
||||
revoked_at TEXT CHECK (
|
||||
revoked_at IS NULL OR (
|
||||
revoked_at = trim(revoked_at)
|
||||
AND length(revoked_at) >= 20
|
||||
AND substr(revoked_at, 11, 1) = 'T'
|
||||
AND substr(revoked_at, -1, 1) = 'Z'
|
||||
AND julianday(revoked_at) IS NOT NULL
|
||||
)
|
||||
),
|
||||
CHECK (
|
||||
(status = 'ACTIVE' AND revoked_at IS NULL)
|
||||
OR (
|
||||
status = 'REVOKED'
|
||||
AND revoked_at IS NOT NULL
|
||||
AND julianday(revoked_at) >= julianday(created_at)
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
CREATE INDEX device_credentials_status_created_idx
|
||||
ON device_credentials (status, created_at, device_id);
|
||||
|
||||
-- +goose Down
|
||||
-- 已签发凭据是安全配置;回滚不得静默删除并让设备身份审计链消失。
|
||||
CREATE TABLE device_credentials_downgrade_guard (
|
||||
valid INTEGER NOT NULL CHECK (valid = 1)
|
||||
);
|
||||
|
||||
INSERT INTO device_credentials_downgrade_guard (valid)
|
||||
SELECT CASE WHEN (SELECT COUNT(*) FROM device_credentials) = 0 THEN 1 ELSE 0 END;
|
||||
|
||||
DROP TABLE device_credentials_downgrade_guard;
|
||||
DROP TABLE device_credentials;
|
||||
@@ -0,0 +1,23 @@
|
||||
# 本机开发环境与解释器缓存
|
||||
.venv/
|
||||
venv/
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
.pytest_cache/
|
||||
.mypy_cache/
|
||||
|
||||
# 运行时生成的日志、截图和其他证据产物不得进入版本库。
|
||||
logs/
|
||||
artifacts/
|
||||
runtime/
|
||||
*.log
|
||||
|
||||
# 本机凭据或环境覆盖仅可保存在未跟踪文件中。
|
||||
.env
|
||||
.env.*
|
||||
secrets/
|
||||
|
||||
# 打包工具生成的本机产物
|
||||
build/
|
||||
dist/
|
||||
*.egg-info/
|
||||
@@ -0,0 +1,19 @@
|
||||
[build-system]
|
||||
requires = ["setuptools>=68"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "cmbuyer-client"
|
||||
version = "0.1.0"
|
||||
description = "cmbuyer 采购工具桌面端"
|
||||
requires-python = ">=3.11"
|
||||
dynamic = ["dependencies"]
|
||||
|
||||
[project.scripts]
|
||||
cmbuyer-client = "cmbuyer_client.app:main"
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
where = ["src"]
|
||||
|
||||
[tool.setuptools.dynamic]
|
||||
dependencies = { file = ["requirements.txt"] }
|
||||
@@ -0,0 +1,9 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# 桌面界面(Qt 官方 Python 绑定)。
|
||||
PySide6
|
||||
# T-101 基线取证使用;只连接显式 serial,不打开或操作拼多多页面。
|
||||
uiautomator2
|
||||
# T-101 直接使用当前 ADB server 的已列出设备对象交给 uiautomator2,禁止 WiFi 自动重连。
|
||||
adbutils>=2.11,<3
|
||||
# 后续截图完整性检查会使用。
|
||||
Pillow
|
||||
@@ -0,0 +1,76 @@
|
||||
"""采集指定 Android 设备的本地基线证据;不打开或操作拼多多页面。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
from pathlib import Path
|
||||
import sys
|
||||
|
||||
|
||||
CLIENT_ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(CLIENT_ROOT / "src"))
|
||||
|
||||
from cmbuyer_client.device.adb import AdbClient, DeviceConnectionError, SubprocessAdbRunner
|
||||
from cmbuyer_client.device.baseline import (
|
||||
BaselineCaptureError,
|
||||
DeviceBaselineCapturer,
|
||||
NoReconnectUiautomatorConnector,
|
||||
)
|
||||
|
||||
|
||||
def parse_arguments(argv: list[str] | None = None) -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(description="采集显式指定 Android 设备的本地基线证据。")
|
||||
parser.add_argument("--serial", required=True, help="ADB device serial;禁止自动选择。")
|
||||
parser.add_argument("--output-dir", required=True, type=Path, help="新建的本地证据目录;不得覆盖已有目录。")
|
||||
parser.add_argument("--timeout", type=float, default=10.0, help="ADB、uiautomator2 RPC 与 ADB socket 超时(秒)。")
|
||||
parser.add_argument("--adb", default="adb", help="adb 可执行文件路径。")
|
||||
return parser.parse_args(argv)
|
||||
|
||||
|
||||
def validate_arguments(arguments: argparse.Namespace) -> None:
|
||||
"""在导入设备库前拒绝危险或无效输入,便于离线测试。"""
|
||||
|
||||
if not arguments.serial.strip():
|
||||
raise ValueError("必须显式提供非空 --serial。")
|
||||
if arguments.timeout <= 0:
|
||||
raise ValueError("--timeout 必须大于 0。")
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
arguments = parse_arguments(argv)
|
||||
try:
|
||||
validate_arguments(arguments)
|
||||
except ValueError as error:
|
||||
print(f"失败:{error}", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
try:
|
||||
import adbutils
|
||||
import uiautomator2 as u2
|
||||
except ImportError:
|
||||
print("失败:缺少 uiautomator2;请在采购工具虚拟环境中运行。", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
client = AdbClient(SubprocessAdbRunner(arguments.adb), timeout_seconds=arguments.timeout)
|
||||
connector = NoReconnectUiautomatorConnector(
|
||||
adbutils.AdbClient(socket_timeout=arguments.timeout).device_list,
|
||||
u2.connect,
|
||||
)
|
||||
capturer = DeviceBaselineCapturer(client, connector, timeout_seconds=arguments.timeout)
|
||||
try:
|
||||
result = capturer.capture(arguments.serial, arguments.output_dir)
|
||||
except (DeviceConnectionError, BaselineCaptureError) as error:
|
||||
# 错误类型只表达状态,不打印 ADB 输出、serial、XML 或页面正文。
|
||||
print(f"基线取证失败:{error}", file=sys.stderr)
|
||||
return 1
|
||||
except OSError:
|
||||
print("基线取证失败:无法创建或发布本地证据目录。", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
print(f"基线取证完成:{result.output_directory}")
|
||||
print(f"manifest:{result.manifest_path}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,75 @@
|
||||
"""打开已验证的拼多多商品直链并采集只读本地证据。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
from pathlib import Path
|
||||
import sys
|
||||
|
||||
|
||||
CLIENT_ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(CLIENT_ROOT / "src"))
|
||||
|
||||
from cmbuyer_client.device.adb import AdbClient, DeviceConnectionError, SubprocessAdbRunner
|
||||
from cmbuyer_client.device.baseline import NoReconnectUiautomatorConnector
|
||||
from cmbuyer_client.pdd.product_open import ProductOpenCapturer, ProductOpenError
|
||||
from cmbuyer_client.pdd.product_url import ProductUrlError, parse_product_url
|
||||
|
||||
|
||||
def parse_arguments(argv: list[str] | None = None) -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(description="打开 canonical 拼多多商品链接并采集只读证据。")
|
||||
parser.add_argument("--serial", required=True, help="ADB device serial;禁止自动选择。")
|
||||
parser.add_argument("--url", required=True, help="唯一允许的 goods.html?goods_id= 直链。")
|
||||
parser.add_argument("--output-dir", required=True, type=Path, help="新建的本地证据目录;不得覆盖已有目录。")
|
||||
parser.add_argument("--timeout", type=float, default=10.0, help="ADB 和只读 RPC 超时(秒)。")
|
||||
parser.add_argument("--adb", default="adb", help="adb 可执行文件路径。")
|
||||
return parser.parse_args(argv)
|
||||
|
||||
|
||||
def validate_arguments(arguments: argparse.Namespace) -> None:
|
||||
if not arguments.serial.strip():
|
||||
raise ValueError("必须显式提供非空 --serial。")
|
||||
if arguments.timeout <= 0:
|
||||
raise ValueError("--timeout 必须大于 0。")
|
||||
parse_product_url(arguments.url)
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
arguments = parse_arguments(argv)
|
||||
try:
|
||||
validate_arguments(arguments)
|
||||
link = parse_product_url(arguments.url)
|
||||
except (ValueError, ProductUrlError) as error:
|
||||
print(f"失败:{error}", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
try:
|
||||
import adbutils
|
||||
import uiautomator2 as u2
|
||||
except ImportError:
|
||||
print("失败:缺少 uiautomator2;请在采购工具虚拟环境中运行。", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
client = AdbClient(SubprocessAdbRunner(arguments.adb), timeout_seconds=arguments.timeout)
|
||||
connector = NoReconnectUiautomatorConnector(
|
||||
adbutils.AdbClient(socket_timeout=arguments.timeout).device_list,
|
||||
u2.connect,
|
||||
)
|
||||
capturer = ProductOpenCapturer(client, connector, timeout_seconds=arguments.timeout)
|
||||
try:
|
||||
result = capturer.open_and_capture(arguments.serial, link.canonical_url, arguments.output_dir)
|
||||
except (DeviceConnectionError, ProductOpenError) as error:
|
||||
# 不打印 ADB 输出、serial、Activity、XML 或页面正文。
|
||||
print(f"商品打开取证失败:{error}", file=sys.stderr)
|
||||
return 1
|
||||
except OSError:
|
||||
print("商品打开取证失败:无法创建或发布本地证据目录。", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
print(f"商品打开取证完成:{result.output_directory}")
|
||||
print(f"manifest:{result.manifest_path}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,103 @@
|
||||
"""采集人工已停在规格面板的三种状态证据;不执行任何页面操作。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
from math import isfinite
|
||||
from pathlib import Path
|
||||
import sys
|
||||
|
||||
|
||||
CLIENT_ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(CLIENT_ROOT / "src"))
|
||||
|
||||
from cmbuyer_client.device.adb import AdbClient, DeviceConnectionError, SubprocessAdbRunner
|
||||
from cmbuyer_client.device.baseline import NoReconnectUiautomatorConnector
|
||||
from cmbuyer_client.pdd.product_url import ProductUrl, ProductUrlError, parse_product_url
|
||||
from cmbuyer_client.pdd.sku_panel_spike import (
|
||||
HUMAN_DECLARED_STATES,
|
||||
SkuPanelEvidenceCapturer,
|
||||
SkuPanelEvidenceError,
|
||||
)
|
||||
|
||||
|
||||
def parse_arguments(argv: list[str] | None = None) -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(description="采集人工已打开的拼多多规格面板状态证据。")
|
||||
parser.add_argument("--serial", required=True, help="ADB device serial;禁止自动选择。")
|
||||
product_source = parser.add_mutually_exclusive_group(required=True)
|
||||
product_source.add_argument("--url", help="唯一 canonical goods.html?goods_id= 直链。")
|
||||
product_source.add_argument("--goods-id", help="纯数字商品标识;仅用于记录证据归属。")
|
||||
parser.add_argument("--state", required=True, choices=sorted(HUMAN_DECLARED_STATES), help="人工声明的面板状态。")
|
||||
parser.add_argument("--output-dir", required=True, type=Path, help="新建的本地证据目录;不得覆盖已有目录。")
|
||||
parser.add_argument("--timeout", type=float, default=10.0, help="ADB 和只读 RPC 超时(秒)。")
|
||||
parser.add_argument("--adb", default="adb", help="adb 可执行文件路径。")
|
||||
return parser.parse_args(argv)
|
||||
|
||||
|
||||
def resolve_product_url(arguments: argparse.Namespace) -> ProductUrl:
|
||||
if isinstance(arguments.url, str):
|
||||
return parse_product_url(arguments.url)
|
||||
if isinstance(arguments.goods_id, str):
|
||||
# 仅使用严格 parser 重新验证并构建,不把输入交给 ADB 或页面。
|
||||
return parse_product_url(f"https://mobile.yangkeduo.com/goods.html?goods_id={arguments.goods_id}")
|
||||
raise ValueError("必须提供 --url 或 --goods-id。")
|
||||
|
||||
|
||||
def validate_arguments(arguments: argparse.Namespace) -> ProductUrl:
|
||||
if not isinstance(arguments.serial, str) or not arguments.serial.strip():
|
||||
raise ValueError("必须显式提供非空 --serial。")
|
||||
if (
|
||||
not isinstance(arguments.timeout, (int, float))
|
||||
or isinstance(arguments.timeout, bool)
|
||||
or arguments.timeout <= 0
|
||||
or not isfinite(arguments.timeout)
|
||||
):
|
||||
raise ValueError("--timeout 必须是大于 0 的有限数值。")
|
||||
if arguments.state not in HUMAN_DECLARED_STATES:
|
||||
raise ValueError("--state 必须是允许的人工声明状态。")
|
||||
return resolve_product_url(arguments)
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
arguments = parse_arguments(argv)
|
||||
try:
|
||||
link = validate_arguments(arguments)
|
||||
except (ValueError, ProductUrlError) as error:
|
||||
print(f"失败:{error}", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
try:
|
||||
import adbutils
|
||||
import uiautomator2 as u2
|
||||
except ImportError:
|
||||
print("失败:缺少 uiautomator2;请在采购工具虚拟环境中运行。", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
client = AdbClient(SubprocessAdbRunner(arguments.adb), timeout_seconds=arguments.timeout)
|
||||
connector = NoReconnectUiautomatorConnector(
|
||||
adbutils.AdbClient(socket_timeout=arguments.timeout).device_list,
|
||||
u2.connect,
|
||||
)
|
||||
capturer = SkuPanelEvidenceCapturer(client, connector, timeout_seconds=arguments.timeout)
|
||||
try:
|
||||
result = capturer.capture(
|
||||
arguments.serial,
|
||||
link.canonical_url,
|
||||
arguments.state,
|
||||
arguments.output_dir,
|
||||
)
|
||||
except (DeviceConnectionError, SkuPanelEvidenceError) as error:
|
||||
# 不打印 ADB 输出、serial、Activity、XML 或页面正文。
|
||||
print(f"规格面板证据采集失败:{error}", file=sys.stderr)
|
||||
return 1
|
||||
except OSError:
|
||||
print("规格面板证据采集失败:无法创建或发布本地证据目录。", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
print(f"规格面板证据采集完成:{result.output_directory}")
|
||||
print(f"manifest:{result.manifest_path}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,84 @@
|
||||
"""恢复 T-103 已取证目标规格、验证现价并保存本地原始截图。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
from math import isfinite
|
||||
from pathlib import Path
|
||||
import sys
|
||||
|
||||
|
||||
CLIENT_ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(CLIENT_ROOT / "src"))
|
||||
|
||||
from cmbuyer_client.device.adb import AdbClient, DeviceConnectionError, SubprocessAdbRunner
|
||||
from cmbuyer_client.device.baseline import NoReconnectUiautomatorConnector
|
||||
from cmbuyer_client.pdd.product_url import ProductUrlError, parse_product_url
|
||||
from cmbuyer_client.pdd.sku_selection import EXPECTED_GOODS_ID, SkuSelectionError, TASK_TO_UI_SELECTION
|
||||
from cmbuyer_client.pdd.sku_selection_runner import SkuSelectionRunError, SkuSelectionRunner
|
||||
|
||||
|
||||
def parse_arguments(argv: list[str] | None = None) -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(description="恢复 T-103 已取证规格并保存本地原始截图。")
|
||||
parser.add_argument("--serial", required=True, help="ADB device serial;禁止自动选择。")
|
||||
parser.add_argument("--url", required=True, help="唯一 canonical goods.html?goods_id= 直链。")
|
||||
parser.add_argument("--color", required=True, help="T-103 任务颜色值。")
|
||||
parser.add_argument("--size", required=True, help="T-103 任务尺码值。")
|
||||
parser.add_argument("--output-dir", required=True, type=Path, help="新建本地目录;不得覆盖已有目录。")
|
||||
parser.add_argument("--timeout", type=float, default=10.0, help="ADB 与设备 RPC 超时(秒)。")
|
||||
parser.add_argument("--adb", default="adb", help="adb 可执行文件路径。")
|
||||
return parser.parse_args(argv)
|
||||
|
||||
|
||||
def validate_arguments(arguments: argparse.Namespace) -> None:
|
||||
if not isinstance(arguments.serial, str) or not arguments.serial.strip():
|
||||
raise ValueError("必须显式提供非空 --serial。")
|
||||
if not isinstance(arguments.timeout, (int, float)) or isinstance(arguments.timeout, bool) or arguments.timeout <= 0 or not isfinite(arguments.timeout):
|
||||
raise ValueError("--timeout 必须是大于 0 的有限数值。")
|
||||
link = parse_product_url(arguments.url)
|
||||
if link.goods_id != EXPECTED_GOODS_ID:
|
||||
raise ValueError("--url 不是 T-103 已取证商品。")
|
||||
if (arguments.color, arguments.size) not in TASK_TO_UI_SELECTION:
|
||||
raise ValueError("--color 与 --size 必须是 T-103 已取证任务值。")
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
arguments = parse_arguments(argv)
|
||||
try:
|
||||
validate_arguments(arguments)
|
||||
except (ValueError, ProductUrlError) as error:
|
||||
print(f"失败:{error}", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
try:
|
||||
import adbutils
|
||||
import uiautomator2 as u2
|
||||
except ImportError:
|
||||
print("失败:缺少 uiautomator2;请在采购工具虚拟环境中运行。", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
runner = SkuSelectionRunner(
|
||||
AdbClient(SubprocessAdbRunner(arguments.adb), timeout_seconds=arguments.timeout),
|
||||
NoReconnectUiautomatorConnector(adbutils.AdbClient(socket_timeout=arguments.timeout).device_list, u2.connect),
|
||||
timeout_seconds=arguments.timeout,
|
||||
)
|
||||
try:
|
||||
result = runner.run(arguments.serial, arguments.url, arguments.color, arguments.size, arguments.output_dir)
|
||||
except (DeviceConnectionError, SkuSelectionRunError, SkuSelectionError) as error:
|
||||
# Flow 可能来自测试替身或未来实现;CLI 不回显任何异常正文,避免泄露节点树或页面文本。
|
||||
print("规格恢复失败:已停止,未发布本地证据目录。", file=sys.stderr)
|
||||
return 1
|
||||
except OSError:
|
||||
print("规格恢复失败:无法创建或发布本地证据目录。", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
print(f"规格恢复完成:{result.output_directory}")
|
||||
print(f"manifest:{result.manifest_path}")
|
||||
print(f"目标规格:{arguments.color} / {arguments.size}")
|
||||
print(f"确认单价:{result.unit_price}")
|
||||
print("页面对应性:请人工核对本地原始截图。")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,41 @@
|
||||
"""本机脱敏 T-103 raw 证据到同级 derived;不连接设备或解析页面语义。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
from pathlib import Path
|
||||
import sys
|
||||
|
||||
|
||||
CLIENT_ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(CLIENT_ROOT / "src"))
|
||||
|
||||
from cmbuyer_client.device.sku_evidence_sanitizer import (
|
||||
SkuEvidenceSanitizationError,
|
||||
sanitize_sku_panel_evidence,
|
||||
)
|
||||
|
||||
|
||||
def parse_arguments(argv: list[str] | None = None) -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(description="将本机 raw 规格面板证据确定性脱敏到同级 derived。")
|
||||
parser.add_argument("--raw-dir", required=True, type=Path, help="仅允许名为 raw 的本机原始证据目录。")
|
||||
parser.add_argument("--output-dir", required=True, type=Path, help="仅允许 raw 同级且名为 derived 的新目录。")
|
||||
return parser.parse_args(argv)
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
arguments = parse_arguments(argv)
|
||||
try:
|
||||
result = sanitize_sku_panel_evidence(arguments.raw_dir, arguments.output_dir)
|
||||
except SkuEvidenceSanitizationError as error:
|
||||
# 错误不回显 raw 路径、manifest/XML、地址、手机号或 serial。
|
||||
print(f"证据脱敏失败:{error}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
print(f"派生证据脱敏完成:{result.output_directory}")
|
||||
print(f"manifest:{result.manifest_path}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,80 @@
|
||||
"""验证 wheel 元数据从 requirements.txt 声明了全部运行时依赖。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
from email import policy
|
||||
from email.parser import BytesParser
|
||||
from pathlib import Path
|
||||
import re
|
||||
import sys
|
||||
import zipfile
|
||||
|
||||
|
||||
def normalize_project_name(name: str) -> str:
|
||||
"""使用足以比较 requirements 与 Core Metadata 的项目名规范化规则。"""
|
||||
|
||||
return re.sub(r"[-_.]+", "-", name).lower()
|
||||
|
||||
|
||||
def dependencies_from_requirements(requirements_file: Path) -> set[str]:
|
||||
"""从唯一依赖来源读取项目名;当前 requirements 不允许间接或可编辑依赖。"""
|
||||
|
||||
dependencies: set[str] = set()
|
||||
for line in requirements_file.read_text(encoding="utf-8").splitlines():
|
||||
requirement = line.partition("#")[0].strip()
|
||||
if not requirement:
|
||||
continue
|
||||
match = re.match(r"[A-Za-z0-9][A-Za-z0-9._-]*", requirement)
|
||||
if match is None:
|
||||
raise ValueError(f"requirements.txt 包含不支持的依赖声明:{requirement}")
|
||||
dependencies.add(normalize_project_name(match.group()))
|
||||
return dependencies
|
||||
|
||||
|
||||
def dependencies_from_wheel(wheel_file: Path) -> set[str]:
|
||||
"""读取 wheel 的 Core Metadata 中声明的 Requires-Dist 项目名。"""
|
||||
|
||||
with zipfile.ZipFile(wheel_file) as wheel:
|
||||
metadata_members = [name for name in wheel.namelist() if name.endswith(".dist-info/METADATA")]
|
||||
if len(metadata_members) != 1:
|
||||
raise ValueError("wheel 中必须恰有一个 .dist-info/METADATA 文件")
|
||||
metadata = BytesParser(policy=policy.default).parsebytes(wheel.read(metadata_members[0]))
|
||||
|
||||
dependencies = set()
|
||||
for requirement in metadata.get_all("Requires-Dist", []):
|
||||
match = re.match(r"[A-Za-z0-9][A-Za-z0-9._-]*", requirement)
|
||||
if match is None:
|
||||
raise ValueError(f"wheel METADATA 包含无效的 Requires-Dist:{requirement}")
|
||||
dependencies.add(normalize_project_name(match.group()))
|
||||
return dependencies
|
||||
|
||||
|
||||
def verify_wheel_metadata(wheel_file: Path, requirements_file: Path) -> set[str]:
|
||||
"""返回没有被 wheel 元数据声明的 requirements 项目名。"""
|
||||
|
||||
return dependencies_from_requirements(requirements_file) - dependencies_from_wheel(wheel_file)
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
parser = argparse.ArgumentParser(description="检查 wheel 是否包含 requirements.txt 的依赖元数据")
|
||||
parser.add_argument("wheel", type=Path, help="待检查的 wheel 文件")
|
||||
parser.add_argument(
|
||||
"--requirements",
|
||||
type=Path,
|
||||
default=Path(__file__).resolve().parents[1] / "requirements.txt",
|
||||
help="唯一依赖来源 requirements.txt 的路径",
|
||||
)
|
||||
arguments = parser.parse_args(argv)
|
||||
|
||||
missing = verify_wheel_metadata(arguments.wheel, arguments.requirements)
|
||||
if missing:
|
||||
print(f"wheel METADATA 缺少依赖:{', '.join(sorted(missing))}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
print("wheel METADATA 已声明 requirements.txt 中的全部依赖。")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,6 @@
|
||||
"""采购工具桌面端包。
|
||||
|
||||
本包当前只提供应用骨架和安全的本地运行基础设施;不包含真机操作或采购流程。
|
||||
"""
|
||||
|
||||
__version__ = "0.1.0"
|
||||
@@ -0,0 +1,7 @@
|
||||
"""支持通过 ``python -m cmbuyer_client`` 启动应用。"""
|
||||
|
||||
from .app import main
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,57 @@
|
||||
"""采购工具的最小桌面应用入口。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from collections.abc import Sequence
|
||||
|
||||
from .logging_policy import configure_application_logger
|
||||
from .runtime import RuntimePaths
|
||||
|
||||
|
||||
def select_application_argv(argv: Sequence[str] | None) -> list[str]:
|
||||
"""保留调用方明确给出的空参数列表,避免改变测试或打包入口的语义。"""
|
||||
|
||||
return list(sys.argv if argv is None else argv)
|
||||
|
||||
|
||||
def main(argv: Sequence[str] | None = None) -> int:
|
||||
"""启动只表达当前工程状态的桌面外壳。
|
||||
|
||||
真机控制和采购执行必须在完成取证并实现后才可接入,因此此入口不导入
|
||||
uiautomator2,也不提供任何会影响采购或支付状态的命令。
|
||||
"""
|
||||
|
||||
try:
|
||||
paths = RuntimePaths.default()
|
||||
logger = configure_application_logger(paths)
|
||||
except OSError as error:
|
||||
print(f"无法创建采购工具运行目录:{error}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
try:
|
||||
from PySide6.QtCore import Qt
|
||||
from PySide6.QtWidgets import QApplication, QLabel, QMainWindow
|
||||
except ImportError:
|
||||
logger.error("缺少 PySide6,无法启动桌面界面。")
|
||||
print("无法启动采购工具:缺少 PySide6。请先安装 requirements.txt 中的依赖。", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
application = QApplication.instance() or QApplication(select_application_argv(argv))
|
||||
application.setApplicationName("采购工具")
|
||||
|
||||
window = QMainWindow()
|
||||
window.setWindowTitle("采购工具")
|
||||
window.setAccessibleName("采购工具")
|
||||
window.setMinimumSize(420, 240)
|
||||
window.resize(560, 320)
|
||||
|
||||
message = QLabel("应用骨架已初始化。\n采购执行功能尚未启用。")
|
||||
message.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
message.setWordWrap(True)
|
||||
message.setAccessibleName("当前状态")
|
||||
window.setCentralWidget(message)
|
||||
|
||||
logger.info("应用已启动;采购执行功能尚未启用。")
|
||||
window.show()
|
||||
return application.exec()
|
||||
@@ -0,0 +1,15 @@
|
||||
"""设备连接与基线取证边界。
|
||||
|
||||
本包只提供显式设备选择、非敏感身份核验和本地基线采集;不包含任何采购页面或订单操作。
|
||||
"""
|
||||
|
||||
from .adb import AdbClient, AdbDevice, CommandResult
|
||||
from .baseline import BaselineCaptureResult, DeviceBaselineCapturer
|
||||
|
||||
__all__ = [
|
||||
"AdbClient",
|
||||
"AdbDevice",
|
||||
"BaselineCaptureResult",
|
||||
"CommandResult",
|
||||
"DeviceBaselineCapturer",
|
||||
]
|
||||
@@ -0,0 +1,282 @@
|
||||
"""ADB 设备清单与物理设备冲突的 fail-closed 边界。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
from typing import Protocol, Sequence
|
||||
|
||||
|
||||
class DeviceConnectionError(RuntimeError):
|
||||
"""显式设备连接边界的基础错误,不携带命令输出或设备敏感内容。"""
|
||||
|
||||
|
||||
class SerialRequiredError(DeviceConnectionError):
|
||||
"""调用方没有明确指定设备 serial。"""
|
||||
|
||||
|
||||
class DeviceNotFoundError(DeviceConnectionError):
|
||||
"""指定 serial 不在 ADB 当前清单中。"""
|
||||
|
||||
|
||||
class DeviceOfflineError(DeviceConnectionError):
|
||||
"""指定设备处于 offline 状态。"""
|
||||
|
||||
|
||||
class DeviceUnauthorizedError(DeviceConnectionError):
|
||||
"""指定设备尚未授权此电脑。"""
|
||||
|
||||
|
||||
class DeviceStateError(DeviceConnectionError):
|
||||
"""指定设备处于其他不可用状态。"""
|
||||
|
||||
|
||||
class DeviceCommandTimeoutError(DeviceConnectionError):
|
||||
"""ADB 命令超过调用方指定的超时。"""
|
||||
|
||||
|
||||
class DeviceCommandError(DeviceConnectionError):
|
||||
"""ADB 命令失败;错误文本刻意不回显设备输出。"""
|
||||
|
||||
|
||||
class DeviceIdentityUnconfirmedError(DeviceConnectionError):
|
||||
"""多条在线通道无法完成同机身份判断,必须由人处理。"""
|
||||
|
||||
|
||||
class DuplicatePhysicalDeviceError(DeviceConnectionError):
|
||||
"""同一物理手机通过多个 ADB 通道同时在线。"""
|
||||
|
||||
|
||||
class IntentLaunchUnconfirmedError(DeviceConnectionError):
|
||||
"""`am start -W` 没有给出可确认的启动成功结果。"""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CommandResult:
|
||||
"""可注入命令执行器的最小、可离线构造结果。"""
|
||||
|
||||
stdout: str
|
||||
stderr: str = ""
|
||||
returncode: int = 0
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class IntentLaunchSummary:
|
||||
"""不含 Activity、页面内容或 ADB 输出的受限启动摘要。"""
|
||||
|
||||
status: str
|
||||
returncode: int
|
||||
|
||||
|
||||
class CommandRunner(Protocol):
|
||||
"""运行 ADB 子命令的可替换边界。"""
|
||||
|
||||
def run(self, arguments: Sequence[str], timeout_seconds: float) -> CommandResult:
|
||||
"""运行参数,不得通过 shell 拼接。"""
|
||||
|
||||
|
||||
class SubprocessAdbRunner:
|
||||
"""使用 subprocess 的生产执行器,所有调用必须带超时。"""
|
||||
|
||||
def __init__(self, executable: str | Path = "adb") -> None:
|
||||
self._executable = str(executable)
|
||||
|
||||
def run(self, arguments: Sequence[str], timeout_seconds: float) -> CommandResult:
|
||||
try:
|
||||
completed = subprocess.run(
|
||||
[self._executable, *arguments],
|
||||
check=False,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
timeout=timeout_seconds,
|
||||
)
|
||||
except subprocess.TimeoutExpired as error:
|
||||
raise DeviceCommandTimeoutError("ADB 命令超时,请检查设备连接后由人工重试。") from error
|
||||
except OSError as error:
|
||||
raise DeviceCommandError("无法启动 ADB,请检查 adb 路径与本机工具链。") from error
|
||||
|
||||
return CommandResult(
|
||||
stdout=completed.stdout,
|
||||
stderr=completed.stderr,
|
||||
returncode=completed.returncode,
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AdbDevice:
|
||||
"""`adb devices -l` 的单行非敏感传输元数据。"""
|
||||
|
||||
serial: str
|
||||
state: str
|
||||
product: str | None = None
|
||||
model: str | None = None
|
||||
device: str | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DeviceInspection:
|
||||
"""选定通道的只读身份结果,原始硬件标识只在内存中参与比较。"""
|
||||
|
||||
device: AdbDevice
|
||||
model: str
|
||||
android_version: str
|
||||
|
||||
|
||||
def parse_adb_devices(output: str) -> list[AdbDevice]:
|
||||
"""解析 `adb devices -l`,忽略标题、空行和 adb 附加提示。"""
|
||||
|
||||
devices: list[AdbDevice] = []
|
||||
for raw_line in output.splitlines():
|
||||
line = raw_line.strip()
|
||||
if not line or line.startswith("List of devices attached") or line.startswith("*"):
|
||||
continue
|
||||
fields = line.split()
|
||||
if len(fields) < 2:
|
||||
continue
|
||||
details = {
|
||||
key: value
|
||||
for field in fields[2:]
|
||||
if ":" in field
|
||||
for key, value in [field.split(":", 1)]
|
||||
}
|
||||
devices.append(
|
||||
AdbDevice(
|
||||
serial=fields[0],
|
||||
state=fields[1],
|
||||
product=details.get("product"),
|
||||
model=details.get("model"),
|
||||
device=details.get("device"),
|
||||
)
|
||||
)
|
||||
return devices
|
||||
|
||||
|
||||
class AdbClient:
|
||||
"""显式 serial 的 ADB 只读查询。
|
||||
|
||||
多个在线通道必须完成硬件身份比对。比对失败时不能用相同 model/product 猜测同一台手机,
|
||||
因为那会把不确定性隐藏成错误的安全结论。
|
||||
"""
|
||||
|
||||
def __init__(self, runner: CommandRunner, timeout_seconds: float = 10.0) -> None:
|
||||
if timeout_seconds <= 0:
|
||||
raise ValueError("timeout_seconds 必须大于 0")
|
||||
self._runner = runner
|
||||
self._timeout_seconds = timeout_seconds
|
||||
|
||||
def inspect(self, serial: str) -> DeviceInspection:
|
||||
"""确认指定通道在线且不与另一在线通道指向同一物理设备。"""
|
||||
|
||||
selected_serial = _require_serial(serial)
|
||||
devices = self.devices()
|
||||
selected = next((device for device in devices if device.serial == selected_serial), None)
|
||||
if selected is None:
|
||||
raise DeviceNotFoundError("指定设备不在 ADB 清单中,请显式检查 serial。")
|
||||
_raise_for_state(selected.state)
|
||||
|
||||
online_devices = [device for device in devices if device.state == "device"]
|
||||
if len(online_devices) > 1:
|
||||
identities: dict[str, frozenset[str]] = {}
|
||||
for candidate in online_devices:
|
||||
try:
|
||||
identities[candidate.serial] = self._physical_identity(candidate)
|
||||
except DeviceConnectionError as error:
|
||||
raise DeviceIdentityUnconfirmedError(
|
||||
"存在多个在线 ADB 通道且身份无法确认,已拒绝选择设备。"
|
||||
) from error
|
||||
|
||||
selected_identity = identities[selected.serial]
|
||||
if any(
|
||||
candidate_serial != selected.serial and selected_identity.intersection(candidate_identity)
|
||||
for candidate_serial, candidate_identity in identities.items()
|
||||
):
|
||||
raise DuplicatePhysicalDeviceError(
|
||||
"同一物理手机的多个 ADB 通道同时在线,已拒绝继续;请仅保留一个通道。"
|
||||
)
|
||||
|
||||
model = self._getprop(selected.serial, "ro.product.model") or selected.model or "unknown"
|
||||
android_version = self._getprop(selected.serial, "ro.build.version.release") or "unknown"
|
||||
return DeviceInspection(device=selected, model=model, android_version=android_version)
|
||||
|
||||
def devices(self) -> list[AdbDevice]:
|
||||
"""读取并解析 ADB 设备清单。"""
|
||||
|
||||
result = self._run_checked(("devices", "-l"))
|
||||
return parse_adb_devices(result.stdout)
|
||||
|
||||
def start_pdd_view_intent(self, serial: str, goods_id: str) -> IntentLaunchSummary:
|
||||
"""以参数数组启动唯一允许的拼多多 ACTION_VIEW Intent。
|
||||
|
||||
这里刻意不提供任意 shell 或任意 package 的执行接口。调用方必须先完成
|
||||
``inspect`` 和应用版本核验;本方法在本层从纯数字 ``goods_id`` 重建 URL,调用方不能
|
||||
把另一个 URL 直接交给 ADB。本方法既不点击控件,也不解析 Activity 或页面文本。
|
||||
"""
|
||||
|
||||
selected_serial = _require_serial(serial)
|
||||
if (
|
||||
not isinstance(goods_id, str)
|
||||
or not goods_id
|
||||
or any(character < "0" or character > "9" for character in goods_id)
|
||||
):
|
||||
raise ValueError("goods_id 必须是纯数字")
|
||||
canonical_url = f"https://mobile.yangkeduo.com/goods.html?goods_id={goods_id}"
|
||||
result = self._run_checked(
|
||||
(
|
||||
"-s",
|
||||
selected_serial,
|
||||
"shell",
|
||||
"am",
|
||||
"start",
|
||||
"-W",
|
||||
"-a",
|
||||
"android.intent.action.VIEW",
|
||||
"-d",
|
||||
canonical_url,
|
||||
"-p",
|
||||
"com.xunmeng.pinduoduo",
|
||||
)
|
||||
)
|
||||
if not any(line.strip() == "Status: ok" for line in result.stdout.splitlines()):
|
||||
raise IntentLaunchUnconfirmedError("商品链接启动结果无法确认,已停止后续取证。")
|
||||
return IntentLaunchSummary(status="ok", returncode=result.returncode)
|
||||
|
||||
def _physical_identity(self, device: AdbDevice) -> frozenset[str]:
|
||||
serialno = self._getprop(device.serial, "ro.serialno")
|
||||
boot_serialno = self._getprop(device.serial, "ro.boot.serialno")
|
||||
identifiers = frozenset(value for value in (serialno, boot_serialno) if value)
|
||||
if identifiers:
|
||||
return identifiers
|
||||
# model/product/device 只能作为展示元数据,不能证明两台同型号设备是同一物理机。
|
||||
raise DeviceIdentityUnconfirmedError("无法读取设备硬件身份摘要。")
|
||||
|
||||
def _getprop(self, serial: str, property_name: str) -> str:
|
||||
result = self._run_checked(("-s", serial, "shell", "getprop", property_name))
|
||||
return result.stdout.strip()
|
||||
|
||||
def _run_checked(self, arguments: Sequence[str]) -> CommandResult:
|
||||
try:
|
||||
result = self._runner.run(arguments, self._timeout_seconds)
|
||||
except subprocess.TimeoutExpired as error:
|
||||
raise DeviceCommandTimeoutError("ADB 命令超时,请检查设备连接后由人工重试。") from error
|
||||
if result.returncode != 0:
|
||||
raise DeviceCommandError("ADB 命令失败,请检查设备连接或授权状态。")
|
||||
return result
|
||||
|
||||
|
||||
def _require_serial(serial: str) -> str:
|
||||
if not isinstance(serial, str) or not serial.strip():
|
||||
raise SerialRequiredError("必须显式提供设备 serial,禁止自动选择设备。")
|
||||
return serial.strip()
|
||||
|
||||
|
||||
def _raise_for_state(state: str) -> None:
|
||||
if state == "device":
|
||||
return
|
||||
if state == "offline":
|
||||
raise DeviceOfflineError("指定设备处于 offline 状态。")
|
||||
if state == "unauthorized":
|
||||
raise DeviceUnauthorizedError("指定设备尚未授权此电脑。")
|
||||
raise DeviceStateError("指定设备不处于可用状态。")
|
||||
@@ -0,0 +1,238 @@
|
||||
"""只读设备基线取证,严格限制在元数据、截图和完整节点树。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime
|
||||
from hashlib import sha256
|
||||
import base64
|
||||
import binascii
|
||||
from io import BytesIO
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
from typing import Any, Protocol
|
||||
from uuid import uuid4
|
||||
from xml.etree import ElementTree
|
||||
|
||||
from adbutils.errors import AdbTimeout
|
||||
from PIL import Image, UnidentifiedImageError
|
||||
from uiautomator2.exceptions import HTTPTimeoutError
|
||||
|
||||
from .adb import AdbClient, DeviceInspection
|
||||
|
||||
|
||||
PDD_PACKAGE = "com.xunmeng.pinduoduo"
|
||||
SCREENSHOT_PARAMS = [1, 80]
|
||||
HIERARCHY_PARAMS = [False, 50]
|
||||
_BASE64_ASCII_WHITESPACE = " \t\r\n"
|
||||
|
||||
|
||||
class BaselineCaptureError(RuntimeError):
|
||||
"""基线取证无法完整落盘时的失败,不会伪造成功产物。"""
|
||||
|
||||
|
||||
class BaselineCaptureTimeoutError(BaselineCaptureError):
|
||||
"""设备基线取证超时;底层异常文本不向 CLI 或日志泄露。"""
|
||||
|
||||
|
||||
class UiAutomatorDevice(Protocol):
|
||||
"""本任务唯一需要的 uiautomator2 只读能力。"""
|
||||
|
||||
def app_info(self, package_name: str) -> dict[str, Any]:
|
||||
"""读取已安装应用元数据。"""
|
||||
|
||||
def jsonrpc_call(self, method: str, params: Any = None, timeout: float = 10) -> Any:
|
||||
"""调用公开 uiautomator2 JSON-RPC 接口。"""
|
||||
|
||||
|
||||
class NoReconnectUiautomatorConnector:
|
||||
"""只把当前 ADB server 已列出的设备对象交给 uiautomator2。
|
||||
|
||||
uiautomator2 直接接收 IP serial 时会在内部尝试 adb disconnect/connect。这里先从已列出设备中
|
||||
取对象再调用 ``u2.connect(device_object)``,避免连接阶段隐式重连已经掉线的 WiFi 通道。
|
||||
"""
|
||||
|
||||
def __init__(self, list_devices: Callable[[], list[Any]], connect: Callable[[Any], UiAutomatorDevice]) -> None:
|
||||
self._list_devices = list_devices
|
||||
self._connect = connect
|
||||
|
||||
def __call__(self, serial: str) -> UiAutomatorDevice:
|
||||
device = next((item for item in self._list_devices() if item.serial == serial), None)
|
||||
if device is None:
|
||||
raise BaselineCaptureError("设备在连接前已从 ADB 清单消失,已拒绝自动重连。")
|
||||
return self._connect(device)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class BaselineCaptureResult:
|
||||
"""已原子发布的基线取证摘要,不包含页面正文或原始 serial。"""
|
||||
|
||||
output_directory: Path
|
||||
manifest_path: Path
|
||||
screenshot_path: Path
|
||||
hierarchy_path: Path
|
||||
|
||||
|
||||
class DeviceBaselineCapturer:
|
||||
"""以先校验通道、后连接、最后原子发布的顺序采集基线。
|
||||
|
||||
截图和 XML 可能包含页面敏感内容,因此仅落在调用方明确指定的本地目录;manifest 只写
|
||||
哈希、设备非敏感元数据和脱敏后的 serial 摘要,绝不嵌入 XML 或页面文本。
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
adb_client: AdbClient,
|
||||
connector: Callable[[str], UiAutomatorDevice],
|
||||
timeout_seconds: float,
|
||||
) -> None:
|
||||
if timeout_seconds <= 0:
|
||||
raise ValueError("timeout_seconds 必须大于 0")
|
||||
self._adb_client = adb_client
|
||||
self._connector = connector
|
||||
self._timeout_seconds = timeout_seconds
|
||||
|
||||
def capture(self, serial: str, output_directory: Path) -> BaselineCaptureResult:
|
||||
"""采集完整基线,任何一步失败均不发布 output_directory。"""
|
||||
|
||||
inspection = self._adb_client.inspect(serial)
|
||||
target = Path(output_directory)
|
||||
if target.exists():
|
||||
raise BaselineCaptureError("输出目录已存在;为防止混入旧证据,拒绝覆盖。")
|
||||
if not target.name:
|
||||
raise BaselineCaptureError("输出目录必须是明确的新目录。")
|
||||
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
staging = target.parent / f".{target.name}.staging-{uuid4().hex}"
|
||||
staging.mkdir()
|
||||
try:
|
||||
device = self._connector(serial)
|
||||
app_info = device.app_info(PDD_PACKAGE)
|
||||
version = _extract_version(app_info)
|
||||
|
||||
screenshot_path = staging / "screenshot.png"
|
||||
screenshot_base64 = device.jsonrpc_call(
|
||||
"takeScreenshot",
|
||||
SCREENSHOT_PARAMS,
|
||||
timeout=self._timeout_seconds,
|
||||
)
|
||||
_save_base64_screenshot(screenshot_base64, screenshot_path)
|
||||
|
||||
hierarchy = device.jsonrpc_call(
|
||||
"dumpWindowHierarchy",
|
||||
HIERARCHY_PARAMS,
|
||||
timeout=self._timeout_seconds,
|
||||
)
|
||||
_validate_hierarchy(hierarchy)
|
||||
hierarchy_path = staging / "hierarchy.xml"
|
||||
hierarchy_path.write_text(hierarchy, encoding="utf-8")
|
||||
|
||||
manifest_path = staging / "manifest.json"
|
||||
manifest_path.write_text(
|
||||
json.dumps(
|
||||
_manifest(inspection, serial, version, screenshot_path, hierarchy_path),
|
||||
ensure_ascii=False,
|
||||
indent=2,
|
||||
sort_keys=True,
|
||||
)
|
||||
+ "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
os.replace(staging, target)
|
||||
except BaselineCaptureError:
|
||||
# 仅删除本次创建、名称带随机标识的暂存目录,绝不触碰调用方原有输出目录。
|
||||
if staging.exists():
|
||||
shutil.rmtree(staging)
|
||||
raise
|
||||
except (AdbTimeout, HTTPTimeoutError, TimeoutError) as error:
|
||||
if staging.exists():
|
||||
shutil.rmtree(staging)
|
||||
raise BaselineCaptureTimeoutError("设备基线取证超时,未发布任何证据产物。") from error
|
||||
except Exception as error:
|
||||
if staging.exists():
|
||||
shutil.rmtree(staging)
|
||||
# uiautomator2/adbutils 可能把 serial、路径或远端响应放入异常文本,不能直接传播到 CLI。
|
||||
raise BaselineCaptureError("设备基线取证未完成,未发布任何证据产物。") from error
|
||||
|
||||
return BaselineCaptureResult(
|
||||
output_directory=target,
|
||||
manifest_path=target / "manifest.json",
|
||||
screenshot_path=target / "screenshot.png",
|
||||
hierarchy_path=target / "hierarchy.xml",
|
||||
)
|
||||
|
||||
|
||||
def _extract_version(app_info: dict[str, Any]) -> str:
|
||||
version = app_info.get("versionName") or app_info.get("version_name")
|
||||
if not isinstance(version, str) or not version.strip():
|
||||
raise BaselineCaptureError("无法读取拼多多版本,拒绝发布不完整取证。")
|
||||
return version.strip()
|
||||
|
||||
|
||||
def _save_base64_screenshot(value: Any, target: Path) -> None:
|
||||
"""规范化常见 ASCII Base64 空白后严格解码;没有 adb screenshot fallback。"""
|
||||
|
||||
if not isinstance(value, str) or not value:
|
||||
raise BaselineCaptureError("截图 RPC 未返回 base64 数据,拒绝发布不完整取证。")
|
||||
try:
|
||||
normalized = value.translate({ord(character): None for character in _BASE64_ASCII_WHITESPACE})
|
||||
raw_image = base64.b64decode(normalized.encode("ascii"), validate=True)
|
||||
except (UnicodeEncodeError, ValueError, binascii.Error) as error:
|
||||
raise BaselineCaptureError("截图 RPC Base64 语法无效,拒绝发布不完整取证。") from error
|
||||
try:
|
||||
with Image.open(BytesIO(raw_image)) as image:
|
||||
image.load()
|
||||
image.save(target, format="PNG")
|
||||
except (UnidentifiedImageError, OSError) as error:
|
||||
raise BaselineCaptureError("截图 RPC 图像数据无效,拒绝发布不完整取证。") from error
|
||||
|
||||
|
||||
def _validate_hierarchy(value: Any) -> None:
|
||||
"""确认 RPC 返回的是完整节点树,不把原始 XML 放进错误或日志。"""
|
||||
|
||||
if not isinstance(value, str) or not value:
|
||||
raise BaselineCaptureError("节点树导出为空,拒绝发布不完整取证。")
|
||||
try:
|
||||
root = ElementTree.fromstring(value)
|
||||
except ElementTree.ParseError as error:
|
||||
raise BaselineCaptureError("节点树不是有效 XML,拒绝发布不完整取证。") from error
|
||||
if root.tag != "hierarchy":
|
||||
raise BaselineCaptureError("节点树根节点无效,拒绝发布不完整取证。")
|
||||
|
||||
|
||||
def _manifest(
|
||||
inspection: DeviceInspection,
|
||||
serial: str,
|
||||
pdd_version: str,
|
||||
screenshot_path: Path,
|
||||
hierarchy_path: Path,
|
||||
) -> dict[str, Any]:
|
||||
"""只序列化审计摘要;页面内容留在 XML 文件,不进入日志或 manifest。"""
|
||||
|
||||
return {
|
||||
"schema_version": 1,
|
||||
"captured_at": datetime.now(UTC).isoformat(),
|
||||
"channel": "wifi" if ":" in serial else "usb",
|
||||
"serial_sha256": sha256(serial.encode("utf-8")).hexdigest(),
|
||||
"device": {
|
||||
"model": inspection.model,
|
||||
"android_version": inspection.android_version,
|
||||
"pdd_package": PDD_PACKAGE,
|
||||
"pdd_version": pdd_version,
|
||||
},
|
||||
"artifacts": [
|
||||
{"path": screenshot_path.name, "sha256": _sha256_file(screenshot_path)},
|
||||
{"path": hierarchy_path.name, "sha256": _sha256_file(hierarchy_path)},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def _sha256_file(path: Path) -> str:
|
||||
digest = sha256()
|
||||
with path.open("rb") as source:
|
||||
for chunk in iter(lambda: source.read(1024 * 1024), b""):
|
||||
digest.update(chunk)
|
||||
return digest.hexdigest()
|
||||
@@ -0,0 +1,580 @@
|
||||
"""T-103 原始规格面板证据的本机确定性隐私脱敏。
|
||||
|
||||
此模块只处理人工采集的本地文件:不连接设备、不识别规格;仅可按已取证的固定
|
||||
几何和严格格式,将跨隐私边界的价格叶节点投影到派生 XML。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from hashlib import sha256
|
||||
import json
|
||||
from pathlib import Path
|
||||
import re
|
||||
import shutil
|
||||
from typing import Any
|
||||
from uuid import uuid4
|
||||
from xml.etree import ElementTree
|
||||
|
||||
from PIL import Image, ImageDraw, UnidentifiedImageError
|
||||
|
||||
from ..pdd.product_url import ProductUrl, ProductUrlError, parse_product_url
|
||||
from ..pdd.sku_panel_state import HUMAN_DECLARED_STATES
|
||||
|
||||
|
||||
SANITIZER_VERSION = "t103-privacy-v5"
|
||||
EXPECTED_GOODS_ID = "937122477375"
|
||||
EXPECTED_PDD_VERSION = "8.17.0"
|
||||
EXPECTED_DEVICE_MODEL = "PKG110"
|
||||
EXPECTED_ANDROID_VERSION = "16"
|
||||
EXPECTED_SCREENSHOT_WIDTH = 1080
|
||||
EXPECTED_SCREENSHOT_HEIGHT = 2376
|
||||
EXPECTED_XML_WIDTH = 1080
|
||||
EXPECTED_XML_HEIGHT = 2376
|
||||
_ARTIFACT_FILES = ("screenshot.png", "hierarchy.xml")
|
||||
_SHA256_RE = re.compile(r"[0-9a-f]{64}\Z")
|
||||
_BOUNDS_RE = re.compile(r"\[(-?\d+),(-?\d+)\]\[(-?\d+),(-?\d+)\]\Z")
|
||||
_FULL_PHONE_RE = re.compile(r"(?:\+?86)?1[3-9]\d{9}")
|
||||
_MASKED_PHONE_RE = re.compile(r"1[3-9]\d\*{4}\d{4}")
|
||||
_MASK_TRANSLATION = str.maketrans({"*": "*", "•": "*", "·": "*", "×": "*", "x": "*", "X": "*"})
|
||||
_SEPARATOR_RE = re.compile(r"[\s\-‐‑‒–—―()()]+")
|
||||
# 这两个槽位来自 T-103 当前第一态、1080×2376 XML 坐标的人工审查。它们不是通用
|
||||
# 页面判据;坐标、文本或结构任何变化都停止发布,交由人重新取证。
|
||||
_CROSSING_PRICE_SLOTS = {
|
||||
(396, 503, 712, 570): "[396,503][712,570]",
|
||||
(730, 503, 895, 570): "[730,503][895,570]",
|
||||
}
|
||||
_CROSSING_PRICE_BOUNDS = frozenset(_CROSSING_PRICE_SLOTS)
|
||||
_PRICE_PROJECTION_ATTRIBUTES = (
|
||||
"bounds",
|
||||
"text",
|
||||
"package",
|
||||
"class",
|
||||
"clickable",
|
||||
"enabled",
|
||||
"visible-to-user",
|
||||
)
|
||||
# 仅接受普通 ASCII 空格,且每个可分隔位置最多一个;禁止换行、折扣、支付/提交文案和
|
||||
# 任何其它字符。前缀捕获组用于区分当前价与至多一个划线/原价候选。
|
||||
# T-103 人工在 live 规格面板确认当前价槽的完整非敏感前缀仅为“快卖完”;不得兼容
|
||||
# 未取证的“快卖光”或其它相近文案。
|
||||
_CROSSING_PRICE_TEXT_RE = re.compile(r" {0,1}(?:(快卖完) {0,1})?[¥¥] {0,1}[1-9]\d*\.\d{2} {0,1}\Z")
|
||||
_CROSSING_PRICE_PREFIX_RE = re.compile(r" {0,1}(?:快卖完 {0,1})?[¥¥] {0,1}[1-9]\d*\.\d{2} {0,1}")
|
||||
_CROSSING_PRICE_ALLOWED_CHARACTERS = frozenset(" 快卖完¥¥0123456789.")
|
||||
|
||||
|
||||
class SkuEvidenceSanitizationError(RuntimeError):
|
||||
"""原始证据不能被安全地发布为派生证据。"""
|
||||
|
||||
|
||||
@dataclass
|
||||
class _CleanupStats:
|
||||
"""仅记录节点数量,供派生 manifest 审计;不记录任何页面文本。"""
|
||||
|
||||
removed_nodes: int = 0
|
||||
cleared_crossing_nodes: int = 0
|
||||
preserved_crossing_price_nodes: int = 0
|
||||
retained_below_nodes: int = 0
|
||||
max_right: int = 0
|
||||
max_bottom: int = 0
|
||||
current_price_candidates: int = 0
|
||||
original_price_candidates: int = 0
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PrivacyMaskConfig:
|
||||
"""仅描述已人工确认的隐私几何区域,绝不承担页面或规格判据。"""
|
||||
|
||||
version: str
|
||||
screenshot_width: int
|
||||
screenshot_height: int
|
||||
xml_width: int
|
||||
xml_height: int
|
||||
privacy_top: int
|
||||
|
||||
|
||||
PRIVACY_MASK_CONFIG = PrivacyMaskConfig(
|
||||
version=SANITIZER_VERSION,
|
||||
screenshot_width=EXPECTED_SCREENSHOT_WIDTH,
|
||||
screenshot_height=EXPECTED_SCREENSHOT_HEIGHT,
|
||||
xml_width=EXPECTED_XML_WIDTH,
|
||||
xml_height=EXPECTED_XML_HEIGHT,
|
||||
# 主审在原始截图确认 y < 540 为收货/手机号区域;整宽遮罩优先保护隐私而非保留版面。
|
||||
privacy_top=540,
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SkuEvidenceSanitizationResult:
|
||||
"""已经原子发布的派生证据位置。"""
|
||||
|
||||
output_directory: Path
|
||||
manifest_path: Path
|
||||
screenshot_path: Path
|
||||
hierarchy_path: Path
|
||||
|
||||
|
||||
def sanitize_sku_panel_evidence(raw_directory: Path, output_directory: Path) -> SkuEvidenceSanitizationResult:
|
||||
"""校验 raw 三文件,并发布同级 ``derived`` 的脱敏副本。
|
||||
|
||||
目标已存在时在读取任何输入前拒绝,避免混入旧派生物或覆盖人工保留文件。
|
||||
"""
|
||||
|
||||
raw = Path(raw_directory)
|
||||
target = Path(output_directory)
|
||||
_validate_directories(raw, target)
|
||||
if target.exists():
|
||||
raise SkuEvidenceSanitizationError("派生证据目录已存在,拒绝覆盖。")
|
||||
|
||||
staging: Path | None = None
|
||||
try:
|
||||
source_manifest_path = _required_file(raw, "manifest.json")
|
||||
source_screenshot_path = _required_file(raw, "screenshot.png")
|
||||
source_hierarchy_path = _required_file(raw, "hierarchy.xml")
|
||||
manifest = _read_source_manifest(source_manifest_path)
|
||||
link, state, source_hashes = _validate_source_manifest(manifest)
|
||||
_verify_source_hashes(source_screenshot_path, source_hierarchy_path, source_hashes)
|
||||
|
||||
staging = raw.parent / f".derived.staging-{uuid4().hex}"
|
||||
staging.mkdir()
|
||||
derived_screenshot_path = staging / "screenshot.png"
|
||||
_sanitize_screenshot(source_screenshot_path, derived_screenshot_path)
|
||||
derived_hierarchy_path = staging / "hierarchy.xml"
|
||||
cleanup_stats = _sanitize_hierarchy(source_hierarchy_path, derived_hierarchy_path)
|
||||
|
||||
derived_manifest_path = staging / "manifest.json"
|
||||
derived_manifest_path.write_text(
|
||||
json.dumps(
|
||||
_derived_manifest(
|
||||
manifest,
|
||||
link,
|
||||
state,
|
||||
source_manifest_path,
|
||||
source_screenshot_path,
|
||||
source_hierarchy_path,
|
||||
derived_screenshot_path,
|
||||
derived_hierarchy_path,
|
||||
cleanup_stats,
|
||||
),
|
||||
ensure_ascii=False,
|
||||
indent=2,
|
||||
sort_keys=True,
|
||||
)
|
||||
+ "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
_publish_staging(staging, target)
|
||||
except SkuEvidenceSanitizationError:
|
||||
_clean_staging(staging)
|
||||
raise
|
||||
except (OSError, ValueError, ElementTree.ParseError, UnidentifiedImageError) as error:
|
||||
_clean_staging(staging)
|
||||
# 原始异常可能含文件路径、JSON/XML 文本或其他敏感内容,不能向 CLI/日志传播。
|
||||
raise SkuEvidenceSanitizationError("原始证据无法安全脱敏,未发布任何派生产物。") from error
|
||||
except Exception as error:
|
||||
_clean_staging(staging)
|
||||
raise SkuEvidenceSanitizationError("原始证据脱敏未完成,未发布任何派生产物。") from error
|
||||
|
||||
return SkuEvidenceSanitizationResult(
|
||||
output_directory=target,
|
||||
manifest_path=target / "manifest.json",
|
||||
screenshot_path=target / "screenshot.png",
|
||||
hierarchy_path=target / "hierarchy.xml",
|
||||
)
|
||||
|
||||
|
||||
def _validate_directories(raw: Path, target: Path) -> None:
|
||||
if raw.name != "raw" or not raw.is_dir():
|
||||
raise SkuEvidenceSanitizationError("原始证据目录必须是存在的 raw 目录。")
|
||||
if target.name != "derived" or target.parent != raw.parent:
|
||||
raise SkuEvidenceSanitizationError("派生证据目录必须是 raw 同级的 derived 目录。")
|
||||
|
||||
|
||||
def _required_file(raw: Path, filename: str) -> Path:
|
||||
candidate = raw / filename
|
||||
if not candidate.is_file():
|
||||
raise SkuEvidenceSanitizationError("原始证据文件集合不完整。")
|
||||
return candidate
|
||||
|
||||
|
||||
def _read_source_manifest(path: Path) -> dict[str, Any]:
|
||||
try:
|
||||
value = json.loads(path.read_text(encoding="utf-8"))
|
||||
except (OSError, UnicodeDecodeError, json.JSONDecodeError) as error:
|
||||
raise SkuEvidenceSanitizationError("原始证据 manifest 无效。") from error
|
||||
if not isinstance(value, dict):
|
||||
raise SkuEvidenceSanitizationError("原始证据 manifest 结构无效。")
|
||||
return value
|
||||
|
||||
|
||||
def _validate_source_manifest(manifest: dict[str, Any]) -> tuple[ProductUrl, str, dict[str, str]]:
|
||||
product = manifest.get("product")
|
||||
device = manifest.get("device")
|
||||
state = manifest.get("human_declared_state")
|
||||
if manifest.get("schema_version") != 1 or not isinstance(product, dict) or not isinstance(device, dict):
|
||||
raise SkuEvidenceSanitizationError("原始证据 manifest 缺少必要元数据。")
|
||||
canonical_url = product.get("canonical_url")
|
||||
goods_id = product.get("goods_id")
|
||||
try:
|
||||
link = parse_product_url(canonical_url)
|
||||
except ProductUrlError as error:
|
||||
raise SkuEvidenceSanitizationError("原始证据商品元数据不匹配。") from error
|
||||
if (
|
||||
link.goods_id != EXPECTED_GOODS_ID
|
||||
or goods_id != EXPECTED_GOODS_ID
|
||||
or device.get("model") != EXPECTED_DEVICE_MODEL
|
||||
or device.get("android_version") != EXPECTED_ANDROID_VERSION
|
||||
or device.get("pdd_version") != EXPECTED_PDD_VERSION
|
||||
or device.get("pdd_package") != "com.xunmeng.pinduoduo"
|
||||
or not isinstance(state, str)
|
||||
or state not in HUMAN_DECLARED_STATES
|
||||
):
|
||||
raise SkuEvidenceSanitizationError("原始证据元数据与脱敏配置不匹配。")
|
||||
return link, state, _artifact_hashes(manifest)
|
||||
|
||||
|
||||
def _artifact_hashes(manifest: dict[str, Any]) -> dict[str, str]:
|
||||
artifacts = manifest.get("artifacts")
|
||||
if not isinstance(artifacts, list):
|
||||
raise SkuEvidenceSanitizationError("原始证据 manifest 缺少文件校验信息。")
|
||||
hashes: dict[str, str] = {}
|
||||
for artifact in artifacts:
|
||||
if not isinstance(artifact, dict):
|
||||
raise SkuEvidenceSanitizationError("原始证据 manifest 文件校验信息无效。")
|
||||
path = artifact.get("path")
|
||||
digest = artifact.get("sha256")
|
||||
if path not in _ARTIFACT_FILES or path in hashes or not isinstance(digest, str) or not _SHA256_RE.fullmatch(digest):
|
||||
raise SkuEvidenceSanitizationError("原始证据 manifest 文件校验信息无效。")
|
||||
hashes[path] = digest
|
||||
if set(hashes) != set(_ARTIFACT_FILES):
|
||||
raise SkuEvidenceSanitizationError("原始证据 manifest 文件校验信息不完整。")
|
||||
return hashes
|
||||
|
||||
|
||||
def _verify_source_hashes(screenshot_path: Path, hierarchy_path: Path, expected: dict[str, str]) -> None:
|
||||
if (
|
||||
_sha256_file(screenshot_path) != expected["screenshot.png"]
|
||||
or _sha256_file(hierarchy_path) != expected["hierarchy.xml"]
|
||||
):
|
||||
raise SkuEvidenceSanitizationError("原始证据文件校验失败。")
|
||||
|
||||
|
||||
def _sanitize_screenshot(source: Path, target: Path) -> None:
|
||||
try:
|
||||
with Image.open(source) as image:
|
||||
image.load()
|
||||
if image.format != "PNG" or image.size != (
|
||||
PRIVACY_MASK_CONFIG.screenshot_width,
|
||||
PRIVACY_MASK_CONFIG.screenshot_height,
|
||||
):
|
||||
raise SkuEvidenceSanitizationError("原始截图分辨率或格式与脱敏配置不匹配。")
|
||||
sanitized = image.convert("RGBA")
|
||||
except SkuEvidenceSanitizationError:
|
||||
raise
|
||||
except (OSError, UnidentifiedImageError) as error:
|
||||
raise SkuEvidenceSanitizationError("原始截图无效。") from error
|
||||
|
||||
# 用不透明黑色覆盖 y < 540,保证截图与 XML 使用相同的隐私几何边界。
|
||||
ImageDraw.Draw(sanitized).rectangle(
|
||||
(0, 0, PRIVACY_MASK_CONFIG.screenshot_width - 1, PRIVACY_MASK_CONFIG.privacy_top - 1),
|
||||
fill=(0, 0, 0, 255),
|
||||
)
|
||||
sanitized.save(target, format="PNG", optimize=False, compress_level=9)
|
||||
|
||||
|
||||
def _sanitize_hierarchy(source: Path, target: Path) -> _CleanupStats:
|
||||
try:
|
||||
root = ElementTree.parse(source).getroot()
|
||||
except (OSError, ElementTree.ParseError) as error:
|
||||
raise SkuEvidenceSanitizationError("原始节点树无效。") from error
|
||||
if root.tag != "hierarchy":
|
||||
raise SkuEvidenceSanitizationError("原始节点树结构不匹配。")
|
||||
if not list(root):
|
||||
raise SkuEvidenceSanitizationError("原始节点树结构不匹配。")
|
||||
stats = _CleanupStats()
|
||||
_clear_node_text(root)
|
||||
for child in list(root):
|
||||
_sanitize_node(root, child, stats)
|
||||
_require_expected_xml_coordinate_space(stats)
|
||||
if stats.removed_nodes < 1 or stats.retained_below_nodes < 1:
|
||||
raise SkuEvidenceSanitizationError("原始节点树未满足隐私几何结构。")
|
||||
_require_safe_crossing_price_projection(stats)
|
||||
if _contains_phone(root):
|
||||
raise SkuEvidenceSanitizationError("派生节点树仍包含手机号,拒绝发布。")
|
||||
ElementTree.ElementTree(root).write(target, encoding="utf-8", xml_declaration=True)
|
||||
return stats
|
||||
|
||||
|
||||
def _sanitize_node(parent: ElementTree.Element, node: ElementTree.Element, stats: _CleanupStats) -> None:
|
||||
if node.tag != "node":
|
||||
raise SkuEvidenceSanitizationError("原始节点树结构不匹配。")
|
||||
bounds = _parse_bounds(node.get("bounds"))
|
||||
_observe_bounds(stats, bounds)
|
||||
position = _vertical_position(bounds)
|
||||
if position == "private":
|
||||
# 私有带内的父节点不可以悄然包含下方子节点,否则会把仍需审计的下方内容一起丢失。
|
||||
for descendant in node.iter("node"):
|
||||
descendant_bounds = _parse_bounds(descendant.get("bounds"))
|
||||
_observe_bounds(stats, descendant_bounds)
|
||||
if _vertical_position(descendant_bounds) != "private":
|
||||
raise SkuEvidenceSanitizationError("原始节点树 bounds 结构不匹配。")
|
||||
stats.removed_nodes += sum(1 for _ in node.iter("node"))
|
||||
parent.remove(node)
|
||||
return
|
||||
if position == "crossing":
|
||||
if bounds in _CROSSING_PRICE_BOUNDS and node.get("text"):
|
||||
_project_crossing_price_node(node, bounds, stats)
|
||||
else:
|
||||
# 全屏/跨界容器可保留其下方子节点,但自身所有属性和文本都可能含地址或手机号。
|
||||
_clear_node_text(node)
|
||||
stats.cleared_crossing_nodes += 1
|
||||
else:
|
||||
stats.retained_below_nodes += 1
|
||||
for child in list(node):
|
||||
_sanitize_node(node, child, stats)
|
||||
|
||||
|
||||
def _parse_bounds(value: object) -> tuple[int, int, int, int]:
|
||||
if not isinstance(value, str):
|
||||
raise SkuEvidenceSanitizationError("原始节点树 bounds 缺失或无效。")
|
||||
match = _BOUNDS_RE.fullmatch(value)
|
||||
if match is None:
|
||||
raise SkuEvidenceSanitizationError("原始节点树 bounds 缺失或无效。")
|
||||
left, top, right, bottom = (int(group) for group in match.groups())
|
||||
if not (0 <= left < right and 0 <= top < bottom):
|
||||
raise SkuEvidenceSanitizationError("原始节点树 bounds 缺失或无效。")
|
||||
return left, top, right, bottom
|
||||
|
||||
|
||||
def _observe_bounds(stats: _CleanupStats, bounds: tuple[int, int, int, int]) -> None:
|
||||
_, _, right, bottom = bounds
|
||||
stats.max_right = max(stats.max_right, right)
|
||||
stats.max_bottom = max(stats.max_bottom, bottom)
|
||||
|
||||
|
||||
def _require_expected_xml_coordinate_space(stats: _CleanupStats) -> None:
|
||||
if (
|
||||
stats.max_right != PRIVACY_MASK_CONFIG.xml_width
|
||||
or stats.max_bottom != PRIVACY_MASK_CONFIG.xml_height
|
||||
):
|
||||
raise SkuEvidenceSanitizationError(
|
||||
f"原始节点树坐标范围不匹配(observed {stats.max_right}x{stats.max_bottom})。"
|
||||
)
|
||||
|
||||
|
||||
def _vertical_position(bounds: tuple[int, int, int, int]) -> str:
|
||||
_, top, _, bottom = bounds
|
||||
if bottom <= PRIVACY_MASK_CONFIG.privacy_top:
|
||||
return "private"
|
||||
if top >= PRIVACY_MASK_CONFIG.privacy_top:
|
||||
return "below"
|
||||
return "crossing"
|
||||
|
||||
|
||||
def _project_crossing_price_node(
|
||||
node: ElementTree.Element,
|
||||
bounds: tuple[int, int, int, int],
|
||||
stats: _CleanupStats,
|
||||
) -> None:
|
||||
"""投影唯一允许的跨界价格叶节点;任何结构漂移一律拒绝发布。"""
|
||||
|
||||
if (
|
||||
len(node) != 0
|
||||
or node.get("package") != "com.xunmeng.pinduoduo"
|
||||
or node.get("class") != "android.widget.TextView"
|
||||
or node.get("clickable") != "false"
|
||||
or node.get("enabled") != "true"
|
||||
or node.get("visible-to-user") != "true"
|
||||
):
|
||||
raise SkuEvidenceSanitizationError("跨界价格节点结构不匹配,拒绝发布。")
|
||||
text = node.get("text")
|
||||
if text is None:
|
||||
raise SkuEvidenceSanitizationError("跨界价格节点文本不匹配,拒绝发布。")
|
||||
match = _CROSSING_PRICE_TEXT_RE.fullmatch(text)
|
||||
if match is None:
|
||||
raise _crossing_price_text_mismatch_error(bounds, text)
|
||||
|
||||
# 只有这七项经上述检查后可进入派生 XML;尤其不复制 content-desc、resource-id 等原始属性。
|
||||
node.attrib = {attribute: node.attrib[attribute] for attribute in _PRICE_PROJECTION_ATTRIBUTES}
|
||||
node.text = None
|
||||
node.tail = None
|
||||
stats.preserved_crossing_price_nodes += 1
|
||||
if match.group(1) is not None:
|
||||
stats.current_price_candidates += 1
|
||||
else:
|
||||
stats.original_price_candidates += 1
|
||||
|
||||
|
||||
def _crossing_price_text_mismatch_error(
|
||||
bounds: tuple[int, int, int, int],
|
||||
text: str,
|
||||
) -> SkuEvidenceSanitizationError:
|
||||
"""仅输出固定槽位与 reason,避免将任意 raw 正文带入 CLI 或日志。"""
|
||||
|
||||
reason = _crossing_price_text_mismatch_reason(text)
|
||||
slot = _CROSSING_PRICE_SLOTS[bounds]
|
||||
return SkuEvidenceSanitizationError(f"跨界价格节点文本不匹配:slot={slot};reason={reason}。")
|
||||
|
||||
|
||||
def _crossing_price_text_mismatch_reason(text: str) -> str:
|
||||
"""将未匹配文本归类为受控枚举;返回值绝不包含原始片段。"""
|
||||
|
||||
if "\r" in text or "\n" in text:
|
||||
return "newline"
|
||||
if any(character.isspace() and character != " " for character in text):
|
||||
return "non_ascii_whitespace"
|
||||
if any(marker in text for marker in ("提交订单", "支付", "下单", "优惠")):
|
||||
return "extra_or_order"
|
||||
without_one_leading_space = text[1:] if text.startswith(" ") else text
|
||||
if without_one_leading_space.startswith("快要抢光"):
|
||||
return "observed_prefix_kuaiyaoqiangguang"
|
||||
if without_one_leading_space.startswith("快") and not without_one_leading_space.startswith("快卖完"):
|
||||
return "known_prefix_missing"
|
||||
if "¥" not in text and "¥" not in text:
|
||||
return "currency_missing"
|
||||
if _CROSSING_PRICE_PREFIX_RE.match(text) is not None:
|
||||
return "extra_or_order"
|
||||
if any(character not in _CROSSING_PRICE_ALLOWED_CHARACTERS for character in text):
|
||||
return "forbidden_characters"
|
||||
return "amount_shape"
|
||||
|
||||
|
||||
def _require_safe_crossing_price_projection(stats: _CleanupStats) -> None:
|
||||
"""当前价必须唯一;原价仅可选且唯一,避免把任意金额释放为价格证据。"""
|
||||
|
||||
if (
|
||||
stats.current_price_candidates != 1
|
||||
or stats.original_price_candidates > 1
|
||||
or stats.preserved_crossing_price_nodes != stats.current_price_candidates + stats.original_price_candidates
|
||||
):
|
||||
raise SkuEvidenceSanitizationError("跨界价格候选不唯一或缺失,拒绝发布。")
|
||||
|
||||
|
||||
def _clear_node_text(node: ElementTree.Element) -> None:
|
||||
node.attrib = {"bounds": node.attrib["bounds"]} if "bounds" in node.attrib else {}
|
||||
node.text = None
|
||||
node.tail = None
|
||||
|
||||
|
||||
def _contains_phone(root: ElementTree.Element) -> bool:
|
||||
"""逐项与跨节点复检电话,避免分隔符、遮罩字符或节点切分绕过。"""
|
||||
|
||||
all_values: list[str] = []
|
||||
content_values: list[str] = []
|
||||
for element in root.iter():
|
||||
if element.text:
|
||||
all_values.append(element.text)
|
||||
content_values.append(element.text)
|
||||
for attribute, value in element.attrib.items():
|
||||
all_values.append(value)
|
||||
if attribute != "bounds":
|
||||
content_values.append(value)
|
||||
if element.tail:
|
||||
all_values.append(element.tail)
|
||||
content_values.append(element.tail)
|
||||
normalized_values = [_normalize_phone_value(value) for value in all_values]
|
||||
normalized_all_document = "".join(normalized_values)
|
||||
normalized_document = "".join(_normalize_phone_value(value) for value in content_values)
|
||||
return (
|
||||
any(_matches_phone(value) for value in normalized_values)
|
||||
or _matches_phone(normalized_all_document)
|
||||
or _matches_phone(normalized_document)
|
||||
)
|
||||
|
||||
|
||||
def _normalize_phone_value(value: str) -> str:
|
||||
return _SEPARATOR_RE.sub("", value.translate(_MASK_TRANSLATION))
|
||||
|
||||
|
||||
def _matches_phone(value: str) -> bool:
|
||||
return _FULL_PHONE_RE.search(value) is not None or _MASKED_PHONE_RE.search(value) is not None
|
||||
|
||||
|
||||
def _derived_manifest(
|
||||
source_manifest: dict[str, Any],
|
||||
link: ProductUrl,
|
||||
state: str,
|
||||
source_manifest_path: Path,
|
||||
source_screenshot_path: Path,
|
||||
source_hierarchy_path: Path,
|
||||
derived_screenshot_path: Path,
|
||||
derived_hierarchy_path: Path,
|
||||
cleanup_stats: _CleanupStats,
|
||||
) -> dict[str, Any]:
|
||||
device = source_manifest["device"]
|
||||
return {
|
||||
"schema_version": 1,
|
||||
"privacy_tier": "SANITIZED",
|
||||
"sanitizer_version": PRIVACY_MASK_CONFIG.version,
|
||||
"screenshot_space": {
|
||||
"width": PRIVACY_MASK_CONFIG.screenshot_width,
|
||||
"height": PRIVACY_MASK_CONFIG.screenshot_height,
|
||||
"privacy_mask_rectangle": [
|
||||
0,
|
||||
0,
|
||||
PRIVACY_MASK_CONFIG.screenshot_width,
|
||||
PRIVACY_MASK_CONFIG.privacy_top,
|
||||
],
|
||||
},
|
||||
"xml_coordinate_space": {
|
||||
"width": PRIVACY_MASK_CONFIG.xml_width,
|
||||
"height": PRIVACY_MASK_CONFIG.xml_height,
|
||||
"privacy_mask_rectangle": [0, 0, PRIVACY_MASK_CONFIG.xml_width, PRIVACY_MASK_CONFIG.privacy_top],
|
||||
"observed_max": {"right": cleanup_stats.max_right, "bottom": cleanup_stats.max_bottom},
|
||||
},
|
||||
"privacy_cleanup": {
|
||||
"removed_nodes": cleanup_stats.removed_nodes,
|
||||
"cleared_crossing_nodes": cleanup_stats.cleared_crossing_nodes,
|
||||
"preserved_crossing_price_nodes": cleanup_stats.preserved_crossing_price_nodes,
|
||||
"retained_below_nodes": cleanup_stats.retained_below_nodes,
|
||||
"max_right": cleanup_stats.max_right,
|
||||
"max_bottom": cleanup_stats.max_bottom,
|
||||
},
|
||||
"product": {"goods_id": link.goods_id},
|
||||
"human_declared_state": state,
|
||||
"device": {
|
||||
"model": device["model"],
|
||||
"android_version": device.get("android_version"),
|
||||
"pdd_package": device["pdd_package"],
|
||||
"pdd_version": device["pdd_version"],
|
||||
},
|
||||
# source hashes stay only in the local derived manifest; no raw path, serial or body is retained.
|
||||
"source": {
|
||||
"manifest_sha256": _sha256_file(source_manifest_path),
|
||||
"artifacts": [
|
||||
{"path": "screenshot.png", "sha256": _sha256_file(source_screenshot_path)},
|
||||
{"path": "hierarchy.xml", "sha256": _sha256_file(source_hierarchy_path)},
|
||||
],
|
||||
},
|
||||
"derived": {
|
||||
"artifacts": [
|
||||
{"path": "screenshot.png", "sha256": _sha256_file(derived_screenshot_path)},
|
||||
{"path": "hierarchy.xml", "sha256": _sha256_file(derived_hierarchy_path)},
|
||||
]
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _sha256_file(path: Path) -> str:
|
||||
digest = sha256()
|
||||
with path.open("rb") as source:
|
||||
for chunk in iter(lambda: source.read(1024 * 1024), b""):
|
||||
digest.update(chunk)
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def _clean_staging(staging: Path | None) -> None:
|
||||
if staging is not None and staging.exists():
|
||||
shutil.rmtree(staging)
|
||||
|
||||
|
||||
def _publish_staging(staging: Path, target: Path) -> None:
|
||||
"""发布前二次检查,并使用目录 rename 而不是会覆盖目标的 replace。"""
|
||||
|
||||
if target.exists():
|
||||
raise SkuEvidenceSanitizationError("派生证据目录已存在,拒绝覆盖。")
|
||||
try:
|
||||
staging.rename(target)
|
||||
except OSError as error:
|
||||
# 竞态中新目标出现或文件系统拒绝 rename 时一律不尝试覆盖或重试。
|
||||
raise SkuEvidenceSanitizationError("派生证据目录发布失败,未覆盖已有目录。") from error
|
||||
@@ -0,0 +1,66 @@
|
||||
"""采购工具日志的最小脱敏策略。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from pathlib import Path
|
||||
import re
|
||||
|
||||
from .runtime import RuntimePaths
|
||||
|
||||
|
||||
LOGGER_NAME = "cmbuyer_client"
|
||||
REDACTED = "[已隐藏]"
|
||||
|
||||
_SENSITIVE_KEY_PATTERN = (
|
||||
r"token|authorization|password|secret|api[_-]?key|"
|
||||
r"address|phone|mobile|payment|pay|card|bank[_-]?account|"
|
||||
r"令牌|授权|密码|密钥|地址|手机号|电话|支付|银行卡"
|
||||
)
|
||||
_KEY_VALUE_PATTERN = re.compile(
|
||||
rf"(?P<key>{_SENSITIVE_KEY_PATTERN})\s*(?P<separator>[:=])\s*"
|
||||
r"(?P<value>\"[^\"]*\"|'[^']*'|[^\s,;]+)",
|
||||
flags=re.IGNORECASE,
|
||||
)
|
||||
_PHONE_PATTERN = re.compile(r"(?<!\d)1[3-9]\d{9}(?!\d)")
|
||||
|
||||
|
||||
def redact_text(message: str) -> str:
|
||||
"""移除日志文本中的凭据、地址、手机号和支付字段值。"""
|
||||
|
||||
def replace_key_value(match: re.Match[str]) -> str:
|
||||
return f"{match.group('key')}{match.group('separator')}{REDACTED}"
|
||||
|
||||
redacted = _KEY_VALUE_PATTERN.sub(replace_key_value, message)
|
||||
return _PHONE_PATTERN.sub(REDACTED, redacted)
|
||||
|
||||
|
||||
class SensitiveDataFilter(logging.Filter):
|
||||
"""在任何 handler 格式化记录前,清除敏感字段。
|
||||
|
||||
该过滤器在日志写入前替换 ``msg`` 与 ``args``,确保文件 handler 不会得到原始值。
|
||||
"""
|
||||
|
||||
def filter(self, record: logging.LogRecord) -> bool:
|
||||
record.msg = redact_text(record.getMessage())
|
||||
record.args = ()
|
||||
return True
|
||||
|
||||
|
||||
def configure_application_logger(paths: RuntimePaths) -> logging.Logger:
|
||||
"""配置唯一的 UTF-8 文件日志,并确保其先经过脱敏过滤。"""
|
||||
|
||||
paths.ensure_exists()
|
||||
logger = logging.getLogger(LOGGER_NAME)
|
||||
logger.setLevel(logging.INFO)
|
||||
logger.propagate = False
|
||||
|
||||
for handler in tuple(logger.handlers):
|
||||
logger.removeHandler(handler)
|
||||
handler.close()
|
||||
|
||||
handler = logging.FileHandler(Path(paths.logs) / "client.log", encoding="utf-8")
|
||||
handler.addFilter(SensitiveDataFilter())
|
||||
handler.setFormatter(logging.Formatter("%(asctime)s %(levelname)s %(message)s"))
|
||||
logger.addHandler(handler)
|
||||
return logger
|
||||
@@ -0,0 +1,23 @@
|
||||
"""拼多多链接的受限打开、只读取证与经取证的规格面板选择。
|
||||
|
||||
此包不提供通用页面选择器、输入、滑动或任何订单动作。
|
||||
"""
|
||||
|
||||
from .product_open import ProductOpenCapturer, ProductOpenResult
|
||||
from .product_url import ProductUrl, ProductUrlError, parse_product_url
|
||||
from .sku_selection import SkuSelection, SkuSelectionError, SkuSelectionFlow
|
||||
from .sku_selection_runner import SkuSelectionRunError, SkuSelectionRunResult, SkuSelectionRunner
|
||||
|
||||
__all__ = [
|
||||
"ProductOpenCapturer",
|
||||
"ProductOpenResult",
|
||||
"ProductUrl",
|
||||
"ProductUrlError",
|
||||
"SkuSelection",
|
||||
"SkuSelectionError",
|
||||
"SkuSelectionFlow",
|
||||
"SkuSelectionRunError",
|
||||
"SkuSelectionRunResult",
|
||||
"SkuSelectionRunner",
|
||||
"parse_product_url",
|
||||
]
|
||||
@@ -0,0 +1,262 @@
|
||||
"""安全打开 canonical 商品链接后的只读取证。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime
|
||||
from hashlib import sha256
|
||||
import json
|
||||
from math import isfinite
|
||||
import os
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
from time import monotonic, sleep
|
||||
from typing import Any, Protocol
|
||||
from uuid import uuid4
|
||||
|
||||
from adbutils.errors import AdbTimeout
|
||||
from uiautomator2.exceptions import HTTPTimeoutError
|
||||
|
||||
from ..device.adb import AdbClient, DeviceConnectionError, DeviceInspection, IntentLaunchSummary
|
||||
from ..device.baseline import (
|
||||
HIERARCHY_PARAMS,
|
||||
PDD_PACKAGE,
|
||||
SCREENSHOT_PARAMS,
|
||||
_save_base64_screenshot,
|
||||
_sha256_file,
|
||||
_validate_hierarchy,
|
||||
)
|
||||
from .product_url import ProductUrl, parse_product_url
|
||||
|
||||
|
||||
EXPECTED_PDD_VERSION = "8.17.0"
|
||||
|
||||
|
||||
class ProductOpenError(RuntimeError):
|
||||
"""商品打开或证据发布未完整完成。"""
|
||||
|
||||
|
||||
class ProductVersionMismatchError(ProductOpenError):
|
||||
"""运行时拼多多版本不是经取证允许的版本。"""
|
||||
|
||||
|
||||
class ProductPackageMismatchError(ProductOpenError):
|
||||
"""Intent 后在有限时间内未观察到拼多多前台包。"""
|
||||
|
||||
|
||||
class ProductOpenTimeoutError(ProductOpenError):
|
||||
"""商品打开后的只读取证超时。"""
|
||||
|
||||
|
||||
class ProductScreenshotCaptureError(ProductOpenError):
|
||||
"""Intent 后截图不能作为完整 PNG 证据保存。"""
|
||||
|
||||
|
||||
class ProductHierarchyCaptureError(ProductOpenError):
|
||||
"""Intent 后完整节点树不能作为有效 XML 证据保存。"""
|
||||
|
||||
|
||||
class ProductOpenUiDevice(Protocol):
|
||||
"""本任务所需的只读 uiautomator2 接口;故意没有任何 UI 操作方法。"""
|
||||
|
||||
def app_info(self, package_name: str) -> dict[str, Any]:
|
||||
"""读取应用元数据。"""
|
||||
|
||||
def app_current(self) -> dict[str, Any]:
|
||||
"""读取当前前台应用元数据。"""
|
||||
|
||||
def jsonrpc_call(self, method: str, params: Any = None, timeout: float = 10) -> Any:
|
||||
"""调用只读取证所需的公开 JSON-RPC 方法。"""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ProductOpenResult:
|
||||
"""已原子发布的商品打开证据位置。"""
|
||||
|
||||
output_directory: Path
|
||||
manifest_path: Path
|
||||
screenshot_path: Path
|
||||
hierarchy_path: Path
|
||||
|
||||
|
||||
class ProductOpenCapturer:
|
||||
"""以 fail-closed 顺序打开已重建链接,并在打开后只读留证。
|
||||
|
||||
本类不判断商品页、Activity、文案或控件;打开后只确认当前 package,随后采集截图与
|
||||
完整节点树。任何失败都不会发布半成品证据目录。
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
adb_client: AdbClient,
|
||||
connector: Callable[[str], ProductOpenUiDevice],
|
||||
timeout_seconds: float,
|
||||
foreground_poll_interval_seconds: float = 0.2,
|
||||
monotonic_clock: Callable[[], float] = monotonic,
|
||||
sleep_function: Callable[[float], None] = sleep,
|
||||
) -> None:
|
||||
if not _is_positive_finite(timeout_seconds):
|
||||
raise ValueError("timeout_seconds 必须是大于 0 的有限数值")
|
||||
if not _is_positive_finite(foreground_poll_interval_seconds):
|
||||
raise ValueError("foreground_poll_interval_seconds 必须是大于 0 的有限数值")
|
||||
self._adb_client = adb_client
|
||||
self._connector = connector
|
||||
self._timeout_seconds = timeout_seconds
|
||||
self._foreground_poll_interval_seconds = foreground_poll_interval_seconds
|
||||
self._monotonic_clock = monotonic_clock
|
||||
self._sleep_function = sleep_function
|
||||
|
||||
def open_and_capture(self, serial: str, product_url: str, output_directory: Path) -> ProductOpenResult:
|
||||
"""完成唯一允许的 Intent 打开及其后的只读取证。"""
|
||||
|
||||
# 公共入口只接收原始字符串并每次重新解析,不能由调用方构造不一致的值对象伪造 manifest。
|
||||
link = parse_product_url(product_url)
|
||||
target = Path(output_directory)
|
||||
_validate_new_target(target)
|
||||
|
||||
staging: Path | None = None
|
||||
try:
|
||||
# inspect 必须先于连接和 Intent,复用 T-101 的显式 serial、重复物理设备拒绝逻辑。
|
||||
inspection = self._adb_client.inspect(serial)
|
||||
device = self._connector(serial)
|
||||
pdd_version = _require_expected_version(device.app_info(PDD_PACKAGE))
|
||||
|
||||
# 版本精确匹配是 Intent 的前置条件,失败时绝不调用 start_pdd_view_intent。
|
||||
intent = self._adb_client.start_pdd_view_intent(serial, link.goods_id)
|
||||
self._wait_for_pdd_foreground(device)
|
||||
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
staging = target.parent / f".{target.name}.staging-{uuid4().hex}"
|
||||
staging.mkdir()
|
||||
screenshot_path = staging / "screenshot.png"
|
||||
try:
|
||||
_save_base64_screenshot(
|
||||
device.jsonrpc_call("takeScreenshot", SCREENSHOT_PARAMS, timeout=self._timeout_seconds),
|
||||
screenshot_path,
|
||||
)
|
||||
except (AdbTimeout, HTTPTimeoutError, TimeoutError):
|
||||
raise
|
||||
except Exception as error:
|
||||
raise ProductScreenshotCaptureError("商品打开后截图取证失败,未发布任何证据产物。") from error
|
||||
|
||||
try:
|
||||
hierarchy = device.jsonrpc_call(
|
||||
"dumpWindowHierarchy",
|
||||
HIERARCHY_PARAMS,
|
||||
timeout=self._timeout_seconds,
|
||||
)
|
||||
_validate_hierarchy(hierarchy)
|
||||
except (AdbTimeout, HTTPTimeoutError, TimeoutError):
|
||||
raise
|
||||
except Exception as error:
|
||||
raise ProductHierarchyCaptureError("商品打开后节点树取证失败,未发布任何证据产物。") from error
|
||||
hierarchy_path = staging / "hierarchy.xml"
|
||||
hierarchy_path.write_text(hierarchy, encoding="utf-8")
|
||||
|
||||
manifest_path = staging / "manifest.json"
|
||||
manifest_path.write_text(
|
||||
json.dumps(
|
||||
_manifest(inspection, serial, link, pdd_version, intent, screenshot_path, hierarchy_path),
|
||||
ensure_ascii=False,
|
||||
indent=2,
|
||||
sort_keys=True,
|
||||
)
|
||||
+ "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
os.replace(staging, target)
|
||||
except (ProductOpenError, DeviceConnectionError):
|
||||
_clean_staging(staging)
|
||||
raise
|
||||
except (AdbTimeout, HTTPTimeoutError, TimeoutError) as error:
|
||||
_clean_staging(staging)
|
||||
raise ProductOpenTimeoutError("商品打开后的只读取证超时,未发布任何证据产物。") from error
|
||||
except Exception as error:
|
||||
_clean_staging(staging)
|
||||
# 底层异常可能含 serial、路径或远端页面内容,不能直接向 CLI 或日志传播。
|
||||
raise ProductOpenError("商品打开或只读取证未完成,未发布任何证据产物。") from error
|
||||
|
||||
return ProductOpenResult(
|
||||
output_directory=target,
|
||||
manifest_path=target / "manifest.json",
|
||||
screenshot_path=target / "screenshot.png",
|
||||
hierarchy_path=target / "hierarchy.xml",
|
||||
)
|
||||
|
||||
def _wait_for_pdd_foreground(self, device: ProductOpenUiDevice) -> None:
|
||||
"""只轮询当前 package,直到 deadline;Activity 和节点树均不参与本判据。"""
|
||||
|
||||
deadline = self._monotonic_clock() + self._timeout_seconds
|
||||
while True:
|
||||
if _is_pdd_foreground(device.app_current()):
|
||||
return
|
||||
remaining = deadline - self._monotonic_clock()
|
||||
if remaining <= 0:
|
||||
raise ProductPackageMismatchError(
|
||||
"商品链接打开后未在限定时间内进入拼多多,已停止后续取证。"
|
||||
)
|
||||
# 每个失败观察后都等待正的、受 deadline 约束的时长,避免 busy-loop。
|
||||
self._sleep_function(min(self._foreground_poll_interval_seconds, remaining))
|
||||
|
||||
|
||||
def _validate_new_target(target: Path) -> None:
|
||||
if target.exists():
|
||||
raise ProductOpenError("输出目录已存在;为防止混入旧证据,拒绝覆盖。")
|
||||
if not target.name:
|
||||
raise ProductOpenError("输出目录必须是明确的新目录。")
|
||||
|
||||
|
||||
def _clean_staging(staging: Path | None) -> None:
|
||||
if staging is not None and staging.exists():
|
||||
# staging 仅在本次调用中创建,删除前不解析或扩展任何调用方提供的路径。
|
||||
shutil.rmtree(staging)
|
||||
|
||||
|
||||
def _require_expected_version(app_info: dict[str, Any]) -> str:
|
||||
if not isinstance(app_info, dict):
|
||||
raise ProductVersionMismatchError("拼多多版本与已取证版本不一致,已停止打开商品链接。")
|
||||
version = app_info.get("versionName") or app_info.get("version_name")
|
||||
if not isinstance(version, str) or version != EXPECTED_PDD_VERSION:
|
||||
raise ProductVersionMismatchError("拼多多版本与已取证版本不一致,已停止打开商品链接。")
|
||||
return version
|
||||
|
||||
|
||||
def _is_positive_finite(value: object) -> bool:
|
||||
return isinstance(value, (int, float)) and not isinstance(value, bool) and value > 0 and isfinite(value)
|
||||
|
||||
|
||||
def _is_pdd_foreground(current: object) -> bool:
|
||||
return isinstance(current, dict) and current.get("package") == PDD_PACKAGE
|
||||
|
||||
|
||||
def _manifest(
|
||||
inspection: DeviceInspection,
|
||||
serial: str,
|
||||
link: ProductUrl,
|
||||
pdd_version: str,
|
||||
intent: IntentLaunchSummary,
|
||||
screenshot_path: Path,
|
||||
hierarchy_path: Path,
|
||||
) -> dict[str, Any]:
|
||||
"""只写审计摘要;原始 serial、Activity、ADB 输出和页面正文均不进入 manifest。"""
|
||||
|
||||
return {
|
||||
"schema_version": 1,
|
||||
"captured_at": datetime.now(UTC).isoformat(),
|
||||
"product": {"goods_id": link.goods_id, "canonical_url": link.canonical_url},
|
||||
"channel": "wifi" if ":" in serial else "usb",
|
||||
"serial_sha256": sha256(serial.encode("utf-8")).hexdigest(),
|
||||
"device": {
|
||||
"model": inspection.model,
|
||||
"android_version": inspection.android_version,
|
||||
"pdd_package": PDD_PACKAGE,
|
||||
"pdd_version": pdd_version,
|
||||
},
|
||||
"intent": {"status": intent.status, "returncode": intent.returncode},
|
||||
"current_package": PDD_PACKAGE,
|
||||
"artifacts": [
|
||||
{"path": screenshot_path.name, "sha256": _sha256_file(screenshot_path)},
|
||||
{"path": hierarchy_path.name, "sha256": _sha256_file(hierarchy_path)},
|
||||
],
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
"""唯一允许交给 Android Intent 的商品链接。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from urllib.parse import parse_qsl, urlsplit
|
||||
|
||||
|
||||
_SCHEME = "https"
|
||||
_HOST = "mobile.yangkeduo.com"
|
||||
_PATH = "/goods.html"
|
||||
|
||||
|
||||
class ProductUrlError(ValueError):
|
||||
"""输入不是可安全重建的 canonical 商品链接。"""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ProductUrl:
|
||||
"""经验证的商品标识及由它重建的 canonical URL。"""
|
||||
|
||||
goods_id: str
|
||||
canonical_url: str
|
||||
|
||||
|
||||
def parse_product_url(value: str) -> ProductUrl:
|
||||
"""只接受一个 ASCII 数字 ``goods_id`` 的拼多多商品直链。
|
||||
|
||||
解析结果绝不原样透传:Intent 使用的 URL 必须从 ``goods_id`` 重新构建,以排除
|
||||
短链、额外参数、userinfo、fragment 和 URL 解析器的边缘表示。
|
||||
"""
|
||||
|
||||
if not isinstance(value, str):
|
||||
raise ProductUrlError("商品链接必须是字符串。")
|
||||
try:
|
||||
parsed = urlsplit(value)
|
||||
port = parsed.port
|
||||
query_pairs = parse_qsl(parsed.query, keep_blank_values=True, strict_parsing=True)
|
||||
except ValueError as error:
|
||||
raise ProductUrlError("商品链接格式无效。") from error
|
||||
|
||||
if (
|
||||
parsed.scheme != _SCHEME
|
||||
or parsed.hostname != _HOST
|
||||
or parsed.username is not None
|
||||
or parsed.password is not None
|
||||
or port is not None
|
||||
or parsed.path != _PATH
|
||||
or parsed.fragment
|
||||
):
|
||||
raise ProductUrlError("商品链接不是允许的拼多多商品直链。")
|
||||
if len(query_pairs) != 1 or query_pairs[0][0] != "goods_id":
|
||||
raise ProductUrlError("商品链接必须且只能包含一个 goods_id 参数。")
|
||||
|
||||
goods_id = query_pairs[0][1]
|
||||
if not goods_id or any(character < "0" or character > "9" for character in goods_id):
|
||||
raise ProductUrlError("goods_id 必须是纯数字。")
|
||||
canonical_url = f"{_SCHEME}://{_HOST}{_PATH}?goods_id={goods_id}"
|
||||
if value != canonical_url:
|
||||
raise ProductUrlError("商品链接必须使用唯一 canonical 表示。")
|
||||
return ProductUrl(goods_id=goods_id, canonical_url=canonical_url)
|
||||
@@ -0,0 +1,252 @@
|
||||
"""人工停留在规格面板后的只读取证。
|
||||
|
||||
本模块不识别规格面板,不打开商品链接,也不读取价格;三种面板状态完全由现场人员声明。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime
|
||||
from hashlib import sha256
|
||||
import json
|
||||
from math import isfinite
|
||||
import os
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
from typing import Any, Protocol
|
||||
from uuid import uuid4
|
||||
|
||||
from adbutils.errors import AdbTimeout
|
||||
from uiautomator2.exceptions import HTTPTimeoutError
|
||||
|
||||
from ..device.adb import AdbClient, DeviceConnectionError, DeviceInspection
|
||||
from ..device.baseline import (
|
||||
HIERARCHY_PARAMS,
|
||||
PDD_PACKAGE,
|
||||
SCREENSHOT_PARAMS,
|
||||
_save_base64_screenshot,
|
||||
_sha256_file,
|
||||
_validate_hierarchy,
|
||||
)
|
||||
from .product_open import EXPECTED_PDD_VERSION
|
||||
from .product_url import ProductUrl, parse_product_url
|
||||
from .sku_panel_state import HUMAN_DECLARED_STATES
|
||||
|
||||
|
||||
class SkuPanelEvidenceError(RuntimeError):
|
||||
"""人工规格面板证据无法完整发布。"""
|
||||
|
||||
|
||||
class SkuPanelDeclaredStateError(SkuPanelEvidenceError):
|
||||
"""调用方没有提供允许的人工声明状态。"""
|
||||
|
||||
|
||||
class SkuPanelVersionMismatchError(SkuPanelEvidenceError):
|
||||
"""运行时拼多多版本不是已取证版本。"""
|
||||
|
||||
|
||||
class SkuPanelPackageMismatchError(SkuPanelEvidenceError):
|
||||
"""人工声明前台不是拼多多时仍试图留证。"""
|
||||
|
||||
|
||||
class SkuPanelEvidenceTimeoutError(SkuPanelEvidenceError):
|
||||
"""只读截图或节点树取证超时。"""
|
||||
|
||||
|
||||
class SkuPanelScreenshotError(SkuPanelEvidenceError):
|
||||
"""截图不能保存为严格有效的 PNG。"""
|
||||
|
||||
|
||||
class SkuPanelHierarchyError(SkuPanelEvidenceError):
|
||||
"""节点树不能保存为严格有效的 XML。"""
|
||||
|
||||
|
||||
class SkuPanelUiDevice(Protocol):
|
||||
"""人工面板证据所需的只读接口,故意没有任何页面操作方法。"""
|
||||
|
||||
def app_info(self, package_name: str) -> dict[str, Any]:
|
||||
"""读取应用元数据。"""
|
||||
|
||||
def app_current(self) -> dict[str, Any]:
|
||||
"""读取当前前台应用元数据。"""
|
||||
|
||||
def jsonrpc_call(self, method: str, params: Any = None, timeout: float = 10) -> Any:
|
||||
"""调用公开 JSON-RPC 的只读取证方法。"""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SkuPanelEvidenceResult:
|
||||
"""已原子发布的本地证据目录。"""
|
||||
|
||||
output_directory: Path
|
||||
manifest_path: Path
|
||||
screenshot_path: Path
|
||||
hierarchy_path: Path
|
||||
|
||||
|
||||
class SkuPanelEvidenceCapturer:
|
||||
"""把人工已停留的面板状态留证,不对页面作任何自动结论。
|
||||
|
||||
状态字段命名为 ``human_declared_state``,防止消费者把本模块误解为自动面板/规格/价格识别。
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
adb_client: AdbClient,
|
||||
connector: Callable[[str], SkuPanelUiDevice],
|
||||
timeout_seconds: float,
|
||||
) -> None:
|
||||
if not _is_positive_finite(timeout_seconds):
|
||||
raise ValueError("timeout_seconds 必须是大于 0 的有限数值")
|
||||
self._adb_client = adb_client
|
||||
self._connector = connector
|
||||
self._timeout_seconds = timeout_seconds
|
||||
|
||||
def capture(
|
||||
self,
|
||||
serial: str,
|
||||
product_url: str,
|
||||
human_declared_state: str,
|
||||
output_directory: Path,
|
||||
) -> SkuPanelEvidenceResult:
|
||||
"""采集人工已准备的状态;不会打开链接、面板或执行任何 UI 操作。"""
|
||||
|
||||
link = parse_product_url(product_url)
|
||||
state = _validate_human_declared_state(human_declared_state)
|
||||
target = Path(output_directory)
|
||||
_validate_new_target(target)
|
||||
|
||||
staging: Path | None = None
|
||||
try:
|
||||
# 沿用 T-101 的显式 serial、在线状态与重复物理设备 fail-closed 核验。
|
||||
inspection = self._adb_client.inspect(serial)
|
||||
device = self._connector(serial)
|
||||
pdd_version = _require_expected_version(device.app_info(PDD_PACKAGE))
|
||||
_require_pdd_foreground(device.app_current())
|
||||
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
staging = target.parent / f".{target.name}.staging-{uuid4().hex}"
|
||||
staging.mkdir()
|
||||
screenshot_path = staging / "screenshot.png"
|
||||
try:
|
||||
_save_base64_screenshot(
|
||||
device.jsonrpc_call("takeScreenshot", SCREENSHOT_PARAMS, timeout=self._timeout_seconds),
|
||||
screenshot_path,
|
||||
)
|
||||
except (AdbTimeout, HTTPTimeoutError, TimeoutError):
|
||||
raise
|
||||
except Exception as error:
|
||||
raise SkuPanelScreenshotError("规格面板截图取证失败,未发布任何证据产物。") from error
|
||||
|
||||
try:
|
||||
hierarchy = device.jsonrpc_call(
|
||||
"dumpWindowHierarchy",
|
||||
HIERARCHY_PARAMS,
|
||||
timeout=self._timeout_seconds,
|
||||
)
|
||||
_validate_hierarchy(hierarchy)
|
||||
except (AdbTimeout, HTTPTimeoutError, TimeoutError):
|
||||
raise
|
||||
except Exception as error:
|
||||
raise SkuPanelHierarchyError("规格面板节点树取证失败,未发布任何证据产物。") from error
|
||||
hierarchy_path = staging / "hierarchy.xml"
|
||||
hierarchy_path.write_text(hierarchy, encoding="utf-8")
|
||||
|
||||
manifest_path = staging / "manifest.json"
|
||||
manifest_path.write_text(
|
||||
json.dumps(
|
||||
_manifest(inspection, serial, link, state, pdd_version, screenshot_path, hierarchy_path),
|
||||
ensure_ascii=False,
|
||||
indent=2,
|
||||
sort_keys=True,
|
||||
)
|
||||
+ "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
os.replace(staging, target)
|
||||
except (SkuPanelEvidenceError, DeviceConnectionError):
|
||||
_clean_staging(staging)
|
||||
raise
|
||||
except (AdbTimeout, HTTPTimeoutError, TimeoutError) as error:
|
||||
_clean_staging(staging)
|
||||
raise SkuPanelEvidenceTimeoutError("规格面板只读取证超时,未发布任何证据产物。") from error
|
||||
except Exception as error:
|
||||
_clean_staging(staging)
|
||||
# 第三方异常可能含 serial、Activity 或页面正文,不能直接向 CLI/日志传播。
|
||||
raise SkuPanelEvidenceError("规格面板只读取证未完成,未发布任何证据产物。") from error
|
||||
|
||||
return SkuPanelEvidenceResult(
|
||||
output_directory=target,
|
||||
manifest_path=target / "manifest.json",
|
||||
screenshot_path=target / "screenshot.png",
|
||||
hierarchy_path=target / "hierarchy.xml",
|
||||
)
|
||||
|
||||
|
||||
def _is_positive_finite(value: object) -> bool:
|
||||
return isinstance(value, (int, float)) and not isinstance(value, bool) and value > 0 and isfinite(value)
|
||||
|
||||
|
||||
def _validate_human_declared_state(value: object) -> str:
|
||||
if not isinstance(value, str) or value not in HUMAN_DECLARED_STATES:
|
||||
raise SkuPanelDeclaredStateError("必须提供允许的人工声明规格面板状态。")
|
||||
return value
|
||||
|
||||
|
||||
def _validate_new_target(target: Path) -> None:
|
||||
if target.exists():
|
||||
raise SkuPanelEvidenceError("输出目录已存在;为防止混入旧证据,拒绝覆盖。")
|
||||
if not target.name:
|
||||
raise SkuPanelEvidenceError("输出目录必须是明确的新目录。")
|
||||
|
||||
|
||||
def _clean_staging(staging: Path | None) -> None:
|
||||
if staging is not None and staging.exists():
|
||||
# staging 仅在本次调用中创建,绝不删除调用方已存在的目录。
|
||||
shutil.rmtree(staging)
|
||||
|
||||
|
||||
def _require_expected_version(app_info: object) -> str:
|
||||
if not isinstance(app_info, dict):
|
||||
raise SkuPanelVersionMismatchError("拼多多版本与已取证版本不一致,已停止取证。")
|
||||
version = app_info.get("versionName") or app_info.get("version_name")
|
||||
if not isinstance(version, str) or version != EXPECTED_PDD_VERSION:
|
||||
raise SkuPanelVersionMismatchError("拼多多版本与已取证版本不一致,已停止取证。")
|
||||
return version
|
||||
|
||||
|
||||
def _require_pdd_foreground(current: object) -> None:
|
||||
if not isinstance(current, dict) or current.get("package") != PDD_PACKAGE:
|
||||
raise SkuPanelPackageMismatchError("当前前台应用不是拼多多,已停止取证。")
|
||||
|
||||
|
||||
def _manifest(
|
||||
inspection: DeviceInspection,
|
||||
serial: str,
|
||||
link: ProductUrl,
|
||||
human_declared_state: str,
|
||||
pdd_version: str,
|
||||
screenshot_path: Path,
|
||||
hierarchy_path: Path,
|
||||
) -> dict[str, Any]:
|
||||
"""仅记录人工声明与非敏感审计摘要,不写入 Activity 或页面内容。"""
|
||||
|
||||
return {
|
||||
"schema_version": 1,
|
||||
"captured_at": datetime.now(UTC).isoformat(),
|
||||
"product": {"goods_id": link.goods_id, "canonical_url": link.canonical_url},
|
||||
"human_declared_state": human_declared_state,
|
||||
"channel": "wifi" if ":" in serial else "usb",
|
||||
"serial_sha256": sha256(serial.encode("utf-8")).hexdigest(),
|
||||
"device": {
|
||||
"model": inspection.model,
|
||||
"android_version": inspection.android_version,
|
||||
"pdd_package": PDD_PACKAGE,
|
||||
"pdd_version": pdd_version,
|
||||
},
|
||||
"artifacts": [
|
||||
{"path": screenshot_path.name, "sha256": _sha256_file(screenshot_path)},
|
||||
{"path": hierarchy_path.name, "sha256": _sha256_file(hierarchy_path)},
|
||||
],
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
"""T-103 的人工声明证据状态;不含任何页面识别或规格语义。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
# 这些值由现场人员填写到 manifest,不能被解释为自动检测出的页面或选择状态。
|
||||
HUMAN_DECLARED_STATES = frozenset(
|
||||
{
|
||||
"panel-opened-target-preselected",
|
||||
"alternate-all-dimensions-selected",
|
||||
"target-selection-restored",
|
||||
}
|
||||
)
|
||||
@@ -0,0 +1,420 @@
|
||||
"""T-103:仅限已取证 PDD 8.17.0 的规格面板恢复。"""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
import re
|
||||
from time import monotonic, sleep
|
||||
from typing import Any, Callable, Protocol
|
||||
from xml.etree import ElementTree
|
||||
|
||||
from ..device.baseline import PDD_PACKAGE
|
||||
from .product_open import EXPECTED_PDD_VERSION
|
||||
from .product_url import parse_product_url
|
||||
|
||||
EXPECTED_GOODS_ID = "937122477375"
|
||||
EXPECTED_UNIT_PRICE = "12.88"
|
||||
# 任务值不是页面判据;右侧是 v5 取证的唯一 accessibility 文案(空格/全角括号均有意义)。
|
||||
TASK_TO_UI_SELECTION = {("黑色CHA(纯棉)", "M(建议100-115)"): ("黑色 CHA (纯棉)", "M(建议100-115)")}
|
||||
_TARGET_COLOR_UI, _TARGET_SIZE_UI = next(iter(TASK_TO_UI_SELECTION.values()))
|
||||
_ENTRY = "快要抢光"
|
||||
_ENTRY_TEXT_BOUNDS = "[900,1312][1056,1355]"
|
||||
_ENTRY_INNER_BOUNDS = "[712,1312][1056,1355]"
|
||||
_ENTRY_ACTION_BOUNDS = "[0,1256][1080,1355]"
|
||||
_SIZE = "尺码"
|
||||
_W, _H = 1080, 2376
|
||||
_PRICE_PARENT = "[396,498][895,570]"
|
||||
_CURRENT = "[396,503][712,570]"
|
||||
_ORIGINAL = "[730,503][895,570]"
|
||||
_SUMMARY = "[396,654][1053,716]"
|
||||
_COLOR_REGION = "[36,1000][1080,1631]"
|
||||
_SIZE_LABEL = "[36,1654][114,1700]"
|
||||
_SIZE_HEADER = "[36,1637][1044,1718]"
|
||||
_SIZE_OPTIONS = "[36,1730][1044,2045]"
|
||||
_BOUNDS = re.compile(r"^\[(\d+),(\d+)\]\[(\d+),(\d+)\]$")
|
||||
_PRICE = re.compile(r"^[^0-9¥¥]*[¥¥]([1-9][0-9]*\.[0-9]{2})$")
|
||||
_ORIGINAL_PRICE = re.compile(r"^[¥¥][1-9][0-9]*\.[0-9]{2}$")
|
||||
_BAD_PRICE_ROLE = ("提交订单", "支付", "优惠", "券", "会员", "补贴", "区间", "实付", "到手", "原价", "划线价", "最低", "低至", "起价", "下单", "先用后付", "预估")
|
||||
|
||||
|
||||
class SkuSelectionError(RuntimeError):
|
||||
"""已取证判据不成立时的脱敏停止。"""
|
||||
|
||||
|
||||
class SkuPanelDevice(Protocol):
|
||||
def app_info(self, package_name: str) -> dict[str, Any]: ...
|
||||
def app_current(self) -> dict[str, Any]: ...
|
||||
def dump_window_hierarchy(self) -> str: ...
|
||||
def tap_sku_entry(self, bounds: str) -> None: ...
|
||||
def tap_sku_option(self, bounds: str) -> None: ...
|
||||
def leave_sku_panel(self) -> None: ...
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SkuSelection:
|
||||
color: str
|
||||
size: str
|
||||
|
||||
|
||||
def resolve_task_selection(color: str, size: str) -> SkuSelection:
|
||||
mapped = TASK_TO_UI_SELECTION.get((color, size))
|
||||
if mapped is None:
|
||||
raise SkuSelectionError("规格任务值不是已取证的唯一目标,已停止操作。")
|
||||
return SkuSelection(*mapped)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _Node:
|
||||
element: ElementTree.Element
|
||||
parent: "_Node | None"
|
||||
@property
|
||||
def text(self) -> str: return self.element.get("text", "")
|
||||
@property
|
||||
def desc(self) -> str: return self.element.get("content-desc", "")
|
||||
@property
|
||||
def bounds(self) -> str: return self.element.get("bounds", "")
|
||||
|
||||
|
||||
class SkuSelectionFlow:
|
||||
def __init__(self, device: SkuPanelDevice, entry_wait_timeout_seconds: float = 0.2,
|
||||
entry_poll_interval_seconds: float = 0.2, monotonic_clock: Callable[[], float] = monotonic,
|
||||
sleep_function: Callable[[float], None] = sleep) -> None:
|
||||
if entry_wait_timeout_seconds < 0 or entry_poll_interval_seconds <= 0:
|
||||
raise ValueError("入口等待参数无效。")
|
||||
self._device, self._entry_timeout, self._poll = device, entry_wait_timeout_seconds, entry_poll_interval_seconds
|
||||
self._clock, self._sleep = monotonic_clock, sleep_function
|
||||
self._pending: tuple[str, Callable[[list[_Node]], Any]] | None = None
|
||||
|
||||
def open_sku_panel(self, product_url: str, pre_intent_hierarchy: str | None = None) -> None:
|
||||
if parse_product_url(product_url).goods_id != EXPECTED_GOODS_ID:
|
||||
raise SkuSelectionError("商品不是已取证目标,已停止操作。")
|
||||
if pre_intent_hierarchy is not None:
|
||||
previous_nodes = _parse_nodes(pre_intent_hierarchy)
|
||||
if _eligible_entries(previous_nodes):
|
||||
raise SkuSelectionError("intent 前页面已出现规格入口,已拒绝旧商品误点。")
|
||||
entry, before = self._wait_for_entry(pre_intent_hierarchy)
|
||||
_action_bounds(entry.bounds)
|
||||
self._pending = (before, _panel)
|
||||
self._device.tap_sku_entry(entry.bounds)
|
||||
self._wait_after_action(before, _panel)
|
||||
|
||||
def select_sku_options(self, selection: SkuSelection) -> None:
|
||||
if selection not in {SkuSelection(*item) for item in TASK_TO_UI_SELECTION.values()}:
|
||||
raise SkuSelectionError("规格 UI 文案不是获准目标,已停止操作。")
|
||||
initial = self._verified_nodes()
|
||||
_option(initial, "color", selection.color); _option(initial, "size", selection.size)
|
||||
_selected_label(initial, "color"); _selected_label(initial, "size")
|
||||
self._restore("color", selection.color)
|
||||
self._restore("size", selection.size)
|
||||
|
||||
def read_sku_unit_price(self) -> str:
|
||||
return _unit_price(self._verified_nodes())
|
||||
|
||||
def verify_target_selection_and_read_price(self, selection: SkuSelection) -> str:
|
||||
nodes = self._verified_nodes()
|
||||
_selected(nodes, "color", selection.color)
|
||||
_selected(nodes, "size", selection.size)
|
||||
return _unit_price(nodes)
|
||||
|
||||
def exit_sku_panel_safely(self) -> None:
|
||||
self._require_foreground()
|
||||
before = self._read_hierarchy()
|
||||
_panel(_parse_nodes(before))
|
||||
self._device.leave_sku_panel()
|
||||
deadline = self._clock() + self._entry_timeout
|
||||
while True:
|
||||
self._require_foreground()
|
||||
raw = self._read_hierarchy()
|
||||
if raw != before:
|
||||
try:
|
||||
_panel(_parse_nodes(raw))
|
||||
except SkuSelectionError:
|
||||
return
|
||||
remaining = deadline - self._clock()
|
||||
if remaining <= 0:
|
||||
raise SkuSelectionError("安全退出后未确认离开规格面板,未重试返回。")
|
||||
self._sleep(min(self._poll, remaining))
|
||||
|
||||
def reconcile_pending_action(self) -> None:
|
||||
"""仅只读调和一次已发出但尚未得到后置条件确认的动作。"""
|
||||
if self._pending is None:
|
||||
return
|
||||
before, condition = self._pending
|
||||
self._wait_after_action(before, condition)
|
||||
|
||||
def _restore(self, dimension: str, expected: str) -> None:
|
||||
self._require_foreground()
|
||||
before = self._read_hierarchy()
|
||||
nodes = _panel(_parse_nodes(before))
|
||||
target = _option(nodes, dimension, expected)
|
||||
if _selected_label(nodes, dimension) == expected:
|
||||
return
|
||||
_action_bounds(target.bounds)
|
||||
condition: Callable[[list[_Node]], Any]
|
||||
if dimension == "color":
|
||||
condition = lambda refreshed: _post_color(refreshed, expected)
|
||||
else:
|
||||
condition = lambda refreshed: _post_all_targets(refreshed, expected)
|
||||
self._pending = (before, condition)
|
||||
self._device.tap_sku_option(target.bounds)
|
||||
if dimension == "color":
|
||||
self._wait_after_action(before, condition)
|
||||
else:
|
||||
self._wait_after_action(before, condition)
|
||||
|
||||
def _wait_for_entry(self, previous: str | None) -> tuple[_Node, str]:
|
||||
deadline, stable = self._clock() + self._entry_timeout, None
|
||||
while True:
|
||||
self._require_version()
|
||||
current = self._device.app_current()
|
||||
if isinstance(current, dict) and current.get("package") == PDD_PACKAGE:
|
||||
raw = self._read_hierarchy()
|
||||
entries = _eligible_entries(_parse_nodes(raw))
|
||||
if len(entries) > 1:
|
||||
raise SkuSelectionError("商品页规格入口不唯一,已停止操作。")
|
||||
if len(entries) == 1 and raw != previous:
|
||||
if stable == raw:
|
||||
return entries[0], raw
|
||||
stable = raw
|
||||
else:
|
||||
stable = None
|
||||
else:
|
||||
stable = None
|
||||
remaining = deadline - self._clock()
|
||||
if remaining <= 0:
|
||||
raise SkuSelectionError("等待已取证规格入口超时,未执行点击。")
|
||||
self._sleep(min(self._poll, remaining))
|
||||
|
||||
def _wait_after_action(self, previous: str, condition: Callable[[list[_Node]], Any]) -> list[_Node]:
|
||||
deadline = self._clock() + self._entry_timeout
|
||||
while True:
|
||||
self._require_foreground()
|
||||
raw = self._read_hierarchy()
|
||||
if raw != previous:
|
||||
nodes = _parse_nodes(raw)
|
||||
try:
|
||||
condition(nodes)
|
||||
self._pending = None
|
||||
return nodes
|
||||
except SkuSelectionError:
|
||||
pass
|
||||
remaining = deadline - self._clock()
|
||||
if remaining <= 0:
|
||||
raise SkuSelectionError("动作后页面未在限定时间内满足已取证后置条件,未重试动作。")
|
||||
self._sleep(min(self._poll, remaining))
|
||||
|
||||
def _verified_nodes(self) -> list[_Node]:
|
||||
self._require_foreground()
|
||||
return _panel(self._read_nodes())
|
||||
|
||||
def _require_version(self) -> None:
|
||||
info = self._device.app_info(PDD_PACKAGE)
|
||||
version = (info.get("versionName") or info.get("version_name")) if isinstance(info, dict) else None
|
||||
if version != EXPECTED_PDD_VERSION:
|
||||
raise SkuSelectionError("拼多多版本与已取证版本不一致,已停止操作。")
|
||||
|
||||
def _require_foreground(self) -> None:
|
||||
self._require_version()
|
||||
current = self._device.app_current()
|
||||
if not isinstance(current, dict) or current.get("package") != PDD_PACKAGE:
|
||||
raise SkuSelectionError("拼多多不在前台,已停止操作。")
|
||||
|
||||
def _read_hierarchy(self) -> str:
|
||||
try: raw = self._device.dump_window_hierarchy()
|
||||
except Exception as error: raise SkuSelectionError("节点树读取失败,已停止操作。") from error
|
||||
if not isinstance(raw, str) or not raw: raise SkuSelectionError("节点树不可用,已停止操作。")
|
||||
return raw
|
||||
|
||||
def _read_nodes(self) -> list[_Node]: return _parse_nodes(self._read_hierarchy())
|
||||
|
||||
|
||||
def _parse_nodes(raw: str) -> list[_Node]:
|
||||
try: root = ElementTree.fromstring(raw)
|
||||
except ElementTree.ParseError as error: raise SkuSelectionError("节点树格式无效,已停止操作。") from error
|
||||
if root.tag != "hierarchy": raise SkuSelectionError("节点树根节点无效,已停止操作。")
|
||||
result: list[_Node] = []
|
||||
def visit(element: ElementTree.Element, parent: _Node | None) -> None:
|
||||
node = _Node(element, parent); result.append(node)
|
||||
for child in element: visit(child, node)
|
||||
visit(root, None)
|
||||
return result
|
||||
|
||||
|
||||
def _panel(nodes: list[_Node]) -> list[_Node]:
|
||||
parent = _one([n for n in nodes if n.bounds == _PRICE_PARENT], "规格面板价格区域不唯一,已停止操作。")
|
||||
_one([n for n in nodes if n.parent is parent and n.bounds == _ORIGINAL and _readonly(n) and _ORIGINAL_PRICE.fullmatch(n.text)], "规格面板原价槽位不唯一,已停止操作。")
|
||||
_one([n for n in nodes if n.bounds == _SUMMARY and _readonly(n) and n.text.startswith("已选:")], "规格面板已选摘要不唯一,已停止操作。")
|
||||
_color_container(nodes); _size_container(nodes)
|
||||
return nodes
|
||||
|
||||
|
||||
def _unit_price(nodes: list[_Node]) -> str:
|
||||
parent = _one([n for n in nodes if n.bounds == _PRICE_PARENT], "规格面板价格区域不唯一,已停止读取。")
|
||||
money = [n for n in nodes if n.parent is parent and _readonly(n) and any(mark in n.text for mark in "¥¥")]
|
||||
if len(money) != 2: raise SkuSelectionError("规格面板金额槽位不唯一,已停止读取。")
|
||||
current = _one([n for n in money if n.bounds == _CURRENT and not _clickable_ancestor(n) and not any(word in n.text for word in _BAD_PRICE_ROLE) and _PRICE.fullmatch(n.text)], "规格面板现价不唯一或不符合已取证槽位,已停止读取。")
|
||||
if not any(n.bounds == _ORIGINAL and _ORIGINAL_PRICE.fullmatch(n.text) for n in money):
|
||||
raise SkuSelectionError("规格面板原价槽位无效,已停止读取。")
|
||||
match = _PRICE.fullmatch(current.text)
|
||||
if match is None: raise SkuSelectionError("规格面板现价格式失效,已停止读取。")
|
||||
return match.group(1)
|
||||
|
||||
|
||||
def _option(nodes: list[_Node], dimension: str, expected: str) -> _Node:
|
||||
_panel(nodes)
|
||||
return _one([n for n in _options(nodes, dimension) if _label(n) == expected], "规格选项不唯一或不是精确匹配,已停止操作。")
|
||||
|
||||
|
||||
def _selected(nodes: list[_Node], dimension: str, expected: str) -> None:
|
||||
if _selected_label(nodes, dimension) != expected:
|
||||
raise SkuSelectionError("规格选择后读回的 selected 文案不一致,已停止操作。")
|
||||
|
||||
|
||||
def _selected_label(nodes: list[_Node], dimension: str) -> str:
|
||||
selected = [n for n in _options(nodes, dimension) if n.element.get("selected") == "true"]
|
||||
label = _label(_one(selected, "规格维度没有唯一 selected 状态,已停止操作。"))
|
||||
if label is None: raise SkuSelectionError("规格维度 selected 文案无效,已停止操作。")
|
||||
return label
|
||||
|
||||
|
||||
def _post_color(nodes: list[_Node], expected: str) -> None:
|
||||
_panel(nodes)
|
||||
_selected(nodes, "color", expected)
|
||||
_selected_label(nodes, "size")
|
||||
|
||||
|
||||
def _post_all_targets(nodes: list[_Node], expected_size: str) -> None:
|
||||
_panel(nodes)
|
||||
_selected(nodes, "color", _TARGET_COLOR_UI)
|
||||
_selected(nodes, "size", expected_size)
|
||||
|
||||
|
||||
def _options(nodes: list[_Node], dimension: str) -> list[_Node]:
|
||||
container = _color_container(nodes) if dimension == "color" else _size_container(nodes) if dimension == "size" else None
|
||||
if container is None: raise SkuSelectionError("未知规格维度,已停止操作。")
|
||||
candidates = [n for n in nodes if _descendant(n, container) and _contained(n, container) and _choice(n) and _label(n) is not None]
|
||||
return [n for n in candidates if not _labeled_ancestor(n, candidates)]
|
||||
|
||||
|
||||
def _color_container(nodes: list[_Node]) -> _Node:
|
||||
return _one([n for n in nodes if n.element.get("package") == PDD_PACKAGE and n.element.get("class") == "androidx.recyclerview.widget.RecyclerView" and n.bounds == _COLOR_REGION], "规格面板颜色容器不唯一,已停止操作。")
|
||||
|
||||
|
||||
def _size_container(nodes: list[_Node]) -> _Node:
|
||||
label = _one([n for n in nodes if n.text == _SIZE and n.bounds == _SIZE_LABEL and _readonly(n)], "规格面板尺码标签不唯一,已停止操作。")
|
||||
header = label.parent
|
||||
if header is None or header.element.get("package") != PDD_PACKAGE or header.element.get("class") != "android.widget.LinearLayout" or header.bounds != _SIZE_HEADER or header.parent is None:
|
||||
raise SkuSelectionError("规格面板尺码标题容器不符合已取证结构,已停止操作。")
|
||||
return _one([n for n in nodes if n.parent is header.parent and n.element.get("package") == PDD_PACKAGE and n.element.get("class") == "android.widget.LinearLayout" and n.bounds == _SIZE_OPTIONS], "规格面板尺码选项容器不唯一,已停止操作。")
|
||||
|
||||
|
||||
def _label(node: _Node) -> str | None:
|
||||
values = {value for value in (node.text, node.desc) if value}
|
||||
return values.pop() if len(values) == 1 else None
|
||||
|
||||
|
||||
def _labeled_ancestor(node: _Node, candidates: list[_Node]) -> bool:
|
||||
ids, parent = {id(n.element) for n in candidates}, node.parent
|
||||
while parent is not None:
|
||||
if id(parent.element) in ids and _label(parent) is not None: return True
|
||||
parent = parent.parent
|
||||
return False
|
||||
|
||||
|
||||
def _descendant(node: _Node, ancestor: _Node) -> bool:
|
||||
parent = node.parent
|
||||
while parent is not None:
|
||||
if parent.element is ancestor.element: return True
|
||||
parent = parent.parent
|
||||
return False
|
||||
|
||||
|
||||
def _contained(node: _Node, container: _Node) -> bool:
|
||||
left, top, right, bottom = _action_bounds(node.bounds)
|
||||
outer_left, outer_top, outer_right, outer_bottom = _action_bounds(container.bounds)
|
||||
return outer_left <= left < right <= outer_right and outer_top <= top < bottom <= outer_bottom
|
||||
|
||||
|
||||
def _clickable_ancestor(node: _Node) -> bool:
|
||||
parent = node.parent
|
||||
while parent is not None:
|
||||
if parent.element.get("clickable") == "true": return True
|
||||
parent = parent.parent
|
||||
return False
|
||||
|
||||
|
||||
def _readonly(node: _Node) -> bool:
|
||||
return node.element.get("package") == PDD_PACKAGE and node.element.get("class") == "android.widget.TextView" and node.element.get("clickable") == "false" and node.element.get("enabled") == "true" and node.element.get("visible-to-user") == "true"
|
||||
|
||||
|
||||
def _live(node: _Node) -> bool:
|
||||
return node.element.get("package") == PDD_PACKAGE and node.element.get("clickable") == "true" and node.element.get("enabled") == "true" and node.element.get("visible-to-user") == "true" and bool(node.bounds)
|
||||
|
||||
|
||||
def _choice(node: _Node) -> bool:
|
||||
return _live(node) and node.element.get("class") == "android.view.ViewGroup" and node.element.get("selected") in {"true", "false"}
|
||||
|
||||
|
||||
def _eligible_entries(nodes: list[_Node]) -> list[_Node]:
|
||||
# 入口文本本身不可点击:必须逐层证明它仍位于已取证的唯一可点击祖先中,但动作坐标继续
|
||||
# 使用文本子节点的窄 bounds,避免把同一祖先内未知区域变成坐标兜底。“免拼购买”等底部
|
||||
# 容器既不属于这条祖先链,也绝不能作为替代入口。
|
||||
if any(node.bounds == _PRICE_PARENT for node in nodes): return []
|
||||
entry_labels = [
|
||||
node for node in nodes
|
||||
if node.text == _ENTRY
|
||||
and node.element.get("package") == PDD_PACKAGE
|
||||
and node.element.get("class") == "android.widget.TextView"
|
||||
]
|
||||
if len(entry_labels) != 1:
|
||||
return entry_labels
|
||||
action_ancestors = [
|
||||
node for node in nodes
|
||||
if _exact_entry_node(node, "android.view.ViewGroup", _ENTRY_ACTION_BOUNDS, "true")
|
||||
]
|
||||
if len(action_ancestors) != 1:
|
||||
return []
|
||||
action_ancestor = action_ancestors[0]
|
||||
entries: list[_Node] = []
|
||||
for node in entry_labels:
|
||||
if not _exact_entry_node(node, "android.widget.TextView", _ENTRY_TEXT_BOUNDS, "false"):
|
||||
return []
|
||||
inner = node.parent
|
||||
switcher = inner.parent if inner is not None else None
|
||||
frame = switcher.parent if switcher is not None else None
|
||||
ancestor = frame.parent if frame is not None else None
|
||||
if (
|
||||
inner is not None
|
||||
and _exact_entry_node(inner, "android.view.ViewGroup", _ENTRY_INNER_BOUNDS, "false")
|
||||
and switcher is not None
|
||||
and _exact_entry_node(switcher, "android.widget.ViewSwitcher", _ENTRY_INNER_BOUNDS, "false")
|
||||
and frame is not None
|
||||
and _exact_entry_node(frame, "android.widget.FrameLayout", _ENTRY_INNER_BOUNDS, "false")
|
||||
and ancestor is action_ancestor
|
||||
):
|
||||
entries.append(node)
|
||||
return entries
|
||||
|
||||
|
||||
def _exact_entry_node(node: _Node, class_name: str, bounds: str, clickable: str) -> bool:
|
||||
return (
|
||||
node.element.get("package") == PDD_PACKAGE
|
||||
and node.element.get("class") == class_name
|
||||
and node.bounds == bounds
|
||||
and node.element.get("clickable") == clickable
|
||||
and node.element.get("enabled") == "true"
|
||||
and node.element.get("visible-to-user") == "true"
|
||||
)
|
||||
|
||||
|
||||
def _action_bounds(bounds: str) -> tuple[int, int, int, int]:
|
||||
match = _BOUNDS.fullmatch(bounds)
|
||||
if match is None: raise SkuSelectionError("规格节点坐标格式无效,已停止操作。")
|
||||
left, top, right, bottom = (int(item) for item in match.groups())
|
||||
if not (0 <= left < right <= _W and 0 <= top < bottom <= _H):
|
||||
raise SkuSelectionError("规格节点坐标不在已取证屏幕范围内,已停止操作。")
|
||||
return left, top, right, bottom
|
||||
|
||||
|
||||
def _one(nodes: list[_Node], message: str) -> _Node:
|
||||
if len(nodes) != 1: raise SkuSelectionError(message)
|
||||
return nodes[0]
|
||||
@@ -0,0 +1,348 @@
|
||||
"""T-103 真机运行边界:窄适配器、原始截图和无页面正文的摘要。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime
|
||||
from hashlib import sha256
|
||||
import json
|
||||
from math import isfinite
|
||||
import os
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
from time import monotonic
|
||||
from typing import Any
|
||||
from uuid import uuid4
|
||||
|
||||
from PIL import Image, UnidentifiedImageError
|
||||
|
||||
from adbutils.errors import AdbTimeout
|
||||
from uiautomator2.exceptions import HTTPTimeoutError
|
||||
|
||||
from ..device.adb import AdbClient, DeviceConnectionError, DeviceInspection
|
||||
from ..device.baseline import PDD_PACKAGE, SCREENSHOT_PARAMS, _save_base64_screenshot, _sha256_file
|
||||
from .product_open import EXPECTED_PDD_VERSION
|
||||
from .product_url import ProductUrl, parse_product_url
|
||||
from .sku_selection import (
|
||||
EXPECTED_GOODS_ID,
|
||||
EXPECTED_UNIT_PRICE,
|
||||
SkuPanelDevice,
|
||||
SkuSelectionError,
|
||||
SkuSelectionFlow,
|
||||
_action_bounds,
|
||||
resolve_task_selection,
|
||||
)
|
||||
|
||||
|
||||
EXPECTED_DEVICE_MODEL = "PKG110"
|
||||
EXPECTED_ANDROID_VERSION = "16"
|
||||
EXPECTED_SCREEN_SIZE = (1080, 2376)
|
||||
|
||||
|
||||
class SkuSelectionRunError(RuntimeError):
|
||||
"""T-103 运行未完整完成;错误文本不携带设备或页面原文。"""
|
||||
|
||||
|
||||
class SkuSelectionRunTimeoutError(SkuSelectionRunError):
|
||||
"""设备 RPC 或操作超时。"""
|
||||
|
||||
|
||||
class SkuSelectionScreenshotError(SkuSelectionRunError):
|
||||
"""原始截图无法作为完整 PNG 原子发布。"""
|
||||
|
||||
|
||||
class SkuSelectionUnexpectedPriceError(SkuSelectionRunError):
|
||||
"""取证面板现价不是本任务已确认值。"""
|
||||
|
||||
|
||||
class SkuSelectionDeviceAdapterError(SkuSelectionRunError):
|
||||
"""第三方设备接口失败的脱敏映射。"""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SkuSelectionRunResult:
|
||||
"""已发布的截图和无页面正文 manifest 摘要。"""
|
||||
|
||||
output_directory: Path
|
||||
screenshot_path: Path
|
||||
manifest_path: Path
|
||||
unit_price: str
|
||||
|
||||
|
||||
class UiautomatorSkuPanelAdapter(SkuPanelDevice):
|
||||
"""把 uiautomator2 缩为 T-103 所需的读取与三种命名操作。
|
||||
|
||||
``tap_sku_entry``、``tap_sku_option`` 和 ``leave_sku_panel`` 是仅有的状态改变方法;
|
||||
坐标由 Flow 和本类双重检查后才计算中心点,每次调用只执行一次底层动作。
|
||||
"""
|
||||
|
||||
def __init__(self, device: Any, timeout_seconds: float) -> None:
|
||||
if not _is_positive_finite(timeout_seconds):
|
||||
raise ValueError("timeout_seconds 必须是大于 0 的有限数值")
|
||||
self._device = device
|
||||
self._timeout_seconds = timeout_seconds
|
||||
self._entry_was_tapped = False
|
||||
self._left_panel = False
|
||||
|
||||
@property
|
||||
def entry_was_tapped(self) -> bool:
|
||||
"""仅供运行器决定故障后的单次尽力返回,不是页面操作。"""
|
||||
|
||||
return self._entry_was_tapped
|
||||
|
||||
@property
|
||||
def left_panel(self) -> bool:
|
||||
return self._left_panel
|
||||
|
||||
def app_info(self, package_name: str) -> dict[str, Any]:
|
||||
value = self._call("app_info", package_name)
|
||||
if not isinstance(value, dict):
|
||||
raise SkuSelectionDeviceAdapterError("无法读取应用版本,已停止操作。")
|
||||
return value
|
||||
|
||||
def app_current(self) -> dict[str, Any]:
|
||||
value = self._call("app_current")
|
||||
if not isinstance(value, dict):
|
||||
raise SkuSelectionDeviceAdapterError("无法读取前台应用,已停止操作。")
|
||||
return value
|
||||
|
||||
def dump_window_hierarchy(self) -> str:
|
||||
value = self._call("jsonrpc_call", "dumpWindowHierarchy", [False, 50], timeout=self._timeout_seconds)
|
||||
if not isinstance(value, str):
|
||||
raise SkuSelectionDeviceAdapterError("节点树读取失败,已停止操作。")
|
||||
return value
|
||||
|
||||
def tap_sku_entry(self, bounds: str) -> None:
|
||||
# 超时也可能表示底层事件已经送达;必须先封存 attempt,后续绝不重试该入口。
|
||||
self._entry_was_tapped = True
|
||||
self._tap_bounds_once(bounds)
|
||||
|
||||
def tap_sku_option(self, bounds: str) -> None:
|
||||
self._tap_bounds_once(bounds)
|
||||
|
||||
def leave_sku_panel(self) -> None:
|
||||
if self._left_panel:
|
||||
raise SkuSelectionDeviceAdapterError("规格面板已经执行过返回,已停止操作。")
|
||||
# 底层调用即使报错也可能已把返回事件送达;先封存本次机会,finally 不得再次返回。
|
||||
self._left_panel = True
|
||||
self._call("jsonrpc_call", "pressKey", ["back"], timeout=self._timeout_seconds)
|
||||
|
||||
def capture_screenshot(self) -> str:
|
||||
value = self._call("jsonrpc_call", "takeScreenshot", SCREENSHOT_PARAMS, timeout=self._timeout_seconds)
|
||||
if not isinstance(value, str):
|
||||
raise SkuSelectionScreenshotError("规格面板原始截图读取失败,未发布任何证据产物。")
|
||||
return value
|
||||
|
||||
def display_size(self) -> tuple[int, int]:
|
||||
value = self._call("window_size")
|
||||
if not isinstance(value, tuple) or len(value) != 2 or any(not isinstance(item, int) for item in value):
|
||||
raise SkuSelectionDeviceAdapterError("无法读取屏幕坐标空间,已停止操作。")
|
||||
return value
|
||||
|
||||
def _tap_bounds_once(self, bounds: str) -> None:
|
||||
left, top, right, bottom = _action_bounds(bounds)
|
||||
center_x = left + (right - left) // 2
|
||||
center_y = top + (bottom - top) // 2
|
||||
self._call("jsonrpc_call", "click", [center_x, center_y], timeout=self._timeout_seconds)
|
||||
|
||||
def _call(self, method: str, *args: Any, **kwargs: Any) -> Any:
|
||||
try:
|
||||
operation = getattr(self._device, method)
|
||||
return operation(*args, **kwargs)
|
||||
except (AdbTimeout, HTTPTimeoutError, TimeoutError) as error:
|
||||
raise SkuSelectionRunTimeoutError("规格面板设备操作超时,已停止操作。") from error
|
||||
except SkuSelectionRunError:
|
||||
raise
|
||||
except Exception as error:
|
||||
raise SkuSelectionDeviceAdapterError("规格面板设备操作失败,已停止操作。") from error
|
||||
|
||||
|
||||
class SkuSelectionRunner:
|
||||
"""只运行 T-103 目标规格恢复、价格确认、原始截图和一次安全退出。"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
adb_client: AdbClient,
|
||||
connector: Callable[[str], Any],
|
||||
timeout_seconds: float,
|
||||
monotonic_clock: Callable[[], float] = monotonic,
|
||||
) -> None:
|
||||
if not _is_positive_finite(timeout_seconds):
|
||||
raise ValueError("timeout_seconds 必须是大于 0 的有限数值")
|
||||
self._adb_client = adb_client
|
||||
self._connector = connector
|
||||
self._timeout_seconds = timeout_seconds
|
||||
self._monotonic_clock = monotonic_clock
|
||||
|
||||
def run(
|
||||
self,
|
||||
serial: str,
|
||||
product_url: str,
|
||||
task_color: str,
|
||||
task_size: str,
|
||||
output_directory: Path,
|
||||
) -> SkuSelectionRunResult:
|
||||
link = parse_product_url(product_url)
|
||||
if link.goods_id != EXPECTED_GOODS_ID:
|
||||
raise SkuSelectionRunError("商品不是 T-103 已取证目标,已停止操作。")
|
||||
selection = resolve_task_selection(task_color, task_size)
|
||||
target = Path(output_directory)
|
||||
_validate_new_target(target)
|
||||
|
||||
adapter: UiautomatorSkuPanelAdapter | None = None
|
||||
flow: SkuSelectionFlow | None = None
|
||||
staging = _prepare_staging(target)
|
||||
deadline = self._monotonic_clock() + self._timeout_seconds
|
||||
try:
|
||||
inspection = self._adb_client.inspect(serial)
|
||||
_require_expected_device(inspection)
|
||||
adapter = UiautomatorSkuPanelAdapter(self._connector(serial), self._timeout_seconds)
|
||||
_require_expected_version(adapter.app_info(PDD_PACKAGE))
|
||||
if adapter.display_size() != EXPECTED_SCREEN_SIZE:
|
||||
raise SkuSelectionRunError("设备不是已取证的竖屏坐标空间,已停止操作。")
|
||||
pre_intent_hierarchy = adapter.dump_window_hierarchy()
|
||||
# 固定 ACTION_VIEW、固定 PDD package 和 canonical goods_id;不接受任意 URL 或 shell。
|
||||
self._adb_client.start_pdd_view_intent(serial, link.goods_id)
|
||||
|
||||
remaining = deadline - self._monotonic_clock()
|
||||
if remaining <= 0:
|
||||
raise SkuSelectionRunTimeoutError("等待规格入口超时,未执行点击。")
|
||||
flow = SkuSelectionFlow(adapter, entry_wait_timeout_seconds=remaining)
|
||||
flow.open_sku_panel(link.canonical_url, pre_intent_hierarchy)
|
||||
flow.select_sku_options(selection)
|
||||
unit_price = flow.verify_target_selection_and_read_price(selection)
|
||||
if unit_price != EXPECTED_UNIT_PRICE:
|
||||
raise SkuSelectionUnexpectedPriceError("规格面板现价不是本任务已确认值,已停止操作。")
|
||||
|
||||
screenshot_path = staging / "screenshot.png"
|
||||
try:
|
||||
_save_base64_screenshot(adapter.capture_screenshot(), screenshot_path)
|
||||
_require_screenshot_size(screenshot_path)
|
||||
except SkuSelectionRunError:
|
||||
raise
|
||||
except Exception as error:
|
||||
raise SkuSelectionScreenshotError("规格面板原始截图保存失败,未发布任何证据产物。") from error
|
||||
|
||||
manifest_path = staging / "manifest.json"
|
||||
# 截图可能落在动态页面切换边界;发布前必须用一棵更新节点树同时重证两维和现价。
|
||||
final_price = flow.verify_target_selection_and_read_price(selection)
|
||||
if final_price != EXPECTED_UNIT_PRICE:
|
||||
raise SkuSelectionUnexpectedPriceError("截图后规格面板现价不是本任务已确认值,已停止操作。")
|
||||
# 正常路径仍经 Flow 做最后一次前台和面板判定;返回操作只发生一次。
|
||||
flow.exit_sku_panel_safely()
|
||||
manifest_path.write_text(
|
||||
json.dumps(_manifest(inspection, serial, link, screenshot_path, task_color, task_size), ensure_ascii=False, indent=2, sort_keys=True) + "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
# Windows 的 rename 不替换既有目标;并发创建 target 时保留其内容并把本次运行判失败。
|
||||
os.rename(staging, target)
|
||||
staging = None
|
||||
except (DeviceConnectionError, SkuSelectionRunError, SkuSelectionError):
|
||||
_clean_staging(staging)
|
||||
raise
|
||||
except (AdbTimeout, HTTPTimeoutError, TimeoutError) as error:
|
||||
_clean_staging(staging)
|
||||
raise SkuSelectionRunTimeoutError("规格面板运行超时,未发布任何证据产物。") from error
|
||||
except OSError as error:
|
||||
_clean_staging(staging)
|
||||
raise SkuSelectionRunError("规格面板证据目录无法创建或发布,未发布任何证据产物。") from error
|
||||
except Exception as error:
|
||||
_clean_staging(staging)
|
||||
raise SkuSelectionRunError("规格面板运行未完成,未发布任何证据产物。") from error
|
||||
finally:
|
||||
# 失败路径只能复用 Flow 的版本、前台和面板证明;证明不了便停止,绝不盲目返回。
|
||||
if flow is not None and adapter is not None and adapter.entry_was_tapped and not adapter.left_panel:
|
||||
try:
|
||||
flow.reconcile_pending_action()
|
||||
flow.exit_sku_panel_safely()
|
||||
except (SkuSelectionRunError, SkuSelectionError):
|
||||
pass
|
||||
|
||||
return SkuSelectionRunResult(
|
||||
output_directory=target,
|
||||
screenshot_path=target / "screenshot.png",
|
||||
manifest_path=target / "manifest.json",
|
||||
unit_price=EXPECTED_UNIT_PRICE,
|
||||
)
|
||||
|
||||
|
||||
def _is_positive_finite(value: object) -> bool:
|
||||
return isinstance(value, (int, float)) and not isinstance(value, bool) and value > 0 and isfinite(value)
|
||||
|
||||
|
||||
def _validate_new_target(target: Path) -> None:
|
||||
if target.exists():
|
||||
raise SkuSelectionRunError("输出目录已存在;为防止覆盖旧证据,已停止操作。")
|
||||
if not target.name:
|
||||
raise SkuSelectionRunError("输出目录必须是明确的新目录。")
|
||||
|
||||
|
||||
def _prepare_staging(target: Path) -> Path:
|
||||
staging: Path | None = None
|
||||
try:
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
staging = target.parent / f".{target.name}.staging-{uuid4().hex}"
|
||||
staging.mkdir()
|
||||
probe = staging / ".write-probe"
|
||||
probe.write_bytes(b"ok")
|
||||
probe.unlink()
|
||||
return staging
|
||||
except OSError as error:
|
||||
_clean_staging(staging)
|
||||
raise SkuSelectionRunError("输出目录不可写,已停止操作。") from error
|
||||
|
||||
|
||||
def _clean_staging(staging: Path | None) -> None:
|
||||
if staging is not None and staging.exists():
|
||||
shutil.rmtree(staging)
|
||||
|
||||
|
||||
def _require_expected_version(app_info: object) -> str:
|
||||
version = (app_info.get("versionName") or app_info.get("version_name")) if isinstance(app_info, dict) else None
|
||||
if version != EXPECTED_PDD_VERSION:
|
||||
raise SkuSelectionRunError("拼多多版本与已取证版本不一致,已停止操作。")
|
||||
return version
|
||||
|
||||
|
||||
def _require_expected_device(inspection: DeviceInspection) -> None:
|
||||
if inspection.model != EXPECTED_DEVICE_MODEL or inspection.android_version != EXPECTED_ANDROID_VERSION:
|
||||
raise SkuSelectionRunError("设备型号或 Android 版本不是已取证组合,已停止操作。")
|
||||
|
||||
|
||||
def _require_screenshot_size(screenshot_path: Path) -> None:
|
||||
try:
|
||||
with Image.open(screenshot_path) as image:
|
||||
image.load()
|
||||
if image.size != EXPECTED_SCREEN_SIZE:
|
||||
raise SkuSelectionScreenshotError("原始截图坐标空间不是已取证尺寸,未发布任何证据产物。")
|
||||
except SkuSelectionRunError:
|
||||
raise
|
||||
except (UnidentifiedImageError, OSError) as error:
|
||||
raise SkuSelectionScreenshotError("原始截图无效,未发布任何证据产物。") from error
|
||||
|
||||
|
||||
def _manifest(inspection: DeviceInspection, serial: str, link: ProductUrl, screenshot_path: Path, task_color: str, task_size: str) -> dict[str, Any]:
|
||||
"""仅写可审计摘要;原始 serial、节点树、页面文案和实际截图内容均不写入 manifest。"""
|
||||
|
||||
return {
|
||||
"schema_version": 1,
|
||||
"captured_at": datetime.now(UTC).isoformat(),
|
||||
"operation": "t103-sku-selection",
|
||||
"product": {"goods_id": link.goods_id, "canonical_url": link.canonical_url},
|
||||
"target_selection": {"color": task_color, "size": task_size},
|
||||
"unit_price": EXPECTED_UNIT_PRICE,
|
||||
"selection_status": "restored",
|
||||
"panel_status": "verified",
|
||||
"safe_exit": "completed",
|
||||
"page_identity": "human_review_required",
|
||||
"channel": "wifi" if ":" in serial else "usb",
|
||||
"serial_sha256": sha256(serial.encode("utf-8")).hexdigest(),
|
||||
"device": {
|
||||
"model": inspection.model,
|
||||
"android_version": inspection.android_version,
|
||||
"pdd_package": PDD_PACKAGE,
|
||||
"pdd_version": EXPECTED_PDD_VERSION,
|
||||
},
|
||||
"artifacts": [{"path": screenshot_path.name, "sha256": _sha256_file(screenshot_path)}],
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
"""采购工具的本地运行目录策略。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RuntimePaths:
|
||||
"""仅包含应用生成物的本地目录。
|
||||
|
||||
日志和证据产物不落在源码树中,避免把含有现场信息的运行数据误提交到 Git。
|
||||
"""
|
||||
|
||||
root: Path
|
||||
logs: Path
|
||||
artifacts: Path
|
||||
|
||||
@classmethod
|
||||
def from_root(cls, root: Path) -> "RuntimePaths":
|
||||
resolved_root = root.expanduser()
|
||||
return cls(
|
||||
root=resolved_root,
|
||||
logs=resolved_root / "logs",
|
||||
artifacts=resolved_root / "artifacts",
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def default(cls) -> "RuntimePaths":
|
||||
local_app_data = os.environ.get("LOCALAPPDATA")
|
||||
if local_app_data:
|
||||
return cls.from_root(Path(local_app_data) / "cmbuyer")
|
||||
|
||||
return cls.from_root(Path.home() / ".local" / "share" / "cmbuyer")
|
||||
|
||||
def ensure_exists(self) -> None:
|
||||
"""创建运行目录;调用方负责向用户呈现无法创建目录的错误。"""
|
||||
|
||||
self.logs.mkdir(parents=True, exist_ok=True)
|
||||
self.artifacts.mkdir(parents=True, exist_ok=True)
|
||||
@@ -0,0 +1 @@
|
||||
"""采购工具的离线单元测试。"""
|
||||
@@ -0,0 +1 @@
|
||||
"""设备连接与基线取证的离线测试。"""
|
||||
@@ -0,0 +1,273 @@
|
||||
"""ADB 设备边界测试:所有命令执行器均为 mock,不连接真机。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Sequence
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import sys
|
||||
import unittest
|
||||
|
||||
|
||||
CLIENT_ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(CLIENT_ROOT / "src"))
|
||||
|
||||
from cmbuyer_client.device.adb import (
|
||||
AdbClient,
|
||||
CommandResult,
|
||||
DeviceIdentityUnconfirmedError,
|
||||
DeviceCommandError,
|
||||
DeviceCommandTimeoutError,
|
||||
DeviceNotFoundError,
|
||||
DeviceOfflineError,
|
||||
DeviceStateError,
|
||||
DeviceUnauthorizedError,
|
||||
DuplicatePhysicalDeviceError,
|
||||
IntentLaunchUnconfirmedError,
|
||||
SerialRequiredError,
|
||||
)
|
||||
|
||||
|
||||
USB_SERIAL = "3B65BD02H7F00000"
|
||||
WIFI_SERIAL = "192.168.0.173:5555"
|
||||
|
||||
|
||||
class FakeRunner:
|
||||
def __init__(self, devices_output: str, properties: dict[tuple[str, str], CommandResult | str]) -> None:
|
||||
self.devices_output = devices_output
|
||||
self.properties = properties
|
||||
self.calls: list[tuple[str, ...]] = []
|
||||
|
||||
def run(self, arguments: Sequence[str], timeout_seconds: float) -> CommandResult:
|
||||
self.calls.append(tuple(arguments))
|
||||
if tuple(arguments) == ("devices", "-l"):
|
||||
return CommandResult(stdout=self.devices_output)
|
||||
key = (arguments[1], arguments[-1])
|
||||
value = self.properties.get(key, "")
|
||||
return value if isinstance(value, CommandResult) else CommandResult(stdout=value)
|
||||
|
||||
|
||||
def _properties(serials: tuple[str, ...]) -> dict[tuple[str, str], str]:
|
||||
values: dict[tuple[str, str], str] = {}
|
||||
for serial in serials:
|
||||
values[(serial, "ro.serialno")] = "physical-phone-1"
|
||||
values[(serial, "ro.boot.serialno")] = "physical-phone-1"
|
||||
values[(serial, "ro.product.model")] = "PKG110"
|
||||
values[(serial, "ro.product.name")] = "PKG110"
|
||||
values[(serial, "ro.product.device")] = "OP5D2BL1"
|
||||
values[(serial, "ro.build.version.release")] = "16"
|
||||
return values
|
||||
|
||||
|
||||
class AdbClientTests(unittest.TestCase):
|
||||
def test_requires_explicit_serial(self) -> None:
|
||||
runner = FakeRunner("List of devices attached\n", {})
|
||||
|
||||
with self.assertRaises(SerialRequiredError):
|
||||
AdbClient(runner).inspect(" ")
|
||||
|
||||
self.assertEqual(runner.calls, [])
|
||||
|
||||
def test_missing_offline_and_unauthorized_are_distinct(self) -> None:
|
||||
missing = AdbClient(FakeRunner("List of devices attached\n", {}))
|
||||
with self.assertRaises(DeviceNotFoundError):
|
||||
missing.inspect(USB_SERIAL)
|
||||
|
||||
offline = AdbClient(FakeRunner(f"List of devices attached\n{USB_SERIAL}\toffline\n", {}))
|
||||
with self.assertRaises(DeviceOfflineError):
|
||||
offline.inspect(USB_SERIAL)
|
||||
|
||||
unauthorized = AdbClient(FakeRunner(f"List of devices attached\n{USB_SERIAL}\tunauthorized\n", {}))
|
||||
with self.assertRaises(DeviceUnauthorizedError):
|
||||
unauthorized.inspect(USB_SERIAL)
|
||||
|
||||
def test_two_channels_with_same_physical_identity_fail_closed(self) -> None:
|
||||
output = (
|
||||
"List of devices attached\n"
|
||||
f"{WIFI_SERIAL}\tdevice product:PKG110 model:PKG110 device:OP5D2BL1\n"
|
||||
f"{USB_SERIAL}\tdevice product:PKG110 model:PKG110 device:OP5D2BL1\n"
|
||||
)
|
||||
runner = FakeRunner(output, _properties((WIFI_SERIAL, USB_SERIAL)))
|
||||
|
||||
with self.assertRaises(DuplicatePhysicalDeviceError):
|
||||
AdbClient(runner).inspect(USB_SERIAL)
|
||||
|
||||
self.assertIn(("-s", WIFI_SERIAL, "shell", "getprop", "ro.serialno"), runner.calls)
|
||||
self.assertIn(("-s", USB_SERIAL, "shell", "getprop", "ro.serialno"), runner.calls)
|
||||
|
||||
def test_multiple_online_devices_with_failed_identity_fail_closed(self) -> None:
|
||||
output = (
|
||||
"List of devices attached\n"
|
||||
f"{WIFI_SERIAL}\tdevice product:PKG110 model:PKG110 device:OP5D2BL1\n"
|
||||
f"{USB_SERIAL}\tdevice product:PKG110 model:PKG110 device:OP5D2BL1\n"
|
||||
)
|
||||
properties = _properties((WIFI_SERIAL, USB_SERIAL))
|
||||
properties[(WIFI_SERIAL, "ro.serialno")] = CommandResult(stdout="", returncode=1)
|
||||
runner = FakeRunner(output, properties)
|
||||
|
||||
with self.assertRaises(DeviceIdentityUnconfirmedError):
|
||||
AdbClient(runner).inspect(USB_SERIAL)
|
||||
|
||||
def test_online_explicit_serial_reads_non_sensitive_metadata(self) -> None:
|
||||
output = f"List of devices attached\n{USB_SERIAL}\tdevice product:PKG110 model:PKG110 device:OP5D2BL1\n"
|
||||
inspection = AdbClient(FakeRunner(output, _properties((USB_SERIAL,)))).inspect(USB_SERIAL)
|
||||
|
||||
self.assertEqual(inspection.device.serial, USB_SERIAL)
|
||||
self.assertEqual(inspection.model, "PKG110")
|
||||
self.assertEqual(inspection.android_version, "16")
|
||||
|
||||
def test_single_online_device_does_not_require_hardware_identity(self) -> None:
|
||||
output = f"List of devices attached\n{USB_SERIAL}\tdevice product:PKG110 model:PKG110 device:OP5D2BL1\n"
|
||||
properties = _properties((USB_SERIAL,))
|
||||
properties[(USB_SERIAL, "ro.serialno")] = ""
|
||||
properties[(USB_SERIAL, "ro.boot.serialno")] = ""
|
||||
|
||||
inspection = AdbClient(FakeRunner(output, properties)).inspect(USB_SERIAL)
|
||||
|
||||
self.assertEqual(inspection.model, "PKG110")
|
||||
|
||||
def test_multiple_online_devices_without_hardware_identity_are_unconfirmed(self) -> None:
|
||||
output = (
|
||||
"List of devices attached\n"
|
||||
f"{WIFI_SERIAL}\tdevice product:PKG110 model:PKG110 device:OP5D2BL1\n"
|
||||
f"{USB_SERIAL}\tdevice product:PKG110 model:PKG110 device:OP5D2BL1\n"
|
||||
)
|
||||
properties = _properties((WIFI_SERIAL, USB_SERIAL))
|
||||
properties[(WIFI_SERIAL, "ro.serialno")] = ""
|
||||
properties[(WIFI_SERIAL, "ro.boot.serialno")] = ""
|
||||
|
||||
with self.assertRaises(DeviceIdentityUnconfirmedError):
|
||||
AdbClient(FakeRunner(output, properties)).inspect(USB_SERIAL)
|
||||
|
||||
def test_multiple_online_devices_with_different_identity_keep_explicit_selection(self) -> None:
|
||||
output = (
|
||||
"List of devices attached\n"
|
||||
f"{WIFI_SERIAL}\tdevice product:PKG110 model:PKG110 device:OP5D2BL1\n"
|
||||
f"{USB_SERIAL}\tdevice product:PKG110 model:PKG110 device:OP5D2BL1\n"
|
||||
)
|
||||
properties = _properties((WIFI_SERIAL, USB_SERIAL))
|
||||
properties[(WIFI_SERIAL, "ro.serialno")] = "physical-phone-2"
|
||||
properties[(WIFI_SERIAL, "ro.boot.serialno")] = "physical-phone-2"
|
||||
|
||||
inspection = AdbClient(FakeRunner(output, properties)).inspect(USB_SERIAL)
|
||||
|
||||
self.assertEqual(inspection.device.serial, USB_SERIAL)
|
||||
|
||||
def test_shared_boot_serial_is_duplicate_even_when_ro_serial_differs(self) -> None:
|
||||
output = (
|
||||
"List of devices attached\n"
|
||||
f"{WIFI_SERIAL}\tdevice product:PKG110 model:PKG110 device:OP5D2BL1\n"
|
||||
f"{USB_SERIAL}\tdevice product:PKG110 model:PKG110 device:OP5D2BL1\n"
|
||||
)
|
||||
properties = _properties((WIFI_SERIAL, USB_SERIAL))
|
||||
properties[(WIFI_SERIAL, "ro.serialno")] = "wifi-transport-serial"
|
||||
properties[(USB_SERIAL, "ro.serialno")] = "usb-transport-serial"
|
||||
properties[(WIFI_SERIAL, "ro.boot.serialno")] = "shared-hardware-serial"
|
||||
properties[(USB_SERIAL, "ro.boot.serialno")] = "shared-hardware-serial"
|
||||
|
||||
with self.assertRaises(DuplicatePhysicalDeviceError):
|
||||
AdbClient(FakeRunner(output, properties)).inspect(USB_SERIAL)
|
||||
|
||||
def test_unknown_adb_state_is_rejected(self) -> None:
|
||||
client = AdbClient(FakeRunner(f"List of devices attached\n{USB_SERIAL}\trecovery\n", {}))
|
||||
|
||||
with self.assertRaises(DeviceStateError):
|
||||
client.inspect(USB_SERIAL)
|
||||
|
||||
def test_runner_timeout_is_a_distinct_connection_error(self) -> None:
|
||||
class TimeoutRunner:
|
||||
def run(self, arguments: Sequence[str], timeout_seconds: float) -> CommandResult:
|
||||
raise subprocess.TimeoutExpired(arguments, timeout_seconds)
|
||||
|
||||
with self.assertRaises(DeviceCommandTimeoutError):
|
||||
AdbClient(TimeoutRunner()).inspect(USB_SERIAL)
|
||||
|
||||
def test_product_intent_is_fixed_to_action_view_and_pdd_package(self) -> None:
|
||||
class IntentRunner:
|
||||
def __init__(self) -> None:
|
||||
self.calls: list[tuple[str, ...]] = []
|
||||
|
||||
def run(self, arguments: Sequence[str], timeout_seconds: float) -> CommandResult:
|
||||
self.calls.append(tuple(arguments))
|
||||
return CommandResult(stdout="Status: ok\n")
|
||||
|
||||
runner = IntentRunner()
|
||||
summary = AdbClient(runner).start_pdd_view_intent(
|
||||
USB_SERIAL,
|
||||
"123",
|
||||
)
|
||||
|
||||
self.assertEqual(summary.status, "ok")
|
||||
self.assertEqual(
|
||||
runner.calls,
|
||||
[
|
||||
(
|
||||
"-s",
|
||||
USB_SERIAL,
|
||||
"shell",
|
||||
"am",
|
||||
"start",
|
||||
"-W",
|
||||
"-a",
|
||||
"android.intent.action.VIEW",
|
||||
"-d",
|
||||
"https://mobile.yangkeduo.com/goods.html?goods_id=123",
|
||||
"-p",
|
||||
"com.xunmeng.pinduoduo",
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
def test_product_intent_without_explicit_success_is_rejected(self) -> None:
|
||||
class UnknownIntentRunner:
|
||||
def run(self, arguments: Sequence[str], timeout_seconds: float) -> CommandResult:
|
||||
return CommandResult(stdout="Starting: Intent { ... }\n")
|
||||
|
||||
with self.assertRaises(IntentLaunchUnconfirmedError):
|
||||
AdbClient(UnknownIntentRunner()).start_pdd_view_intent(
|
||||
USB_SERIAL,
|
||||
"123",
|
||||
)
|
||||
|
||||
def test_product_intent_rejects_invalid_goods_id_before_runner(self) -> None:
|
||||
class RecordingRunner:
|
||||
def __init__(self) -> None:
|
||||
self.calls: list[tuple[str, ...]] = []
|
||||
|
||||
def run(self, arguments: Sequence[str], timeout_seconds: float) -> CommandResult:
|
||||
self.calls.append(tuple(arguments))
|
||||
return CommandResult(stdout="Status: ok\n")
|
||||
|
||||
invalid_values: tuple[object, ...] = (
|
||||
"",
|
||||
"12a",
|
||||
"123",
|
||||
" 123",
|
||||
"123 ",
|
||||
"https://mobile.yangkeduo.com/goods.html?goods_id=123",
|
||||
"am start -W -d anything",
|
||||
123,
|
||||
None,
|
||||
)
|
||||
for value in invalid_values:
|
||||
with self.subTest(value=repr(value)):
|
||||
runner = RecordingRunner()
|
||||
with self.assertRaises(ValueError):
|
||||
AdbClient(runner).start_pdd_view_intent(USB_SERIAL, value) # type: ignore[arg-type]
|
||||
self.assertEqual(runner.calls, [])
|
||||
|
||||
def test_product_intent_nonzero_and_timeout_remain_distinct(self) -> None:
|
||||
class FailedIntentRunner:
|
||||
def run(self, arguments: Sequence[str], timeout_seconds: float) -> CommandResult:
|
||||
return CommandResult(stdout="sensitive command output", returncode=1)
|
||||
|
||||
class TimeoutIntentRunner:
|
||||
def run(self, arguments: Sequence[str], timeout_seconds: float) -> CommandResult:
|
||||
raise subprocess.TimeoutExpired(arguments, timeout_seconds)
|
||||
|
||||
with self.assertRaises(DeviceCommandError) as command_error:
|
||||
AdbClient(FailedIntentRunner()).start_pdd_view_intent(USB_SERIAL, "123")
|
||||
self.assertNotIn("sensitive command output", str(command_error.exception))
|
||||
|
||||
with self.assertRaises(DeviceCommandTimeoutError):
|
||||
AdbClient(TimeoutIntentRunner()).start_pdd_view_intent(USB_SERIAL, "123")
|
||||
@@ -0,0 +1,281 @@
|
||||
"""基线取证测试:mock ADB/uiautomator2,不连接手机。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
import base64
|
||||
from io import BytesIO
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
from PIL import Image
|
||||
from uiautomator2.exceptions import HTTPTimeoutError
|
||||
|
||||
|
||||
CLIENT_ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(CLIENT_ROOT / "src"))
|
||||
sys.path.insert(0, str(CLIENT_ROOT / "scripts"))
|
||||
|
||||
from cmbuyer_client.device.adb import AdbDevice, DeviceInspection
|
||||
from cmbuyer_client.device.baseline import (
|
||||
BaselineCaptureError,
|
||||
BaselineCaptureTimeoutError,
|
||||
DeviceBaselineCapturer,
|
||||
NoReconnectUiautomatorConnector,
|
||||
PDD_PACKAGE,
|
||||
)
|
||||
from capture_device_baseline import parse_arguments, validate_arguments
|
||||
|
||||
|
||||
SERIAL = "USB-serial-for-test"
|
||||
|
||||
|
||||
class StaticAdbClient:
|
||||
def __init__(self) -> None:
|
||||
self.serials: list[str] = []
|
||||
|
||||
def inspect(self, serial: str) -> DeviceInspection:
|
||||
self.serials.append(serial)
|
||||
return DeviceInspection(
|
||||
device=AdbDevice(serial=serial, state="device", model="Test Model"),
|
||||
model="Test Model",
|
||||
android_version="16",
|
||||
)
|
||||
|
||||
|
||||
class FakeUiDevice:
|
||||
def __init__(self, fail_dump: bool = False) -> None:
|
||||
self.fail_dump = fail_dump
|
||||
self.rpc_calls: list[tuple[str, object, float]] = []
|
||||
self.app_info_calls: list[str] = []
|
||||
|
||||
def app_info(self, package_name: str) -> dict[str, str]:
|
||||
self.app_info_calls.append(package_name)
|
||||
return {"versionName": "8.17.0"}
|
||||
|
||||
def jsonrpc_call(self, method: str, params: object = None, timeout: float = 10) -> str:
|
||||
self.rpc_calls.append((method, params, timeout))
|
||||
if method == "takeScreenshot":
|
||||
image_data = BytesIO()
|
||||
Image.new("RGB", (1, 1), color="white").save(image_data, format="PNG")
|
||||
return base64.b64encode(image_data.getvalue()).decode("ascii")
|
||||
if method != "dumpWindowHierarchy":
|
||||
raise AssertionError(f"unexpected method: {method}")
|
||||
if self.fail_dump:
|
||||
raise RuntimeError("mock dump failed")
|
||||
return "<hierarchy><node text='page body must stay out of manifest'/></hierarchy>"
|
||||
|
||||
|
||||
class BaselineCaptureTests(unittest.TestCase):
|
||||
def test_capture_writes_hashes_without_xml_or_raw_serial_in_manifest(self) -> None:
|
||||
adb = StaticAdbClient()
|
||||
device = FakeUiDevice()
|
||||
capturer = DeviceBaselineCapturer(adb, lambda serial: device, timeout_seconds=7.5)
|
||||
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
output = Path(directory) / "baseline"
|
||||
result = capturer.capture(SERIAL, output)
|
||||
manifest = result.manifest_path.read_text(encoding="utf-8")
|
||||
|
||||
self.assertEqual(adb.serials, [SERIAL])
|
||||
self.assertEqual(device.app_info_calls, [PDD_PACKAGE])
|
||||
self.assertEqual(
|
||||
device.rpc_calls,
|
||||
[
|
||||
("takeScreenshot", [1, 80], 7.5),
|
||||
("dumpWindowHierarchy", [False, 50], 7.5),
|
||||
],
|
||||
)
|
||||
self.assertTrue(result.screenshot_path.is_file())
|
||||
self.assertTrue(result.hierarchy_path.is_file())
|
||||
self.assertIn('"sha256"', manifest)
|
||||
self.assertNotIn("page body must stay out of manifest", manifest)
|
||||
self.assertNotIn(SERIAL, manifest)
|
||||
self.assertIn('"channel": "usb"', manifest)
|
||||
|
||||
def test_capture_failure_cleans_staging_and_does_not_publish_partial_output(self) -> None:
|
||||
device = FakeUiDevice(fail_dump=True)
|
||||
capturer = DeviceBaselineCapturer(StaticAdbClient(), lambda serial: device, timeout_seconds=5)
|
||||
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
parent = Path(directory)
|
||||
output = parent / "baseline"
|
||||
with self.assertRaises(BaselineCaptureError) as raised:
|
||||
capturer.capture(SERIAL, output)
|
||||
|
||||
self.assertFalse(output.exists())
|
||||
self.assertEqual(list(parent.iterdir()), [])
|
||||
self.assertNotIn("mock dump failed", str(raised.exception))
|
||||
|
||||
def test_existing_output_is_never_overwritten(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
output = Path(directory) / "baseline"
|
||||
output.mkdir()
|
||||
sentinel = output / "keep.txt"
|
||||
sentinel.write_text("preserve", encoding="utf-8")
|
||||
capturer = DeviceBaselineCapturer(StaticAdbClient(), lambda serial: FakeUiDevice(), timeout_seconds=5)
|
||||
|
||||
with self.assertRaises(BaselineCaptureError):
|
||||
capturer.capture(SERIAL, output)
|
||||
self.assertEqual(sentinel.read_text(encoding="utf-8"), "preserve")
|
||||
|
||||
def test_no_reconnect_connector_passes_only_current_adb_device_object(self) -> None:
|
||||
class ListedDevice:
|
||||
serial = SERIAL
|
||||
|
||||
listed = ListedDevice()
|
||||
connected: list[object] = []
|
||||
|
||||
connector = NoReconnectUiautomatorConnector(lambda: [listed], lambda device: connected.append(device) or FakeUiDevice())
|
||||
connector(SERIAL)
|
||||
|
||||
self.assertEqual(connected, [listed])
|
||||
|
||||
def test_no_reconnect_connector_refuses_disappeared_serial(self) -> None:
|
||||
connector = NoReconnectUiautomatorConnector(lambda: [], lambda device: FakeUiDevice())
|
||||
|
||||
with self.assertRaises(BaselineCaptureError) as raised:
|
||||
connector(SERIAL)
|
||||
self.assertIn("拒绝自动重连", str(raised.exception))
|
||||
|
||||
def test_connector_exception_is_redacted_and_publishes_no_partial_output(self) -> None:
|
||||
def failing_connector(serial: str) -> FakeUiDevice:
|
||||
raise RuntimeError(f"third party leaked {serial}")
|
||||
|
||||
capturer = DeviceBaselineCapturer(StaticAdbClient(), failing_connector, timeout_seconds=5)
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
output = Path(directory) / "baseline"
|
||||
with self.assertRaises(BaselineCaptureError) as raised:
|
||||
capturer.capture(SERIAL, output)
|
||||
|
||||
self.assertNotIn(SERIAL, str(raised.exception))
|
||||
self.assertFalse(output.exists())
|
||||
self.assertEqual(list(Path(directory).iterdir()), [])
|
||||
|
||||
def test_invalid_screenshot_base64_syntax_fails_closed_without_partial_output(self) -> None:
|
||||
class InvalidScreenshotDevice(FakeUiDevice):
|
||||
def jsonrpc_call(self, method: str, params: object = None, timeout: float = 10) -> str:
|
||||
if method == "takeScreenshot":
|
||||
valid = super().jsonrpc_call(method, params, timeout)
|
||||
return valid[:12] + "!" + valid[12:]
|
||||
return super().jsonrpc_call(method, params, timeout)
|
||||
|
||||
capturer = DeviceBaselineCapturer(StaticAdbClient(), lambda serial: InvalidScreenshotDevice(), timeout_seconds=5)
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
output = Path(directory) / "baseline"
|
||||
with self.assertRaises(BaselineCaptureError) as raised:
|
||||
capturer.capture(SERIAL, output)
|
||||
|
||||
self.assertIn("Base64 语法无效", str(raised.exception))
|
||||
self.assertNotIn("!", str(raised.exception))
|
||||
self.assertFalse(output.exists())
|
||||
self.assertEqual(list(Path(directory).iterdir()), [])
|
||||
|
||||
def test_invalid_padding_and_unapproved_ascii_whitespace_fail_closed(self) -> None:
|
||||
invalid_insertions = {
|
||||
"padding": lambda value: value[:-1],
|
||||
"vertical-tab": lambda value: value[:12] + "\v" + value[12:],
|
||||
"form-feed": lambda value: value[:12] + "\f" + value[12:],
|
||||
}
|
||||
|
||||
for name, make_invalid in invalid_insertions.items():
|
||||
with self.subTest(name=name), tempfile.TemporaryDirectory() as directory:
|
||||
class InvalidScreenshotDevice(FakeUiDevice):
|
||||
def jsonrpc_call(self, method: str, params: object = None, timeout: float = 10) -> str:
|
||||
value = super().jsonrpc_call(method, params, timeout)
|
||||
if method == "takeScreenshot":
|
||||
return make_invalid(value)
|
||||
return value
|
||||
|
||||
output = Path(directory) / "baseline"
|
||||
capturer = DeviceBaselineCapturer(
|
||||
StaticAdbClient(),
|
||||
lambda serial: InvalidScreenshotDevice(),
|
||||
timeout_seconds=5,
|
||||
)
|
||||
with self.assertRaises(BaselineCaptureError) as raised:
|
||||
capturer.capture(SERIAL, output)
|
||||
|
||||
self.assertIn("Base64 语法无效", str(raised.exception))
|
||||
self.assertNotIn(SERIAL, str(raised.exception))
|
||||
self.assertFalse(output.exists())
|
||||
self.assertEqual(list(Path(directory).iterdir()), [])
|
||||
|
||||
def test_base64_decoded_nonimage_fails_closed_without_partial_output(self) -> None:
|
||||
class NonImageScreenshotDevice(FakeUiDevice):
|
||||
def jsonrpc_call(self, method: str, params: object = None, timeout: float = 10) -> str:
|
||||
if method == "takeScreenshot":
|
||||
return base64.b64encode(b"not an image").decode("ascii")
|
||||
return super().jsonrpc_call(method, params, timeout)
|
||||
|
||||
capturer = DeviceBaselineCapturer(StaticAdbClient(), lambda serial: NonImageScreenshotDevice(), timeout_seconds=5)
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
output = Path(directory) / "baseline"
|
||||
with self.assertRaises(BaselineCaptureError) as raised:
|
||||
capturer.capture(SERIAL, output)
|
||||
|
||||
self.assertIn("图像数据无效", str(raised.exception))
|
||||
self.assertNotIn("not an image", str(raised.exception))
|
||||
self.assertFalse(output.exists())
|
||||
self.assertEqual(list(Path(directory).iterdir()), [])
|
||||
|
||||
def test_ascii_base64_whitespace_is_normalized_before_strict_decode(self) -> None:
|
||||
class WhitespaceScreenshotDevice(FakeUiDevice):
|
||||
def jsonrpc_call(self, method: str, params: object = None, timeout: float = 10) -> str:
|
||||
value = super().jsonrpc_call(method, params, timeout)
|
||||
if method == "takeScreenshot":
|
||||
return value[:10] + " \t\r\n" + value[10:30] + "\n" + value[30:]
|
||||
return value
|
||||
|
||||
capturer = DeviceBaselineCapturer(StaticAdbClient(), lambda serial: WhitespaceScreenshotDevice(), timeout_seconds=5)
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
output = Path(directory) / "baseline"
|
||||
result = capturer.capture(SERIAL, output)
|
||||
|
||||
self.assertTrue(result.screenshot_path.is_file())
|
||||
with Image.open(result.screenshot_path) as image:
|
||||
self.assertEqual(image.size, (1, 1))
|
||||
|
||||
def test_invalid_or_non_hierarchy_xml_fails_closed_without_partial_output(self) -> None:
|
||||
class InvalidHierarchyDevice(FakeUiDevice):
|
||||
def jsonrpc_call(self, method: str, params: object = None, timeout: float = 10) -> str:
|
||||
if method == "dumpWindowHierarchy":
|
||||
return "<not-hierarchy/>"
|
||||
return super().jsonrpc_call(method, params, timeout)
|
||||
|
||||
capturer = DeviceBaselineCapturer(StaticAdbClient(), lambda serial: InvalidHierarchyDevice(), timeout_seconds=5)
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
output = Path(directory) / "baseline"
|
||||
with self.assertRaises(BaselineCaptureError) as raised:
|
||||
capturer.capture(SERIAL, output)
|
||||
|
||||
self.assertNotIn("not-hierarchy", str(raised.exception))
|
||||
self.assertFalse(output.exists())
|
||||
self.assertEqual(list(Path(directory).iterdir()), [])
|
||||
|
||||
def test_rpc_timeout_is_distinct_redacted_and_does_not_publish_partial_output(self) -> None:
|
||||
class TimeoutRpcDevice(FakeUiDevice):
|
||||
def jsonrpc_call(self, method: str, params: object = None, timeout: float = 10) -> str:
|
||||
raise HTTPTimeoutError(f"raw serial={SERIAL} xml=<hierarchy/>")
|
||||
|
||||
capturer = DeviceBaselineCapturer(StaticAdbClient(), lambda serial: TimeoutRpcDevice(), timeout_seconds=5)
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
output = Path(directory) / "baseline"
|
||||
with self.assertRaises(BaselineCaptureTimeoutError) as raised:
|
||||
capturer.capture(SERIAL, output)
|
||||
|
||||
self.assertIn("超时", str(raised.exception))
|
||||
self.assertNotIn(SERIAL, str(raised.exception))
|
||||
self.assertNotIn("hierarchy", str(raised.exception))
|
||||
self.assertFalse(output.exists())
|
||||
self.assertEqual(list(Path(directory).iterdir()), [])
|
||||
|
||||
def test_cli_validation_rejects_empty_serial_and_nonpositive_timeout(self) -> None:
|
||||
empty_serial = parse_arguments(["--serial", "", "--output-dir", "baseline"])
|
||||
with self.assertRaisesRegex(ValueError, "非空 --serial"):
|
||||
validate_arguments(empty_serial)
|
||||
|
||||
nonpositive_timeout = parse_arguments(["--serial", SERIAL, "--output-dir", "baseline", "--timeout", "0"])
|
||||
with self.assertRaisesRegex(ValueError, "必须大于 0"):
|
||||
validate_arguments(nonpositive_timeout)
|
||||
@@ -0,0 +1,735 @@
|
||||
"""T-103 脱敏器测试:全部证据为合成数据,绝不读取真实 raw 目录。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from hashlib import sha256
|
||||
import json
|
||||
from pathlib import Path
|
||||
import sys
|
||||
from tempfile import TemporaryDirectory
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
from xml.etree import ElementTree
|
||||
|
||||
from PIL import Image
|
||||
|
||||
|
||||
CLIENT_ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(CLIENT_ROOT / "src"))
|
||||
|
||||
from cmbuyer_client.device.sku_evidence_sanitizer import (
|
||||
EXPECTED_GOODS_ID,
|
||||
EXPECTED_SCREENSHOT_HEIGHT,
|
||||
EXPECTED_SCREENSHOT_WIDTH,
|
||||
EXPECTED_XML_HEIGHT,
|
||||
EXPECTED_XML_WIDTH,
|
||||
HUMAN_DECLARED_STATES,
|
||||
SkuEvidenceSanitizationError,
|
||||
sanitize_sku_panel_evidence,
|
||||
)
|
||||
|
||||
|
||||
TEST_SERIAL = "synthetic-serial-never-publish"
|
||||
TEST_ADDRESS = "SYNTHETIC_ADDRESS_NEVER_PUBLISH"
|
||||
FULL_PHONE = "13800138000"
|
||||
MASKED_PHONE = "138****0000"
|
||||
SAFE_TEXT = "synthetic-safe-lower-content"
|
||||
CURRENT_PRICE = "快卖完 ¥12.88"
|
||||
ORIGINAL_PRICE = "¥29.00"
|
||||
PRICE_CURRENT_BOUNDS = "[396,503][712,570]"
|
||||
PRICE_ORIGINAL_BOUNDS = "[730,503][895,570]"
|
||||
|
||||
|
||||
def _hash(path: Path) -> str:
|
||||
digest = sha256()
|
||||
with path.open("rb") as source:
|
||||
for chunk in iter(lambda: source.read(1024 * 1024), b""):
|
||||
digest.update(chunk)
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def _default_xml() -> str:
|
||||
return (
|
||||
"<hierarchy rotation='0'>"
|
||||
f"<node bounds='[0,0][1080,540]' text='{TEST_ADDRESS}' content-desc='{MASKED_PHONE} {FULL_PHONE}' />"
|
||||
f"{_price_node(CURRENT_PRICE, PRICE_CURRENT_BOUNDS)}"
|
||||
f"{_price_node(ORIGINAL_PRICE, PRICE_ORIGINAL_BOUNDS)}"
|
||||
f"<node bounds='[0,540][1080,2376]' text='{SAFE_TEXT}' />"
|
||||
"</hierarchy>"
|
||||
)
|
||||
|
||||
|
||||
def _price_node(
|
||||
text: str,
|
||||
bounds: str,
|
||||
*,
|
||||
package: str = "com.xunmeng.pinduoduo",
|
||||
node_class: str = "android.widget.TextView",
|
||||
clickable: str = "false",
|
||||
enabled: str = "true",
|
||||
visible: str = "true",
|
||||
extra_attributes: str = "",
|
||||
children: str = "",
|
||||
) -> str:
|
||||
attributes = (
|
||||
f"bounds='{bounds}' text='{text}' package='{package}' class='{node_class}' "
|
||||
f"clickable='{clickable}' enabled='{enabled}' visible-to-user='{visible}'{extra_attributes}"
|
||||
)
|
||||
return f"<node {attributes}>{children}</node>"
|
||||
|
||||
|
||||
def _xml_with_prices(
|
||||
current: str = CURRENT_PRICE,
|
||||
original: str = ORIGINAL_PRICE,
|
||||
*,
|
||||
current_node: str | None = None,
|
||||
original_node: str | None = None,
|
||||
include_original: bool = True,
|
||||
extra_nodes: str = "",
|
||||
) -> str:
|
||||
current_markup = current_node if current_node is not None else _price_node(current, PRICE_CURRENT_BOUNDS)
|
||||
original_markup = (
|
||||
original_node if original_node is not None else _price_node(original, PRICE_ORIGINAL_BOUNDS)
|
||||
) if include_original else ""
|
||||
return (
|
||||
"<hierarchy>"
|
||||
f"<node bounds='[0,0][1080,540]' text='{TEST_ADDRESS}' />"
|
||||
f"{current_markup}"
|
||||
f"{original_markup}"
|
||||
f"{extra_nodes}"
|
||||
f"<node bounds='[0,570][1080,2376]' text='{SAFE_TEXT}' />"
|
||||
"</hierarchy>"
|
||||
)
|
||||
|
||||
|
||||
def _write_raw(
|
||||
root: Path,
|
||||
*,
|
||||
state: str = "panel-opened-target-preselected",
|
||||
goods_id: str = EXPECTED_GOODS_ID,
|
||||
model: str = "PKG110",
|
||||
android_version: str = "16",
|
||||
pdd_version: str = "8.17.0",
|
||||
size: tuple[int, int] = (EXPECTED_SCREENSHOT_WIDTH, EXPECTED_SCREENSHOT_HEIGHT),
|
||||
xml: str | None = None,
|
||||
) -> Path:
|
||||
raw = root / "raw"
|
||||
raw.mkdir(parents=True)
|
||||
screenshot = raw / "screenshot.png"
|
||||
image = Image.new("RGB", size, color=(0, 180, 0))
|
||||
if size == (EXPECTED_SCREENSHOT_WIDTH, EXPECTED_SCREENSHOT_HEIGHT):
|
||||
for y in range(540):
|
||||
for x in range(8):
|
||||
image.putpixel((x, y), (255, 0, 0))
|
||||
image.save(screenshot, format="PNG")
|
||||
hierarchy = raw / "hierarchy.xml"
|
||||
hierarchy.write_text(_default_xml() if xml is None else xml, encoding="utf-8")
|
||||
manifest = {
|
||||
"schema_version": 1,
|
||||
"product": {
|
||||
"goods_id": goods_id,
|
||||
"canonical_url": f"https://mobile.yangkeduo.com/goods.html?goods_id={goods_id}",
|
||||
},
|
||||
"human_declared_state": state,
|
||||
"serial": TEST_SERIAL,
|
||||
"channel": "wifi",
|
||||
"device": {
|
||||
"model": model,
|
||||
"android_version": android_version,
|
||||
"pdd_package": "com.xunmeng.pinduoduo",
|
||||
"pdd_version": pdd_version,
|
||||
},
|
||||
"artifacts": [
|
||||
{"path": "screenshot.png", "sha256": _hash(screenshot)},
|
||||
{"path": "hierarchy.xml", "sha256": _hash(hierarchy)},
|
||||
],
|
||||
}
|
||||
(raw / "manifest.json").write_text(json.dumps(manifest, sort_keys=True), encoding="utf-8")
|
||||
return raw
|
||||
|
||||
|
||||
class SkuEvidenceSanitizerTests(unittest.TestCase):
|
||||
def test_all_declared_states_mask_screenshot_and_xml_without_raw_metadata(self) -> None:
|
||||
for state in sorted(HUMAN_DECLARED_STATES):
|
||||
with self.subTest(state=state), TemporaryDirectory() as temporary:
|
||||
raw = _write_raw(Path(temporary), state=state)
|
||||
result = sanitize_sku_panel_evidence(raw, raw.parent / "derived")
|
||||
with Image.open(result.screenshot_path) as image:
|
||||
self.assertEqual(image.getpixel((0, 0)), (0, 0, 0, 255))
|
||||
self.assertEqual(image.getpixel((100, 600)), (0, 180, 0, 255))
|
||||
derived_xml = result.hierarchy_path.read_text(encoding="utf-8")
|
||||
manifest = result.manifest_path.read_text(encoding="utf-8")
|
||||
|
||||
self.assertIn(SAFE_TEXT, derived_xml)
|
||||
self.assertNotIn(TEST_ADDRESS, derived_xml)
|
||||
self.assertNotIn(FULL_PHONE, derived_xml)
|
||||
self.assertNotIn(MASKED_PHONE, derived_xml)
|
||||
self.assertIn(f'"human_declared_state": "{state}"', manifest)
|
||||
self.assertIn('"privacy_tier": "SANITIZED"', manifest)
|
||||
self.assertIn('"sanitizer_version": "t103-privacy-v5"', manifest)
|
||||
self.assertIn('"screenshot_space": {', manifest)
|
||||
self.assertIn('"xml_coordinate_space": {', manifest)
|
||||
self.assertIn('"height": 2376', manifest)
|
||||
self.assertIn('"height": 2376', manifest)
|
||||
self.assertIn('"privacy_mask_rectangle": [', manifest)
|
||||
self.assertIn('"removed_nodes": 1', manifest)
|
||||
self.assertIn('"cleared_crossing_nodes": 0', manifest)
|
||||
self.assertIn('"preserved_crossing_price_nodes": 2', manifest)
|
||||
self.assertIn('"retained_below_nodes": 1', manifest)
|
||||
self.assertIn('"max_right": 1080', manifest)
|
||||
self.assertIn('"max_bottom": 2376', manifest)
|
||||
self.assertNotIn("canonical_url", manifest)
|
||||
self.assertNotIn(TEST_SERIAL, manifest)
|
||||
self.assertNotIn("serial", manifest)
|
||||
self.assertNotIn("channel", manifest)
|
||||
self.assertNotIn(TEST_ADDRESS, manifest)
|
||||
self.assertNotIn(FULL_PHONE, manifest)
|
||||
|
||||
def test_crossing_container_keeps_lower_children_but_clears_its_text(self) -> None:
|
||||
xml = (
|
||||
"<hierarchy>"
|
||||
f"<node bounds='[0,0][1080,2376]' text='{TEST_ADDRESS}' content-desc='{MASKED_PHONE}'>"
|
||||
f"<node bounds='[0,0][1080,540]' text='{FULL_PHONE}' />"
|
||||
f"{_price_node(CURRENT_PRICE, PRICE_CURRENT_BOUNDS)}"
|
||||
f"{_price_node(ORIGINAL_PRICE, PRICE_ORIGINAL_BOUNDS)}"
|
||||
f"<node bounds='[0,540][1080,2376]' text='{SAFE_TEXT}' />"
|
||||
"</node></hierarchy>"
|
||||
)
|
||||
with TemporaryDirectory() as temporary:
|
||||
raw = _write_raw(Path(temporary), xml=xml)
|
||||
result = sanitize_sku_panel_evidence(raw, raw.parent / "derived")
|
||||
root = ElementTree.parse(result.hierarchy_path).getroot()
|
||||
crossing = root.find("node")
|
||||
|
||||
self.assertIsNotNone(crossing)
|
||||
assert crossing is not None
|
||||
self.assertEqual(crossing.attrib, {"bounds": "[0,0][1080,2376]"})
|
||||
self.assertEqual(len(list(crossing)), 3)
|
||||
self.assertEqual(list(crossing)[-1].get("text"), SAFE_TEXT)
|
||||
manifest = json.loads(result.manifest_path.read_text(encoding="utf-8"))
|
||||
self.assertEqual(
|
||||
manifest["privacy_cleanup"],
|
||||
{
|
||||
"removed_nodes": 1,
|
||||
"cleared_crossing_nodes": 1,
|
||||
"preserved_crossing_price_nodes": 2,
|
||||
"retained_below_nodes": 1,
|
||||
"max_right": 1080,
|
||||
"max_bottom": 2376,
|
||||
},
|
||||
)
|
||||
|
||||
def test_only_strict_crossing_price_leaves_are_projected_with_whitelisted_attributes(self) -> None:
|
||||
xml = _xml_with_prices(
|
||||
current_node=_price_node(
|
||||
CURRENT_PRICE,
|
||||
PRICE_CURRENT_BOUNDS,
|
||||
extra_attributes=" content-desc='discard' resource-id='discard' focused='true'",
|
||||
),
|
||||
original_node=_price_node(
|
||||
ORIGINAL_PRICE,
|
||||
PRICE_ORIGINAL_BOUNDS,
|
||||
extra_attributes=" content-desc='discard-too' resource-id='discard-too'",
|
||||
),
|
||||
)
|
||||
with TemporaryDirectory() as temporary:
|
||||
raw = _write_raw(Path(temporary), xml=xml)
|
||||
result = sanitize_sku_panel_evidence(raw, raw.parent / "derived")
|
||||
root = ElementTree.parse(result.hierarchy_path).getroot()
|
||||
prices = [node for node in root.findall("node") if node.get("text") in {CURRENT_PRICE, ORIGINAL_PRICE}]
|
||||
|
||||
self.assertEqual(len(prices), 2)
|
||||
for node in prices:
|
||||
self.assertEqual(
|
||||
set(node.attrib),
|
||||
{"bounds", "text", "package", "class", "clickable", "enabled", "visible-to-user"},
|
||||
)
|
||||
self.assertEqual(node.get("package"), "com.xunmeng.pinduoduo")
|
||||
self.assertEqual(node.get("class"), "android.widget.TextView")
|
||||
self.assertEqual(node.get("clickable"), "false")
|
||||
self.assertEqual(node.get("enabled"), "true")
|
||||
self.assertEqual(node.get("visible-to-user"), "true")
|
||||
|
||||
manifest = json.loads(result.manifest_path.read_text(encoding="utf-8"))
|
||||
self.assertEqual(manifest["privacy_cleanup"]["preserved_crossing_price_nodes"], 2)
|
||||
self.assertNotIn("discard", result.hierarchy_path.read_text(encoding="utf-8"))
|
||||
|
||||
def test_crossing_price_window_rejects_text_and_structure_drift(self) -> None:
|
||||
bad_texts = (
|
||||
f"快卖完 ¥12.88 {TEST_ADDRESS}",
|
||||
f"快卖完 ¥12.88 {FULL_PHONE}",
|
||||
"快卖完 ¥12.88 使用微信支付",
|
||||
"快卖完 ¥12.88 提交订单",
|
||||
"快卖完 ¥12.88 优惠-11元",
|
||||
"快要抢光 ¥12.88",
|
||||
"快卖完 ¥0.00",
|
||||
"快卖完 ¥12.8",
|
||||
"快卖完 ¥12.880",
|
||||
"快卖完 ¥12.88",
|
||||
)
|
||||
for text in bad_texts:
|
||||
with self.subTest(text=text), TemporaryDirectory() as temporary:
|
||||
raw = _write_raw(Path(temporary), xml=_xml_with_prices(current=text))
|
||||
with self.assertRaises(SkuEvidenceSanitizationError):
|
||||
sanitize_sku_panel_evidence(raw, raw.parent / "derived")
|
||||
self.assertFalse((raw.parent / "derived").exists())
|
||||
|
||||
def test_crossing_price_text_mismatch_reports_only_fixed_slot_and_reason(self) -> None:
|
||||
newline_node = _price_node(CURRENT_PRICE, PRICE_CURRENT_BOUNDS).replace(
|
||||
"快卖完 ¥12.88", "快卖完 ¥12.88"
|
||||
)
|
||||
cases = (
|
||||
("newline", _xml_with_prices(current_node=newline_node), "newline", PRICE_CURRENT_BOUNDS),
|
||||
(
|
||||
"non-ascii-whitespace",
|
||||
_xml_with_prices(current="快卖完 ¥12.88"),
|
||||
"non_ascii_whitespace",
|
||||
PRICE_CURRENT_BOUNDS,
|
||||
),
|
||||
(
|
||||
"known-prefix-missing",
|
||||
_xml_with_prices(current="快要抢光 ¥12.88"),
|
||||
"observed_prefix_kuaiyaoqiangguang",
|
||||
PRICE_CURRENT_BOUNDS,
|
||||
),
|
||||
(
|
||||
"other-kuai-prefix-remains-generic",
|
||||
_xml_with_prices(current="快递地址 ¥12.88"),
|
||||
"known_prefix_missing",
|
||||
PRICE_CURRENT_BOUNDS,
|
||||
),
|
||||
(
|
||||
"currency-missing",
|
||||
_xml_with_prices(current="快卖完 12.88"),
|
||||
"currency_missing",
|
||||
PRICE_CURRENT_BOUNDS,
|
||||
),
|
||||
(
|
||||
"amount-shape",
|
||||
_xml_with_prices(current="快卖完 ¥12.8"),
|
||||
"amount_shape",
|
||||
PRICE_CURRENT_BOUNDS,
|
||||
),
|
||||
(
|
||||
"extra-or-order",
|
||||
_xml_with_prices(current="提交订单 ¥12.88"),
|
||||
"extra_or_order",
|
||||
PRICE_CURRENT_BOUNDS,
|
||||
),
|
||||
(
|
||||
"forbidden-characters",
|
||||
_xml_with_prices(current="商品 ¥12.88"),
|
||||
"forbidden_characters",
|
||||
PRICE_CURRENT_BOUNDS,
|
||||
),
|
||||
(
|
||||
"right-slot-amount-shape",
|
||||
_xml_with_prices(original="¥29.0"),
|
||||
"amount_shape",
|
||||
PRICE_ORIGINAL_BOUNDS,
|
||||
),
|
||||
)
|
||||
for name, xml, reason, bounds in cases:
|
||||
with self.subTest(name=name), TemporaryDirectory() as temporary:
|
||||
raw = _write_raw(Path(temporary), xml=xml)
|
||||
with self.assertRaises(SkuEvidenceSanitizationError) as raised:
|
||||
sanitize_sku_panel_evidence(raw, raw.parent / "derived")
|
||||
|
||||
self.assertEqual(
|
||||
str(raised.exception),
|
||||
f"跨界价格节点文本不匹配:slot={bounds};reason={reason}。",
|
||||
)
|
||||
self.assertFalse((raw.parent / "derived").exists())
|
||||
|
||||
def test_crossing_price_text_mismatch_never_echoes_sensitive_or_order_text(self) -> None:
|
||||
cases = (
|
||||
f"快卖完 ¥12.88 {TEST_ADDRESS}",
|
||||
f"快卖完 ¥12.88 {FULL_PHONE}",
|
||||
"快卖完 ¥12.88 使用微信支付",
|
||||
"快卖完 ¥12.88 提交订单",
|
||||
)
|
||||
for text in cases:
|
||||
with self.subTest(text=text), TemporaryDirectory() as temporary:
|
||||
raw = _write_raw(Path(temporary), xml=_xml_with_prices(current=text))
|
||||
with self.assertRaises(SkuEvidenceSanitizationError) as raised:
|
||||
sanitize_sku_panel_evidence(raw, raw.parent / "derived")
|
||||
|
||||
message = str(raised.exception)
|
||||
self.assertIn("slot=[396,503][712,570]", message)
|
||||
self.assertIn("reason=extra_or_order", message)
|
||||
for raw_fragment in (TEST_ADDRESS, FULL_PHONE, "使用微信支付", "提交订单", "¥12.88"):
|
||||
self.assertNotIn(raw_fragment, message)
|
||||
|
||||
def test_observed_prefix_diagnostic_does_not_echo_its_suffix(self) -> None:
|
||||
suffix = "地址和金额都不得回显"
|
||||
text = f"快要抢光 ¥12.88 {suffix}"
|
||||
with TemporaryDirectory() as temporary:
|
||||
raw = _write_raw(Path(temporary), xml=_xml_with_prices(current=text))
|
||||
with self.assertRaises(SkuEvidenceSanitizationError) as raised:
|
||||
sanitize_sku_panel_evidence(raw, raw.parent / "derived")
|
||||
|
||||
message = str(raised.exception)
|
||||
self.assertEqual(
|
||||
message,
|
||||
"跨界价格节点文本不匹配:slot=[396,503][712,570];"
|
||||
"reason=observed_prefix_kuaiyaoqiangguang。",
|
||||
)
|
||||
self.assertNotIn(suffix, message)
|
||||
self.assertNotIn("¥12.88", message)
|
||||
|
||||
def test_crossing_price_projection_allows_only_limited_ascii_spaces_and_yen_variants(self) -> None:
|
||||
for current in ("快卖完 ¥12.88", " 快卖完 ¥ 12.88 ", "快卖完 ¥12.88"):
|
||||
with self.subTest(current=current), TemporaryDirectory() as temporary:
|
||||
raw = _write_raw(Path(temporary), xml=_xml_with_prices(current=current))
|
||||
result = sanitize_sku_panel_evidence(raw, raw.parent / "derived")
|
||||
hierarchy = result.hierarchy_path.read_text(encoding="utf-8")
|
||||
self.assertIn(current, hierarchy)
|
||||
|
||||
def test_unverified_or_missing_current_price_prefixes_fail_closed(self) -> None:
|
||||
cases = (
|
||||
("old-prefix", "快卖光 ¥12.88"),
|
||||
("bottom-button-prefix", "快要抢光 ¥12.88"),
|
||||
("missing-prefix", "¥12.88"),
|
||||
)
|
||||
for name, current in cases:
|
||||
with self.subTest(name=name), TemporaryDirectory() as temporary:
|
||||
raw = _write_raw(Path(temporary), xml=_xml_with_prices(current=current))
|
||||
with self.assertRaises(SkuEvidenceSanitizationError):
|
||||
sanitize_sku_panel_evidence(raw, raw.parent / "derived")
|
||||
self.assertFalse((raw.parent / "derived").exists())
|
||||
|
||||
def test_unique_current_price_without_original_price_is_published(self) -> None:
|
||||
with TemporaryDirectory() as temporary:
|
||||
raw = _write_raw(Path(temporary), xml=_xml_with_prices(include_original=False))
|
||||
result = sanitize_sku_panel_evidence(raw, raw.parent / "derived")
|
||||
hierarchy = result.hierarchy_path.read_text(encoding="utf-8")
|
||||
manifest = json.loads(result.manifest_path.read_text(encoding="utf-8"))
|
||||
|
||||
self.assertIn(CURRENT_PRICE, hierarchy)
|
||||
self.assertNotIn(ORIGINAL_PRICE, hierarchy)
|
||||
self.assertEqual(manifest["privacy_cleanup"]["preserved_crossing_price_nodes"], 1)
|
||||
|
||||
def test_crossing_price_projection_rejects_newline_and_structure_drift(self) -> None:
|
||||
encoded_newline = _price_node(CURRENT_PRICE, PRICE_CURRENT_BOUNDS).replace(
|
||||
"快卖完 ¥12.88", "快卖完 ¥12.88"
|
||||
)
|
||||
with TemporaryDirectory() as temporary:
|
||||
raw = _write_raw(Path(temporary), xml=_xml_with_prices(current_node=encoded_newline))
|
||||
with self.assertRaises(SkuEvidenceSanitizationError):
|
||||
sanitize_sku_panel_evidence(raw, raw.parent / "derived")
|
||||
self.assertFalse((raw.parent / "derived").exists())
|
||||
|
||||
bad_structure = (
|
||||
("package", {"package": "com.android.systemui"}),
|
||||
("class", {"node_class": "android.view.View"}),
|
||||
("clickable", {"clickable": "true"}),
|
||||
("disabled", {"enabled": "false"}),
|
||||
("hidden", {"visible": "false"}),
|
||||
("children", {"children": "<node bounds='[400,510][500,520]' />"}),
|
||||
)
|
||||
for name, kwargs in bad_structure:
|
||||
with self.subTest(structure=name), TemporaryDirectory() as temporary:
|
||||
raw = _write_raw(
|
||||
Path(temporary),
|
||||
xml=_xml_with_prices(current_node=_price_node(CURRENT_PRICE, PRICE_CURRENT_BOUNDS, **kwargs)),
|
||||
)
|
||||
with self.assertRaises(SkuEvidenceSanitizationError):
|
||||
sanitize_sku_panel_evidence(raw, raw.parent / "derived")
|
||||
self.assertFalse((raw.parent / "derived").exists())
|
||||
|
||||
def test_crossing_price_candidates_require_unique_current_and_at_most_one_original(self) -> None:
|
||||
scenarios = (
|
||||
(
|
||||
"missing-current",
|
||||
_xml_with_prices(current="¥12.88", original=ORIGINAL_PRICE),
|
||||
),
|
||||
(
|
||||
"duplicate-current",
|
||||
_xml_with_prices(current=CURRENT_PRICE, original=f"快卖完 {ORIGINAL_PRICE}"),
|
||||
),
|
||||
(
|
||||
"multiple-original",
|
||||
_xml_with_prices(
|
||||
extra_nodes=_price_node("¥39.88", PRICE_ORIGINAL_BOUNDS),
|
||||
),
|
||||
),
|
||||
)
|
||||
for name, xml in scenarios:
|
||||
with self.subTest(name=name), TemporaryDirectory() as temporary:
|
||||
raw = _write_raw(Path(temporary), xml=xml)
|
||||
with self.assertRaises(SkuEvidenceSanitizationError):
|
||||
sanitize_sku_panel_evidence(raw, raw.parent / "derived")
|
||||
self.assertFalse((raw.parent / "derived").exists())
|
||||
|
||||
def test_crossing_price_outside_fixed_windows_is_cleared_and_submit_price_is_not_candidate(self) -> None:
|
||||
outside_crossing = _price_node("快卖光 ¥99.99", "[396,498][712,570]")
|
||||
submit = (
|
||||
"<node bounds='[369,2225][710,2284]' text='提交订单 ¥12.88' "
|
||||
"package='com.xunmeng.pinduoduo' class='android.widget.TextView' clickable='false' "
|
||||
"enabled='true' visible-to-user='true' resource-id='submit-button' />"
|
||||
)
|
||||
with TemporaryDirectory() as temporary:
|
||||
raw = _write_raw(Path(temporary), xml=_xml_with_prices(extra_nodes=outside_crossing + submit))
|
||||
result = sanitize_sku_panel_evidence(raw, raw.parent / "derived")
|
||||
hierarchy = result.hierarchy_path.read_text(encoding="utf-8")
|
||||
manifest = json.loads(result.manifest_path.read_text(encoding="utf-8"))
|
||||
|
||||
self.assertNotIn("快卖光 ¥99.99", hierarchy)
|
||||
self.assertIn("提交订单 ¥12.88", hierarchy)
|
||||
self.assertIn("submit-button", hierarchy)
|
||||
self.assertEqual(manifest["privacy_cleanup"]["preserved_crossing_price_nodes"], 2)
|
||||
|
||||
def test_same_raw_and_config_produce_identical_derived_files(self) -> None:
|
||||
with TemporaryDirectory() as temporary:
|
||||
root = Path(temporary)
|
||||
left_raw = _write_raw(root / "left")
|
||||
right_raw = _write_raw(root / "right")
|
||||
left = sanitize_sku_panel_evidence(left_raw, left_raw.parent / "derived")
|
||||
right = sanitize_sku_panel_evidence(right_raw, right_raw.parent / "derived")
|
||||
|
||||
for left_path, right_path in (
|
||||
(left.screenshot_path, right.screenshot_path),
|
||||
(left.hierarchy_path, right.hierarchy_path),
|
||||
(left.manifest_path, right.manifest_path),
|
||||
):
|
||||
self.assertEqual(left_path.read_bytes(), right_path.read_bytes())
|
||||
|
||||
def test_manifest_records_distinct_screenshot_and_xml_coordinate_spaces(self) -> None:
|
||||
with TemporaryDirectory() as temporary:
|
||||
raw = _write_raw(Path(temporary))
|
||||
result = sanitize_sku_panel_evidence(raw, raw.parent / "derived")
|
||||
manifest = json.loads(result.manifest_path.read_text(encoding="utf-8"))
|
||||
|
||||
self.assertEqual(
|
||||
manifest["screenshot_space"],
|
||||
{"width": 1080, "height": 2376, "privacy_mask_rectangle": [0, 0, 1080, 540]},
|
||||
)
|
||||
self.assertEqual(
|
||||
manifest["xml_coordinate_space"],
|
||||
{
|
||||
"width": 1080,
|
||||
"height": 2376,
|
||||
"privacy_mask_rectangle": [0, 0, 1080, 540],
|
||||
"observed_max": {"right": 1080, "bottom": 2376},
|
||||
},
|
||||
)
|
||||
|
||||
def test_hash_and_metadata_mismatch_fail_closed_without_leak(self) -> None:
|
||||
scenarios = (
|
||||
("hash", {}, "screenshot"),
|
||||
("model", {"model": "other"}, None),
|
||||
("android", {"android_version": "15"}, None),
|
||||
("version", {"pdd_version": "8.17.1"}, None),
|
||||
("goods", {"goods_id": "123"}, None),
|
||||
("state", {"state": "guessed"}, None),
|
||||
("old-screenshot-space", {"size": (1080, 2400)}, None),
|
||||
("other-screenshot-space", {"size": (100, 100)}, None),
|
||||
)
|
||||
for name, kwargs, corrupt_file in scenarios:
|
||||
with self.subTest(name=name), TemporaryDirectory() as temporary:
|
||||
raw = _write_raw(Path(temporary), **kwargs)
|
||||
if corrupt_file is not None:
|
||||
(raw / f"{corrupt_file}.png").write_bytes(b"changed")
|
||||
with self.assertRaises(SkuEvidenceSanitizationError) as raised:
|
||||
sanitize_sku_panel_evidence(raw, raw.parent / "derived")
|
||||
|
||||
message = str(raised.exception)
|
||||
self.assertNotIn(TEST_SERIAL, message)
|
||||
self.assertNotIn(TEST_ADDRESS, message)
|
||||
self.assertNotIn(FULL_PHONE, message)
|
||||
self.assertFalse((raw.parent / "derived").exists())
|
||||
self.assertEqual(list(raw.parent.glob(".derived.staging-*")), [])
|
||||
|
||||
def test_old_and_unknown_human_states_are_rejected(self) -> None:
|
||||
for state in ("initial", "one-dimension-selected", "all-dimensions-selected", "guessed"):
|
||||
with self.subTest(state=state), TemporaryDirectory() as temporary:
|
||||
raw = _write_raw(Path(temporary), state=state)
|
||||
with self.assertRaises(SkuEvidenceSanitizationError):
|
||||
sanitize_sku_panel_evidence(raw, raw.parent / "derived")
|
||||
self.assertFalse((raw.parent / "derived").exists())
|
||||
|
||||
def test_malformed_inputs_and_bounds_or_phone_residue_fail_closed(self) -> None:
|
||||
malformed = (
|
||||
("manifest", None),
|
||||
("png", None),
|
||||
("xml", None),
|
||||
("bounds", "<hierarchy><node text='missing bounds' /></hierarchy>"),
|
||||
(
|
||||
"private-parent-with-lower-child",
|
||||
"<hierarchy><node bounds='[0,0][1080,540]'><node bounds='[0,540][1080,2376]' text='x' /></node></hierarchy>",
|
||||
),
|
||||
("full-phone-below", f"<hierarchy><node bounds='[0,540][1080,2376]' text='{FULL_PHONE}' /></hierarchy>"),
|
||||
("masked-phone-below", f"<hierarchy><node bounds='[0,540][1080,2376]' text='{MASKED_PHONE}' /></hierarchy>"),
|
||||
)
|
||||
for kind, xml in malformed:
|
||||
with self.subTest(kind=kind), TemporaryDirectory() as temporary:
|
||||
raw = _write_raw(Path(temporary), xml=xml)
|
||||
if kind == "manifest":
|
||||
(raw / "manifest.json").write_text("{invalid", encoding="utf-8")
|
||||
elif kind == "png":
|
||||
(raw / "screenshot.png").write_bytes(b"not a png")
|
||||
manifest = json.loads((raw / "manifest.json").read_text(encoding="utf-8"))
|
||||
manifest["artifacts"][0]["sha256"] = _hash(raw / "screenshot.png")
|
||||
(raw / "manifest.json").write_text(json.dumps(manifest), encoding="utf-8")
|
||||
elif kind == "xml":
|
||||
(raw / "hierarchy.xml").write_text("<hierarchy>", encoding="utf-8")
|
||||
manifest = json.loads((raw / "manifest.json").read_text(encoding="utf-8"))
|
||||
manifest["artifacts"][1]["sha256"] = _hash(raw / "hierarchy.xml")
|
||||
(raw / "manifest.json").write_text(json.dumps(manifest), encoding="utf-8")
|
||||
with self.assertRaises(SkuEvidenceSanitizationError):
|
||||
sanitize_sku_panel_evidence(raw, raw.parent / "derived")
|
||||
|
||||
self.assertFalse((raw.parent / "derived").exists())
|
||||
self.assertEqual(list(raw.parent.glob(".derived.staging-*")), [])
|
||||
|
||||
def test_phone_recheck_rejects_separator_mask_and_cross_node_bypasses(self) -> None:
|
||||
variants = (
|
||||
"138 0013-8000",
|
||||
"138****0000",
|
||||
"138••••0000",
|
||||
"138xxxx0000",
|
||||
"138XXXX0000",
|
||||
)
|
||||
for value in variants:
|
||||
xml = (
|
||||
"<hierarchy>"
|
||||
"<node bounds='[0,0][1080,540]' text='private' />"
|
||||
f"<node bounds='[0,540][1080,2376]' text='{value}' />"
|
||||
"</hierarchy>"
|
||||
)
|
||||
with self.subTest(value=value), TemporaryDirectory() as temporary:
|
||||
raw = _write_raw(Path(temporary), xml=xml)
|
||||
with self.assertRaises(SkuEvidenceSanitizationError):
|
||||
sanitize_sku_panel_evidence(raw, raw.parent / "derived")
|
||||
self.assertFalse((raw.parent / "derived").exists())
|
||||
|
||||
split_xml = (
|
||||
"<hierarchy>"
|
||||
"<node bounds='[0,0][1080,540]' text='private' />"
|
||||
"<node bounds='[0,540][1080,1000]' text='138' content-desc='0013' />"
|
||||
"<node bounds='[0,1000][1080,2376]' text='8000' />"
|
||||
"</hierarchy>"
|
||||
)
|
||||
with TemporaryDirectory() as temporary:
|
||||
raw = _write_raw(Path(temporary), xml=split_xml)
|
||||
with self.assertRaises(SkuEvidenceSanitizationError):
|
||||
sanitize_sku_panel_evidence(raw, raw.parent / "derived")
|
||||
self.assertFalse((raw.parent / "derived").exists())
|
||||
|
||||
def test_privacy_geometry_requires_removed_and_retained_nodes(self) -> None:
|
||||
scenarios = (
|
||||
("no-private", f"<hierarchy><node bounds='[0,540][1080,2376]' text='{SAFE_TEXT}' /></hierarchy>"),
|
||||
("no-below", "<hierarchy><node bounds='[0,0][1080,540]' text='private' /></hierarchy>"),
|
||||
)
|
||||
for name, xml in scenarios:
|
||||
with self.subTest(name=name), TemporaryDirectory() as temporary:
|
||||
raw = _write_raw(Path(temporary), xml=xml)
|
||||
with self.assertRaises(SkuEvidenceSanitizationError):
|
||||
sanitize_sku_panel_evidence(raw, raw.parent / "derived")
|
||||
self.assertFalse((raw.parent / "derived").exists())
|
||||
self.assertEqual(list(raw.parent.glob(".derived.staging-*")), [])
|
||||
|
||||
def test_xml_coordinate_space_must_have_exact_configured_maximums(self) -> None:
|
||||
scenarios = (
|
||||
(
|
||||
"short-width",
|
||||
"<hierarchy><node bounds='[0,0][1079,540]' text='private' />"
|
||||
"<node bounds='[0,540][1079,2376]' text='safe' /></hierarchy>",
|
||||
"1079x2376",
|
||||
),
|
||||
(
|
||||
"short-height",
|
||||
"<hierarchy><node bounds='[0,0][1080,540]' text='private' />"
|
||||
"<node bounds='[0,540][1080,2375]' text='safe' /></hierarchy>",
|
||||
"1080x2375",
|
||||
),
|
||||
(
|
||||
"wide-width",
|
||||
"<hierarchy><node bounds='[0,0][1081,540]' text='private' />"
|
||||
"<node bounds='[0,540][1081,2376]' text='safe' /></hierarchy>",
|
||||
"1081x2376",
|
||||
),
|
||||
(
|
||||
"old-v2-xml-height",
|
||||
"<hierarchy><node bounds='[0,0][1080,540]' text='private' />"
|
||||
"<node bounds='[0,540][1080,2400]' text='safe' /></hierarchy>",
|
||||
"1080x2400",
|
||||
),
|
||||
)
|
||||
for name, xml, observed in scenarios:
|
||||
with self.subTest(name=name), TemporaryDirectory() as temporary:
|
||||
raw = _write_raw(Path(temporary), xml=xml)
|
||||
with self.assertRaises(SkuEvidenceSanitizationError) as raised:
|
||||
sanitize_sku_panel_evidence(raw, raw.parent / "derived")
|
||||
|
||||
self.assertIn(observed, str(raised.exception))
|
||||
self.assertNotIn(TEST_SERIAL, str(raised.exception))
|
||||
self.assertFalse((raw.parent / "derived").exists())
|
||||
self.assertEqual(list(raw.parent.glob(".derived.staging-*")), [])
|
||||
|
||||
def test_manifest_schema_package_and_artifact_structure_are_required(self) -> None:
|
||||
def mutate(manifest: dict[str, object], kind: str) -> None:
|
||||
if kind == "schema":
|
||||
manifest["schema_version"] = 2
|
||||
elif kind == "package":
|
||||
manifest["device"]["pdd_package"] = "com.example.other" # type: ignore[index]
|
||||
elif kind == "missing":
|
||||
manifest.pop("artifacts")
|
||||
elif kind == "duplicate":
|
||||
manifest["artifacts"].append(manifest["artifacts"][0]) # type: ignore[index]
|
||||
elif kind == "bad-hash":
|
||||
manifest["artifacts"][0]["sha256"] = "g" * 64 # type: ignore[index]
|
||||
|
||||
for kind in ("schema", "package", "missing", "duplicate", "bad-hash"):
|
||||
with self.subTest(kind=kind), TemporaryDirectory() as temporary:
|
||||
raw = _write_raw(Path(temporary))
|
||||
manifest_path = raw / "manifest.json"
|
||||
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
|
||||
mutate(manifest, kind)
|
||||
manifest_path.write_text(json.dumps(manifest), encoding="utf-8")
|
||||
with self.assertRaises(SkuEvidenceSanitizationError) as raised:
|
||||
sanitize_sku_panel_evidence(raw, raw.parent / "derived")
|
||||
|
||||
self.assertNotIn(TEST_SERIAL, str(raised.exception))
|
||||
self.assertNotIn(TEST_ADDRESS, str(raised.exception))
|
||||
self.assertFalse((raw.parent / "derived").exists())
|
||||
|
||||
def test_target_created_during_publish_is_preserved_and_staging_is_removed(self) -> None:
|
||||
with TemporaryDirectory() as temporary:
|
||||
raw = _write_raw(Path(temporary))
|
||||
target = raw.parent / "derived"
|
||||
|
||||
def race_rename(destination: Path) -> None:
|
||||
destination.mkdir()
|
||||
(destination / "sentinel.txt").write_text("keep", encoding="utf-8")
|
||||
raise FileExistsError("simulated publish race")
|
||||
|
||||
with patch("cmbuyer_client.device.sku_evidence_sanitizer.Path.rename", side_effect=race_rename):
|
||||
with self.assertRaises(SkuEvidenceSanitizationError):
|
||||
sanitize_sku_panel_evidence(raw, target)
|
||||
|
||||
self.assertEqual((target / "sentinel.txt").read_text(encoding="utf-8"), "keep")
|
||||
self.assertEqual(list(raw.parent.glob(".derived.staging-*")), [])
|
||||
|
||||
def test_existing_derived_is_preserved_without_reading_or_writing_raw(self) -> None:
|
||||
with TemporaryDirectory() as temporary:
|
||||
raw = _write_raw(Path(temporary))
|
||||
target = raw.parent / "derived"
|
||||
target.mkdir()
|
||||
sentinel = target / "sentinel.txt"
|
||||
sentinel.write_text("keep", encoding="utf-8")
|
||||
|
||||
with self.assertRaises(SkuEvidenceSanitizationError):
|
||||
sanitize_sku_panel_evidence(raw, target)
|
||||
|
||||
self.assertEqual(sentinel.read_text(encoding="utf-8"), "keep")
|
||||
self.assertEqual(list(raw.parent.glob(".derived.staging-*")), [])
|
||||
|
||||
def test_directory_contract_rejects_non_sibling_paths(self) -> None:
|
||||
with TemporaryDirectory() as temporary:
|
||||
root = Path(temporary)
|
||||
raw = _write_raw(root)
|
||||
with self.assertRaises(SkuEvidenceSanitizationError):
|
||||
sanitize_sku_panel_evidence(raw, root / "not-derived")
|
||||
with self.assertRaises(SkuEvidenceSanitizationError):
|
||||
sanitize_sku_panel_evidence(root / "not-raw", root / "derived")
|
||||
@@ -0,0 +1 @@
|
||||
"""拼多多受限打开模块的离线测试。"""
|
||||
@@ -0,0 +1,14 @@
|
||||
<hierarchy>
|
||||
<node package="com.xunmeng.pinduoduo" class="android.view.ViewGroup" bounds="[0,1256][1080,1355]" clickable="true" enabled="true" visible-to-user="true">
|
||||
<node package="com.xunmeng.pinduoduo" class="android.widget.FrameLayout" bounds="[712,1312][1056,1355]" clickable="false" enabled="true" visible-to-user="true">
|
||||
<node package="com.xunmeng.pinduoduo" class="android.widget.ViewSwitcher" bounds="[712,1312][1056,1355]" clickable="false" enabled="true" visible-to-user="true">
|
||||
<node package="com.xunmeng.pinduoduo" class="android.view.ViewGroup" bounds="[712,1312][1056,1355]" clickable="false" enabled="true" visible-to-user="true">
|
||||
<node text="快要抢光" package="com.xunmeng.pinduoduo" class="android.widget.TextView" bounds="[900,1312][1056,1355]" clickable="false" enabled="true" visible-to-user="true" />
|
||||
</node>
|
||||
</node>
|
||||
</node>
|
||||
</node>
|
||||
<node package="com.xunmeng.pinduoduo" class="android.view.ViewGroup" bounds="[446,2166][1080,2328]" clickable="true" enabled="true" visible-to-user="true">
|
||||
<node text="免拼购买" package="com.xunmeng.pinduoduo" class="android.widget.TextView" bounds="[688,2256][856,2305]" clickable="false" enabled="true" visible-to-user="true" />
|
||||
</node>
|
||||
</hierarchy>
|
||||
@@ -0,0 +1,26 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<hierarchy>
|
||||
<node package="com.xunmeng.pinduoduo" class="android.view.ViewGroup" bounds="[0,474][1080,2328]">
|
||||
<node package="" class="android.view.ViewGroup" bounds="[396,498][895,570]">
|
||||
<node text="快卖完 ¥12.88" package="com.xunmeng.pinduoduo" class="android.widget.TextView" clickable="false" enabled="true" visible-to-user="true" bounds="[396,503][712,570]" />
|
||||
<node text="¥29.88" package="com.xunmeng.pinduoduo" class="android.widget.TextView" clickable="false" enabled="true" visible-to-user="true" bounds="[730,503][895,570]" />
|
||||
</node>
|
||||
<node text="已选: 黑色 CHA (纯棉) M(建议100-115)" package="com.xunmeng.pinduoduo" class="android.widget.TextView" clickable="false" enabled="true" visible-to-user="true" bounds="[396,654][1053,716]" />
|
||||
<node package="com.xunmeng.pinduoduo" class="androidx.recyclerview.widget.RecyclerView" bounds="[36,1000][1080,1631]">
|
||||
<node content-desc="黑色 CHA (纯棉)" package="com.xunmeng.pinduoduo" class="android.view.ViewGroup" selected="true" clickable="true" enabled="true" visible-to-user="true" bounds="[126,1000][438,1172]" />
|
||||
<node content-desc="粉红" package="com.xunmeng.pinduoduo" class="android.view.ViewGroup" selected="false" clickable="true" enabled="true" visible-to-user="true" bounds="[456,1000][690,1172]" />
|
||||
</node>
|
||||
<node package="com.xunmeng.pinduoduo" class="android.view.ViewGroup" bounds="[36,1637][1044,2045]">
|
||||
<node package="com.xunmeng.pinduoduo" class="android.widget.LinearLayout" bounds="[36,1637][1044,1718]">
|
||||
<node text="尺码" package="com.xunmeng.pinduoduo" class="android.widget.TextView" clickable="false" enabled="true" visible-to-user="true" bounds="[36,1654][114,1700]" />
|
||||
</node>
|
||||
<node package="com.xunmeng.pinduoduo" class="android.widget.LinearLayout" bounds="[36,1730][1044,2045]">
|
||||
<node text="M(建议100-115)" package="com.xunmeng.pinduoduo" class="android.view.ViewGroup" selected="true" clickable="true" enabled="true" visible-to-user="true" bounds="[439,1730][831,1815]" />
|
||||
<node text="L(建议115-130)" package="com.xunmeng.pinduoduo" class="android.view.ViewGroup" selected="false" clickable="true" enabled="true" visible-to-user="true" bounds="[840,1730][1044,1815]" />
|
||||
</node>
|
||||
</node>
|
||||
<node package="com.xunmeng.pinduoduo" class="android.widget.LinearLayout" clickable="true" enabled="true" visible-to-user="true" bounds="[357,2181][722,2328]">
|
||||
<node text="提交订单 ¥12.88" package="com.xunmeng.pinduoduo" class="android.widget.TextView" clickable="false" enabled="true" visible-to-user="true" bounds="[369,2225][710,2284]" />
|
||||
</node>
|
||||
</node>
|
||||
</hierarchy>
|
||||
@@ -0,0 +1,266 @@
|
||||
"""商品打开围栏的离线测试;所有设备和命令均为 fake。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
from io import BytesIO
|
||||
from pathlib import Path
|
||||
import sys
|
||||
from tempfile import TemporaryDirectory
|
||||
import unittest
|
||||
|
||||
from PIL import Image
|
||||
|
||||
|
||||
CLIENT_ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(CLIENT_ROOT / "src"))
|
||||
|
||||
from cmbuyer_client.device.adb import AdbDevice, DeviceInspection, IntentLaunchSummary
|
||||
from cmbuyer_client.pdd.product_open import (
|
||||
ProductOpenCapturer,
|
||||
ProductOpenTimeoutError,
|
||||
ProductOpenUiDevice,
|
||||
ProductHierarchyCaptureError,
|
||||
ProductPackageMismatchError,
|
||||
ProductScreenshotCaptureError,
|
||||
ProductVersionMismatchError,
|
||||
)
|
||||
from cmbuyer_client.pdd.product_url import ProductUrl, ProductUrlError
|
||||
|
||||
|
||||
SERIAL = "192.168.0.173:5555"
|
||||
URL = "https://mobile.yangkeduo.com/goods.html?goods_id=123"
|
||||
HIERARCHY = "<?xml version='1.0' encoding='UTF-8'?><hierarchy rotation='0'><node /></hierarchy>"
|
||||
|
||||
|
||||
def _png_base64() -> str:
|
||||
image_data = BytesIO()
|
||||
Image.new("RGB", (1, 1), color="white").save(image_data, format="PNG")
|
||||
return base64.b64encode(image_data.getvalue()).decode("ascii")
|
||||
|
||||
|
||||
class FakeAdbClient:
|
||||
def __init__(self) -> None:
|
||||
self.calls: list[tuple[str, str | None]] = []
|
||||
self.inspection = DeviceInspection(
|
||||
device=AdbDevice(serial=SERIAL, state="device", model="PKG110"),
|
||||
model="PKG110",
|
||||
android_version="16",
|
||||
)
|
||||
|
||||
def inspect(self, serial: str) -> DeviceInspection:
|
||||
self.calls.append(("inspect", serial))
|
||||
return self.inspection
|
||||
|
||||
def start_pdd_view_intent(self, serial: str, goods_id: str) -> IntentLaunchSummary:
|
||||
self.calls.append(("intent", goods_id))
|
||||
return IntentLaunchSummary(status="ok", returncode=0)
|
||||
|
||||
|
||||
class FakeUiDevice:
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
version: str = "8.17.0",
|
||||
current_package: str = "com.xunmeng.pinduoduo",
|
||||
current_packages: list[str] | None = None,
|
||||
hierarchy: str = HIERARCHY,
|
||||
timeout_on_screenshot: bool = False,
|
||||
) -> None:
|
||||
self.version = version
|
||||
self.current_package = current_package
|
||||
self.current_packages = list(current_packages) if current_packages is not None else None
|
||||
self.hierarchy = hierarchy
|
||||
self.timeout_on_screenshot = timeout_on_screenshot
|
||||
self.calls: list[str] = []
|
||||
|
||||
def app_info(self, package_name: str) -> dict[str, str]:
|
||||
self.calls.append("app_info")
|
||||
return {"versionName": self.version}
|
||||
|
||||
def app_current(self) -> dict[str, str]:
|
||||
self.calls.append("app_current")
|
||||
if self.current_packages:
|
||||
package = self.current_packages.pop(0)
|
||||
self.current_package = package
|
||||
return {"package": self.current_package, "activity": "sensitive.activity.name"}
|
||||
|
||||
def jsonrpc_call(self, method: str, params: object = None, timeout: float = 10) -> str:
|
||||
self.calls.append(method)
|
||||
if method == "takeScreenshot":
|
||||
if self.timeout_on_screenshot:
|
||||
raise TimeoutError("raw remote detail")
|
||||
return _png_base64()
|
||||
if method == "dumpWindowHierarchy":
|
||||
return self.hierarchy
|
||||
raise AssertionError(f"unexpected RPC {method}")
|
||||
|
||||
|
||||
class ProductOpenTests(unittest.TestCase):
|
||||
def _capturer(self, adb: FakeAdbClient, device: FakeUiDevice, **kwargs: object) -> ProductOpenCapturer:
|
||||
return ProductOpenCapturer(adb, lambda serial: device, timeout_seconds=2, **kwargs)
|
||||
|
||||
def test_success_uses_canonical_url_and_redacted_atomic_manifest(self) -> None:
|
||||
adb = FakeAdbClient()
|
||||
device = FakeUiDevice()
|
||||
with TemporaryDirectory() as temporary:
|
||||
target = Path(temporary) / "evidence"
|
||||
result = self._capturer(adb, device).open_and_capture(SERIAL, URL, target)
|
||||
manifest = result.manifest_path.read_text(encoding="utf-8")
|
||||
|
||||
self.assertTrue(result.screenshot_path.exists())
|
||||
self.assertTrue(result.hierarchy_path.exists())
|
||||
self.assertEqual(adb.calls, [("inspect", SERIAL), ("intent", "123")])
|
||||
self.assertEqual(device.calls, ["app_info", "app_current", "takeScreenshot", "dumpWindowHierarchy"])
|
||||
self.assertIn('"goods_id": "123"', manifest)
|
||||
self.assertIn('"canonical_url": "https://mobile.yangkeduo.com/goods.html?goods_id=123"', manifest)
|
||||
self.assertNotIn(SERIAL, manifest)
|
||||
self.assertNotIn("sensitive.activity.name", manifest)
|
||||
self.assertNotIn(HIERARCHY, manifest)
|
||||
|
||||
def test_version_mismatch_halts_before_intent(self) -> None:
|
||||
adb = FakeAdbClient()
|
||||
for version in ("8.17.1", " 8.17.0 "):
|
||||
with self.subTest(version=version), TemporaryDirectory() as temporary:
|
||||
target = Path(temporary) / "evidence"
|
||||
with self.assertRaises(ProductVersionMismatchError):
|
||||
self._capturer(adb, FakeUiDevice(version=version)).open_and_capture(SERIAL, URL, target)
|
||||
|
||||
self.assertEqual(adb.calls[-1:], [("inspect", SERIAL)])
|
||||
self.assertFalse(target.exists())
|
||||
|
||||
def test_public_entry_rejects_caller_constructed_url_value_object(self) -> None:
|
||||
adb = FakeAdbClient()
|
||||
with TemporaryDirectory() as temporary:
|
||||
with self.assertRaises(ProductUrlError):
|
||||
self._capturer(adb, FakeUiDevice()).open_and_capture(
|
||||
SERIAL,
|
||||
ProductUrl(goods_id="123", canonical_url="https://example.invalid/"), # type: ignore[arg-type]
|
||||
Path(temporary) / "evidence",
|
||||
)
|
||||
|
||||
self.assertEqual(adb.calls, [])
|
||||
|
||||
def test_foreground_package_mismatch_halts_before_capture(self) -> None:
|
||||
adb = FakeAdbClient()
|
||||
device = FakeUiDevice(current_package="com.example.other")
|
||||
clock = FakeClock()
|
||||
with TemporaryDirectory() as temporary:
|
||||
target = Path(temporary) / "evidence"
|
||||
with self.assertRaises(ProductPackageMismatchError):
|
||||
self._capturer(
|
||||
adb,
|
||||
device,
|
||||
foreground_poll_interval_seconds=0.5,
|
||||
monotonic_clock=clock.monotonic,
|
||||
sleep_function=clock.sleep,
|
||||
).open_and_capture(SERIAL, URL, target)
|
||||
|
||||
self.assertEqual(adb.calls, [("inspect", SERIAL), ("intent", "123")])
|
||||
self.assertEqual(device.calls, ["app_info", "app_current", "app_current", "app_current", "app_current", "app_current"])
|
||||
self.assertEqual(clock.sleeps, [0.5, 0.5, 0.5, 0.5])
|
||||
self.assertFalse(target.exists())
|
||||
|
||||
def test_foreground_package_poll_waits_for_pdd_before_reading_evidence(self) -> None:
|
||||
adb = FakeAdbClient()
|
||||
device = FakeUiDevice(current_packages=["com.example.other", "com.xunmeng.pinduoduo"])
|
||||
clock = FakeClock()
|
||||
with TemporaryDirectory() as temporary:
|
||||
target = Path(temporary) / "evidence"
|
||||
result = self._capturer(
|
||||
adb,
|
||||
device,
|
||||
foreground_poll_interval_seconds=0.25,
|
||||
monotonic_clock=clock.monotonic,
|
||||
sleep_function=clock.sleep,
|
||||
).open_and_capture(SERIAL, URL, target)
|
||||
|
||||
self.assertTrue(result.manifest_path.exists())
|
||||
self.assertEqual(clock.sleeps, [0.25])
|
||||
self.assertEqual(
|
||||
device.calls,
|
||||
["app_info", "app_current", "app_current", "takeScreenshot", "dumpWindowHierarchy"],
|
||||
)
|
||||
|
||||
def test_foreground_package_poll_stops_at_deadline_without_evidence(self) -> None:
|
||||
adb = FakeAdbClient()
|
||||
device = FakeUiDevice(current_packages=["com.example.other", "", "com.example.other"])
|
||||
clock = FakeClock()
|
||||
with TemporaryDirectory() as temporary:
|
||||
target = Path(temporary) / "evidence"
|
||||
with self.assertRaises(ProductPackageMismatchError):
|
||||
self._capturer(
|
||||
adb,
|
||||
device,
|
||||
foreground_poll_interval_seconds=0.8,
|
||||
monotonic_clock=clock.monotonic,
|
||||
sleep_function=clock.sleep,
|
||||
).open_and_capture(SERIAL, URL, target)
|
||||
|
||||
self.assertEqual(len(clock.sleeps), 3)
|
||||
for actual, expected in zip(clock.sleeps, (0.8, 0.8, 0.4), strict=True):
|
||||
self.assertAlmostEqual(actual, expected)
|
||||
self.assertEqual(device.calls, ["app_info", "app_current", "app_current", "app_current", "app_current"])
|
||||
self.assertFalse(target.exists())
|
||||
self.assertEqual(list(Path(temporary).glob(".evidence.staging-*")), [])
|
||||
|
||||
def test_foreground_poll_interval_must_be_positive_and_finite(self) -> None:
|
||||
for interval in (0, -0.1, float("inf"), float("nan"), True):
|
||||
with self.subTest(interval=interval):
|
||||
with self.assertRaises(ValueError):
|
||||
ProductOpenCapturer(
|
||||
FakeAdbClient(),
|
||||
lambda serial: FakeUiDevice(),
|
||||
timeout_seconds=2,
|
||||
foreground_poll_interval_seconds=interval, # type: ignore[arg-type]
|
||||
)
|
||||
|
||||
def test_timeout_and_invalid_hierarchy_leave_no_partial_evidence(self) -> None:
|
||||
scenarios = (
|
||||
(FakeUiDevice(timeout_on_screenshot=True), ProductOpenTimeoutError),
|
||||
(FakeUiDevice(hierarchy="<not-hierarchy />"), ProductHierarchyCaptureError),
|
||||
)
|
||||
for device, error_type in scenarios:
|
||||
with self.subTest(error_type=error_type.__name__), TemporaryDirectory() as temporary:
|
||||
target = Path(temporary) / "evidence"
|
||||
with self.assertRaises(error_type):
|
||||
self._capturer(FakeAdbClient(), device).open_and_capture(SERIAL, URL, target)
|
||||
|
||||
self.assertFalse(target.exists())
|
||||
self.assertEqual(list(Path(temporary).glob(".evidence.staging-*")), [])
|
||||
|
||||
def test_invalid_screenshot_is_a_distinct_redacted_failure(self) -> None:
|
||||
class InvalidScreenshotDevice(FakeUiDevice):
|
||||
def jsonrpc_call(self, method: str, params: object = None, timeout: float = 10) -> str:
|
||||
if method == "takeScreenshot":
|
||||
self.calls.append(method)
|
||||
return "not valid base64!"
|
||||
return super().jsonrpc_call(method, params, timeout)
|
||||
|
||||
with TemporaryDirectory() as temporary:
|
||||
target = Path(temporary) / "evidence"
|
||||
with self.assertRaises(ProductScreenshotCaptureError) as raised:
|
||||
self._capturer(FakeAdbClient(), InvalidScreenshotDevice()).open_and_capture(SERIAL, URL, target)
|
||||
|
||||
self.assertNotIn("base64", str(raised.exception).lower())
|
||||
self.assertFalse(target.exists())
|
||||
self.assertEqual(list(Path(temporary).glob(".evidence.staging-*")), [])
|
||||
|
||||
def test_read_only_protocol_has_no_ui_operation_methods(self) -> None:
|
||||
forbidden = {"click", "swipe", "send_keys", "set_text", "press", "long_click"}
|
||||
|
||||
self.assertTrue(forbidden.isdisjoint(ProductOpenUiDevice.__dict__))
|
||||
self.assertEqual(base64.b64decode(_png_base64())[:8], b"\x89PNG\r\n\x1a\n")
|
||||
|
||||
|
||||
class FakeClock:
|
||||
def __init__(self) -> None:
|
||||
self.value = 0.0
|
||||
self.sleeps: list[float] = []
|
||||
|
||||
def monotonic(self) -> float:
|
||||
return self.value
|
||||
|
||||
def sleep(self, seconds: float) -> None:
|
||||
self.sleeps.append(seconds)
|
||||
self.value += seconds
|
||||
@@ -0,0 +1,49 @@
|
||||
"""canonical 商品 URL 的离线解析测试。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import unittest
|
||||
|
||||
|
||||
CLIENT_ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(CLIENT_ROOT / "src"))
|
||||
|
||||
from cmbuyer_client.pdd.product_url import ProductUrlError, parse_product_url
|
||||
|
||||
|
||||
class ProductUrlTests(unittest.TestCase):
|
||||
def test_rebuilds_url_from_goods_id(self) -> None:
|
||||
link = parse_product_url("https://mobile.yangkeduo.com/goods.html?goods_id=00123")
|
||||
|
||||
self.assertEqual(link.goods_id, "00123")
|
||||
self.assertEqual(
|
||||
link.canonical_url,
|
||||
"https://mobile.yangkeduo.com/goods.html?goods_id=00123",
|
||||
)
|
||||
|
||||
def test_rejects_noncanonical_and_ambiguous_urls(self) -> None:
|
||||
rejected = (
|
||||
"http://mobile.yangkeduo.com/goods.html?goods_id=123",
|
||||
"https://other.example/goods.html?goods_id=123",
|
||||
"https://mobile.yangkeduo.com/other.html?goods_id=123",
|
||||
"https://user@mobile.yangkeduo.com/goods.html?goods_id=123",
|
||||
"https://mobile.yangkeduo.com:8443/goods.html?goods_id=123",
|
||||
"https://mobile.yangkeduo.com:443/goods.html?goods_id=123",
|
||||
"https://mobile.yangkeduo.com/goods.html?goods_id=123#fragment",
|
||||
"https://mobile.yangkeduo.com/goods.html",
|
||||
"https://mobile.yangkeduo.com/goods.html?goods_id=123&goods_id=456",
|
||||
"https://mobile.yangkeduo.com/goods.html?goods_id=123&source=share",
|
||||
"https://mobile.yangkeduo.com/goods.html?goods_id=12a",
|
||||
"https://mobile.yangkeduo.com/goods.html?goods_id=%EF%BC%91%EF%BC%92%EF%BC%93",
|
||||
"https://mobile.yangkeduo.com/goods.html?goods_id=",
|
||||
" https://mobile.yangkeduo.com/goods.html?goods_id=123",
|
||||
"https://MOBILE.YANGKEDUO.COM/goods.html?goods_id=123",
|
||||
"https://mobile.yangkeduo.com/goods.html?goods_id=%31%32%33",
|
||||
)
|
||||
|
||||
for value in rejected:
|
||||
with self.subTest(value=value):
|
||||
with self.assertRaises(ProductUrlError):
|
||||
parse_product_url(value)
|
||||
@@ -0,0 +1,263 @@
|
||||
"""人工声明规格面板状态的离线只读取证测试。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import base64
|
||||
from importlib.util import module_from_spec, spec_from_file_location
|
||||
from io import BytesIO
|
||||
from pathlib import Path
|
||||
import sys
|
||||
from tempfile import TemporaryDirectory
|
||||
import unittest
|
||||
|
||||
from PIL import Image
|
||||
|
||||
|
||||
CLIENT_ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(CLIENT_ROOT / "src"))
|
||||
|
||||
from cmbuyer_client.device.adb import AdbDevice, DeviceInspection
|
||||
from cmbuyer_client.pdd.product_url import ProductUrlError
|
||||
from cmbuyer_client.pdd.sku_panel_spike import (
|
||||
HUMAN_DECLARED_STATES,
|
||||
SkuPanelDeclaredStateError,
|
||||
SkuPanelEvidenceCapturer,
|
||||
SkuPanelEvidenceError,
|
||||
SkuPanelEvidenceTimeoutError,
|
||||
SkuPanelHierarchyError,
|
||||
SkuPanelPackageMismatchError,
|
||||
SkuPanelScreenshotError,
|
||||
SkuPanelUiDevice,
|
||||
SkuPanelVersionMismatchError,
|
||||
)
|
||||
|
||||
|
||||
SERIAL = "192.168.0.173:5555"
|
||||
URL = "https://mobile.yangkeduo.com/goods.html?goods_id=123"
|
||||
HIERARCHY = "<?xml version='1.0' encoding='UTF-8'?><hierarchy><node text='sensitive page text' /></hierarchy>"
|
||||
|
||||
|
||||
def _png_base64() -> str:
|
||||
image_data = BytesIO()
|
||||
Image.new("RGB", (1, 1), color="white").save(image_data, format="PNG")
|
||||
return base64.b64encode(image_data.getvalue()).decode("ascii")
|
||||
|
||||
|
||||
class FakeAdbClient:
|
||||
def __init__(self) -> None:
|
||||
self.calls: list[str] = []
|
||||
self.inspection = DeviceInspection(
|
||||
device=AdbDevice(serial=SERIAL, state="device", model="PKG110"),
|
||||
model="PKG110",
|
||||
android_version="16",
|
||||
)
|
||||
|
||||
def inspect(self, serial: str) -> DeviceInspection:
|
||||
self.calls.append(serial)
|
||||
return self.inspection
|
||||
|
||||
|
||||
class FakeUiDevice:
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
version: str = "8.17.0",
|
||||
package: str = "com.xunmeng.pinduoduo",
|
||||
screenshot: str | None = None,
|
||||
hierarchy: str = HIERARCHY,
|
||||
) -> None:
|
||||
self.version = version
|
||||
self.package = package
|
||||
self.screenshot = screenshot if screenshot is not None else _png_base64()
|
||||
self.hierarchy = hierarchy
|
||||
self.calls: list[str] = []
|
||||
|
||||
def app_info(self, package_name: str) -> dict[str, str]:
|
||||
self.calls.append("app_info")
|
||||
return {"versionName": self.version}
|
||||
|
||||
def app_current(self) -> dict[str, str]:
|
||||
self.calls.append("app_current")
|
||||
return {"package": self.package, "activity": "sensitive.activity.name"}
|
||||
|
||||
def jsonrpc_call(self, method: str, params: object = None, timeout: float = 10) -> str:
|
||||
self.calls.append(method)
|
||||
if method == "takeScreenshot":
|
||||
return self.screenshot
|
||||
if method == "dumpWindowHierarchy":
|
||||
return self.hierarchy
|
||||
raise AssertionError(f"unexpected RPC {method}")
|
||||
|
||||
|
||||
def _load_spike_script() -> object:
|
||||
script_path = CLIENT_ROOT / "scripts" / "capture_sku_panel_spike.py"
|
||||
spec = spec_from_file_location("capture_sku_panel_spike_for_test", script_path)
|
||||
assert spec is not None and spec.loader is not None
|
||||
module = module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
class SkuPanelEvidenceTests(unittest.TestCase):
|
||||
def _capturer(self, adb: FakeAdbClient, device: FakeUiDevice) -> SkuPanelEvidenceCapturer:
|
||||
return SkuPanelEvidenceCapturer(adb, lambda serial: device, timeout_seconds=2)
|
||||
|
||||
def test_all_human_declared_states_publish_redacted_manifest(self) -> None:
|
||||
for state in sorted(HUMAN_DECLARED_STATES):
|
||||
with self.subTest(state=state), TemporaryDirectory() as temporary:
|
||||
adb = FakeAdbClient()
|
||||
device = FakeUiDevice()
|
||||
target = Path(temporary) / "evidence"
|
||||
result = self._capturer(adb, device).capture(SERIAL, URL, state, target)
|
||||
manifest = result.manifest_path.read_text(encoding="utf-8")
|
||||
|
||||
self.assertEqual(adb.calls, [SERIAL])
|
||||
self.assertEqual(device.calls, ["app_info", "app_current", "takeScreenshot", "dumpWindowHierarchy"])
|
||||
self.assertIn(f'"human_declared_state": "{state}"', manifest)
|
||||
self.assertIn('"goods_id": "123"', manifest)
|
||||
self.assertIn('"canonical_url": "https://mobile.yangkeduo.com/goods.html?goods_id=123"', manifest)
|
||||
self.assertNotIn("detected_state", manifest)
|
||||
self.assertNotIn(SERIAL, manifest)
|
||||
self.assertNotIn("sensitive.activity.name", manifest)
|
||||
self.assertNotIn(HIERARCHY, manifest)
|
||||
|
||||
def test_invalid_state_and_url_fail_before_device_access(self) -> None:
|
||||
adb = FakeAdbClient()
|
||||
rejected_states = ("initial", "one-dimension-selected", "all-dimensions-selected", "guessed")
|
||||
with TemporaryDirectory() as temporary:
|
||||
for state in rejected_states:
|
||||
with self.subTest(state=state), self.assertRaises(SkuPanelDeclaredStateError):
|
||||
self._capturer(adb, FakeUiDevice()).capture(SERIAL, URL, state, Path(temporary) / "state")
|
||||
with self.assertRaises(ProductUrlError):
|
||||
self._capturer(adb, FakeUiDevice()).capture(
|
||||
SERIAL,
|
||||
"https://mobile.yangkeduo.com/goods.html?goods_id=12x",
|
||||
"panel-opened-target-preselected",
|
||||
Path(temporary) / "url",
|
||||
)
|
||||
|
||||
self.assertEqual(adb.calls, [])
|
||||
|
||||
def test_version_or_foreground_package_mismatch_stops_before_artifacts(self) -> None:
|
||||
scenarios = (
|
||||
(FakeUiDevice(version="8.17.1"), SkuPanelVersionMismatchError, ["app_info"]),
|
||||
(FakeUiDevice(package="com.example.other"), SkuPanelPackageMismatchError, ["app_info", "app_current"]),
|
||||
)
|
||||
for device, error_type, expected_calls in scenarios:
|
||||
with self.subTest(error_type=error_type.__name__), TemporaryDirectory() as temporary:
|
||||
target = Path(temporary) / "evidence"
|
||||
with self.assertRaises(error_type):
|
||||
self._capturer(FakeAdbClient(), device).capture(SERIAL, URL, "panel-opened-target-preselected", target)
|
||||
|
||||
self.assertEqual(device.calls, expected_calls)
|
||||
self.assertFalse(target.exists())
|
||||
|
||||
def test_screenshot_and_xml_failure_leave_no_partial_evidence(self) -> None:
|
||||
scenarios = (
|
||||
(FakeUiDevice(screenshot="not valid base64!"), SkuPanelScreenshotError),
|
||||
(FakeUiDevice(hierarchy="<not-hierarchy />"), SkuPanelHierarchyError),
|
||||
)
|
||||
for device, error_type in scenarios:
|
||||
with self.subTest(error_type=error_type.__name__), TemporaryDirectory() as temporary:
|
||||
target = Path(temporary) / "evidence"
|
||||
with self.assertRaises(error_type):
|
||||
self._capturer(FakeAdbClient(), device).capture(SERIAL, URL, "panel-opened-target-preselected", target)
|
||||
|
||||
self.assertFalse(target.exists())
|
||||
self.assertEqual(list(Path(temporary).glob(".evidence.staging-*")), [])
|
||||
|
||||
def test_screenshot_timeout_is_redacted_and_leaves_no_partial_evidence(self) -> None:
|
||||
class TimeoutScreenshotDevice(FakeUiDevice):
|
||||
def jsonrpc_call(self, method: str, params: object = None, timeout: float = 10) -> str:
|
||||
if method == "takeScreenshot":
|
||||
self.calls.append(method)
|
||||
raise TimeoutError("adb 192.168.0.173:5555 raw detail")
|
||||
return super().jsonrpc_call(method, params, timeout)
|
||||
|
||||
with TemporaryDirectory() as temporary:
|
||||
target = Path(temporary) / "evidence"
|
||||
with self.assertRaises(SkuPanelEvidenceTimeoutError) as raised:
|
||||
self._capturer(FakeAdbClient(), TimeoutScreenshotDevice()).capture(SERIAL, URL, "panel-opened-target-preselected", target)
|
||||
|
||||
self.assertNotIn(SERIAL, str(raised.exception))
|
||||
self.assertNotIn("adb", str(raised.exception).lower())
|
||||
self.assertFalse(target.exists())
|
||||
self.assertEqual(list(Path(temporary).glob(".evidence.staging-*")), [])
|
||||
|
||||
def test_existing_output_directory_is_not_overwritten_or_connected(self) -> None:
|
||||
adb = FakeAdbClient()
|
||||
device = FakeUiDevice()
|
||||
with TemporaryDirectory() as temporary:
|
||||
target = Path(temporary) / "evidence"
|
||||
target.mkdir()
|
||||
sentinel = target / "sentinel.txt"
|
||||
sentinel.write_text("keep", encoding="utf-8")
|
||||
|
||||
with self.assertRaises(SkuPanelEvidenceError):
|
||||
self._capturer(adb, device).capture(SERIAL, URL, "panel-opened-target-preselected", target)
|
||||
|
||||
self.assertEqual(adb.calls, [])
|
||||
self.assertEqual(device.calls, [])
|
||||
self.assertEqual(sentinel.read_text(encoding="utf-8"), "keep")
|
||||
|
||||
def test_protocol_has_no_ui_or_purchase_operation_methods(self) -> None:
|
||||
forbidden = {
|
||||
"click",
|
||||
"swipe",
|
||||
"send_keys",
|
||||
"set_text",
|
||||
"press",
|
||||
"open_product",
|
||||
"open_sku_panel",
|
||||
"set_quantity",
|
||||
"go_to_order_confirm",
|
||||
"submit_order",
|
||||
"pay",
|
||||
}
|
||||
self.assertTrue(forbidden.isdisjoint(SkuPanelUiDevice.__dict__))
|
||||
self.assertEqual({name for name in SkuPanelEvidenceCapturer.__dict__ if not name.startswith("_")}, {"capture"})
|
||||
|
||||
|
||||
class SkuPanelSpikeCliTests(unittest.TestCase):
|
||||
def test_validate_arguments_rejects_invalid_serial_timeout_state_and_url(self) -> None:
|
||||
script = _load_spike_script()
|
||||
valid = {
|
||||
"serial": SERIAL,
|
||||
"url": URL,
|
||||
"goods_id": None,
|
||||
"state": "panel-opened-target-preselected",
|
||||
"output_dir": Path("evidence"),
|
||||
"timeout": 10.0,
|
||||
"adb": "adb",
|
||||
}
|
||||
invalid_values = (
|
||||
("serial", ""),
|
||||
("timeout", 0),
|
||||
("timeout", float("inf")),
|
||||
("state", "not-declared"),
|
||||
("state", "initial"),
|
||||
("state", "one-dimension-selected"),
|
||||
("state", "all-dimensions-selected"),
|
||||
("url", "https://mobile.yangkeduo.com/goods.html?goods_id=bad"),
|
||||
)
|
||||
for field, value in invalid_values:
|
||||
with self.subTest(field=field, value=value):
|
||||
arguments = argparse.Namespace(**(valid | {field: value}))
|
||||
with self.assertRaises((ValueError, ProductUrlError)):
|
||||
script.validate_arguments(arguments) # type: ignore[attr-defined]
|
||||
|
||||
def test_goods_id_is_rebuilt_as_canonical_url(self) -> None:
|
||||
script = _load_spike_script()
|
||||
arguments = argparse.Namespace(
|
||||
serial=SERIAL,
|
||||
url=None,
|
||||
goods_id="00123",
|
||||
state="target-selection-restored",
|
||||
output_dir=Path("evidence"),
|
||||
timeout=10.0,
|
||||
adb="adb",
|
||||
)
|
||||
|
||||
link = script.validate_arguments(arguments) # type: ignore[attr-defined]
|
||||
self.assertEqual(link.canonical_url, "https://mobile.yangkeduo.com/goods.html?goods_id=00123")
|
||||
@@ -0,0 +1,689 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import ast
|
||||
import base64
|
||||
from contextlib import redirect_stderr
|
||||
from io import BytesIO
|
||||
import importlib.util
|
||||
from pathlib import Path
|
||||
from tempfile import TemporaryDirectory
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
from xml.etree import ElementTree
|
||||
|
||||
from PIL import Image
|
||||
|
||||
import cmbuyer_client.pdd as pdd
|
||||
import cmbuyer_client.pdd.sku_selection_runner as runner_module
|
||||
from cmbuyer_client.device.adb import AdbDevice, DeviceInspection
|
||||
from cmbuyer_client.pdd import SkuSelectionError, SkuSelectionFlow, SkuSelectionRunner
|
||||
from cmbuyer_client.pdd.sku_selection import SkuPanelDevice, _action_bounds, resolve_task_selection
|
||||
from cmbuyer_client.pdd.sku_selection_runner import (
|
||||
SkuSelectionDeviceAdapterError,
|
||||
SkuSelectionRunError,
|
||||
SkuSelectionScreenshotError,
|
||||
UiautomatorSkuPanelAdapter,
|
||||
)
|
||||
|
||||
|
||||
_FIXTURE = Path(__file__).with_name("fixtures") / "sku_panel_8_17_0.xml"
|
||||
_ENTRY_FIXTURE = Path(__file__).with_name("fixtures") / "product_entry_8_17_0.xml"
|
||||
_TARGET_URL = "https://mobile.yangkeduo.com/goods.html?goods_id=937122477375"
|
||||
_TASK_COLOR = "黑色CHA(纯棉)"
|
||||
_TASK_SIZE = "M(建议100-115)"
|
||||
_PRODUCT_PAGE = _ENTRY_FIXTURE.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def _png_base64() -> str:
|
||||
image = Image.new("RGB", (1080, 2376), "white")
|
||||
raw = BytesIO()
|
||||
image.save(raw, format="PNG")
|
||||
return base64.b64encode(raw.getvalue()).decode("ascii")
|
||||
|
||||
|
||||
class _RawDevice:
|
||||
def __init__(self, hierarchy: str = _PRODUCT_PAGE, screenshot: str | None = None) -> None:
|
||||
self.hierarchy = hierarchy
|
||||
self.panel_hierarchy = _FIXTURE.read_text(encoding="utf-8")
|
||||
self.version = "8.17.0"
|
||||
self.package = "com.xunmeng.pinduoduo"
|
||||
self.screenshot = _png_base64() if screenshot is None else screenshot
|
||||
self.calls: list[tuple[object, ...]] = []
|
||||
self.fail_color_readback = False
|
||||
|
||||
def app_info(self, package_name: str) -> dict[str, str]:
|
||||
self.calls.append(("app_info", package_name))
|
||||
return {"versionName": self.version}
|
||||
|
||||
def app_current(self) -> dict[str, str]:
|
||||
self.calls.append(("app_current",))
|
||||
return {"package": self.package}
|
||||
|
||||
def jsonrpc_call(self, method: str, params: object = None, timeout: float = 10) -> str:
|
||||
self.calls.append(("jsonrpc", method, params, timeout))
|
||||
if method == "dumpWindowHierarchy":
|
||||
return self.hierarchy
|
||||
if method == "takeScreenshot":
|
||||
return self.screenshot
|
||||
if method == "pressKey":
|
||||
self.hierarchy = "<hierarchy />"
|
||||
return ""
|
||||
if method == "click":
|
||||
if not isinstance(params, list) or len(params) != 2:
|
||||
raise AssertionError(params)
|
||||
self._apply_tap(int(params[0]), int(params[1]))
|
||||
return ""
|
||||
raise AssertionError(method)
|
||||
|
||||
def _apply_tap(self, x: int, y: int) -> None:
|
||||
if "快要抢光" in self.hierarchy and "[396,498][895,570]" not in self.hierarchy:
|
||||
self.hierarchy = self.panel_hierarchy
|
||||
return
|
||||
root = ElementTree.fromstring(self.hierarchy)
|
||||
target = next(node for node in root.iter("node") if _center(node.get("bounds", "")) == (x, y))
|
||||
color = target.get("bounds", "").endswith("][438,1172]")
|
||||
for node in root.iter("node"):
|
||||
if node.get("selected") is not None and ((color and ",1000]" in node.get("bounds", "")) or (not color and ",1730]" in node.get("bounds", ""))):
|
||||
node.set("selected", "false")
|
||||
if color and self.fail_color_readback:
|
||||
next(node for node in root.iter("node") if node.get("content-desc") == "粉红").set("selected", "true")
|
||||
else:
|
||||
target.set("selected", "true")
|
||||
self.hierarchy = ElementTree.tostring(root, encoding="unicode")
|
||||
|
||||
def window_size(self) -> tuple[int, int]:
|
||||
self.calls.append(("window_size",))
|
||||
return 1080, 2376
|
||||
|
||||
def select_alternates(self) -> None:
|
||||
root = ElementTree.fromstring(self.panel_hierarchy)
|
||||
for node in root.iter("node"):
|
||||
if node.get("selected") is not None:
|
||||
node.set("selected", "false")
|
||||
next(node for node in root.iter("node") if node.get("content-desc") == "粉红").set("selected", "true")
|
||||
next(node for node in root.iter("node") if node.get("text") == "L(建议115-130)").set("selected", "true")
|
||||
self.panel_hierarchy = ElementTree.tostring(root, encoding="unicode")
|
||||
if self.hierarchy != _PRODUCT_PAGE:
|
||||
self.hierarchy = self.panel_hierarchy
|
||||
|
||||
|
||||
def _center(bounds: str) -> tuple[int, int]:
|
||||
left_top, right_bottom = bounds.split("][")
|
||||
left, top = (int(value) for value in left_top.removeprefix("[").split(","))
|
||||
right, bottom = (int(value) for value in right_bottom.removesuffix("]").split(","))
|
||||
return left + (right - left) // 2, top + (bottom - top) // 2
|
||||
|
||||
|
||||
def _entry_chain(root: ElementTree.Element) -> list[ElementTree.Element]:
|
||||
parents = {child: parent for parent in root.iter() for child in parent}
|
||||
child = next(node for node in root.iter("node") if node.get("text") == "快要抢光")
|
||||
chain = [child]
|
||||
for _ in range(4):
|
||||
chain.append(parents[chain[-1]])
|
||||
return chain
|
||||
|
||||
|
||||
def _mutate_entry(depth: int, attribute: str, value: str) -> str:
|
||||
root = ElementTree.fromstring(_PRODUCT_PAGE)
|
||||
_entry_chain(root)[depth].set(attribute, value)
|
||||
return ElementTree.tostring(root, encoding="unicode")
|
||||
|
||||
|
||||
def _without_entry() -> str:
|
||||
root = ElementTree.fromstring(_PRODUCT_PAGE)
|
||||
root.remove(_entry_chain(root)[4])
|
||||
return ElementTree.tostring(root, encoding="unicode")
|
||||
|
||||
|
||||
def _duplicate_entry() -> str:
|
||||
root = ElementTree.fromstring(_PRODUCT_PAGE)
|
||||
entry_root = _entry_chain(root)[4]
|
||||
root.append(ElementTree.fromstring(ElementTree.tostring(entry_root, encoding="unicode")))
|
||||
return ElementTree.tostring(root, encoding="unicode")
|
||||
|
||||
|
||||
def _actions(device: _RawDevice, method: str) -> list[tuple[object, ...]]:
|
||||
return [call for call in device.calls if call[0] == "jsonrpc" and call[1] == method]
|
||||
|
||||
|
||||
def _tap_centers(device: _RawDevice) -> list[tuple[int, int]]:
|
||||
return [tuple(call[2]) for call in _actions(device, "click")] # type: ignore[misc]
|
||||
|
||||
|
||||
class _FakeAdb:
|
||||
def __init__(self) -> None:
|
||||
self.calls: list[tuple[object, ...]] = []
|
||||
self.inspection = DeviceInspection(AdbDevice(serial="device-1", state="device"), "PKG110", "16")
|
||||
self.on_intent: callable | None = None
|
||||
|
||||
def inspect(self, serial: str) -> DeviceInspection:
|
||||
self.calls.append(("inspect", serial))
|
||||
return self.inspection
|
||||
|
||||
def start_pdd_view_intent(self, serial: str, goods_id: str) -> object:
|
||||
self.calls.append(("intent", serial, goods_id))
|
||||
if self.on_intent is not None:
|
||||
self.on_intent()
|
||||
return object()
|
||||
|
||||
|
||||
class SkuSelectionFlowTests(unittest.TestCase):
|
||||
def _assert_entry_rejected_without_click(self, hierarchy: str) -> None:
|
||||
now = [0.0]
|
||||
device = _RawDevice(hierarchy)
|
||||
flow = SkuSelectionFlow(
|
||||
UiautomatorSkuPanelAdapter(device, 10),
|
||||
0.01,
|
||||
0.01,
|
||||
lambda: now[0],
|
||||
lambda seconds: now.__setitem__(0, now[0] + seconds),
|
||||
)
|
||||
with self.assertRaises(SkuSelectionError):
|
||||
flow.open_sku_panel(_TARGET_URL)
|
||||
self.assertEqual(_actions(device, "click"), [])
|
||||
|
||||
def test_target_mapping_is_exact_and_success_path_restores_target(self) -> None:
|
||||
device = _RawDevice()
|
||||
adapter = UiautomatorSkuPanelAdapter(device, 10)
|
||||
flow = SkuSelectionFlow(adapter)
|
||||
|
||||
flow.open_sku_panel(_TARGET_URL)
|
||||
flow.select_sku_options(resolve_task_selection(_TASK_COLOR, _TASK_SIZE))
|
||||
self.assertEqual(flow.read_sku_unit_price(), "12.88")
|
||||
flow.exit_sku_panel_safely()
|
||||
|
||||
self.assertEqual(_tap_centers(device), [(978, 1333)])
|
||||
self.assertEqual(_actions(device, "pressKey"), [("jsonrpc", "pressKey", ["back"], 10)])
|
||||
|
||||
def test_full_verified_entry_structure_taps_exact_text_child_once(self) -> None:
|
||||
device = _RawDevice()
|
||||
SkuSelectionFlow(UiautomatorSkuPanelAdapter(device, 10)).open_sku_panel(_TARGET_URL)
|
||||
|
||||
self.assertEqual(_tap_centers(device), [(978, 1333)])
|
||||
|
||||
def test_entry_child_and_every_ancestor_attribute_drift_never_clicks(self) -> None:
|
||||
expected_clickable = ("false", "false", "false", "false", "true")
|
||||
for depth in range(5):
|
||||
changes = {
|
||||
"package": "other.package",
|
||||
"class": "android.widget.Button",
|
||||
"bounds": "[1,1][2,2]",
|
||||
"clickable": "true" if expected_clickable[depth] == "false" else "false",
|
||||
"enabled": "false",
|
||||
"visible-to-user": "false",
|
||||
}
|
||||
for attribute, value in changes.items():
|
||||
with self.subTest(depth=depth, attribute=attribute):
|
||||
self._assert_entry_rejected_without_click(_mutate_entry(depth, attribute, value))
|
||||
|
||||
def test_duplicate_entry_and_forbidden_sibling_entry_never_click(self) -> None:
|
||||
self._assert_entry_rejected_without_click(_duplicate_entry())
|
||||
self._assert_entry_rejected_without_click(_without_entry())
|
||||
self._assert_entry_rejected_without_click(_mutate_entry(0, "clickable", "true"))
|
||||
|
||||
def test_unknown_task_or_ui_variants_are_rejected_without_action(self) -> None:
|
||||
for color, size in (("黑色 CHA (纯棉)", _TASK_SIZE), (_TASK_COLOR, "M(建议100-115)"), ("黑色CHA(纯棉)", _TASK_SIZE)):
|
||||
with self.subTest(color=color, size=size), self.assertRaises(SkuSelectionError):
|
||||
resolve_task_selection(color, size)
|
||||
|
||||
device = _RawDevice(_FIXTURE.read_text(encoding="utf-8"))
|
||||
with self.assertRaises(SkuSelectionError):
|
||||
SkuSelectionFlow(UiautomatorSkuPanelAdapter(device, 10)).select_sku_options(
|
||||
resolve_task_selection(_TASK_COLOR, _TASK_SIZE).__class__("粉红", "L(建议115-130)")
|
||||
)
|
||||
self.assertEqual(_actions(device, "click"), [])
|
||||
|
||||
def test_option_selected_and_container_drift_fail_closed_before_click(self) -> None:
|
||||
base = _FIXTURE.read_text(encoding="utf-8")
|
||||
cases = (
|
||||
base.replace('selected="true" clickable="true" enabled="true" visible-to-user="true" bounds="[126,1000][438,1172]"', 'clickable="true" enabled="true" visible-to-user="true" bounds="[126,1000][438,1172]"'),
|
||||
base.replace('selected="true" clickable="true" enabled="true" visible-to-user="true" bounds="[126,1000][438,1172]"', 'selected="maybe" clickable="true" enabled="true" visible-to-user="true" bounds="[126,1000][438,1172]"'),
|
||||
base.replace('bounds="[126,1000][438,1172]"', 'bounds="[1,1][20,20]"'),
|
||||
base.replace('enabled="true" visible-to-user="true" bounds="[126,1000][438,1172]"', 'enabled="false" visible-to-user="true" bounds="[126,1000][438,1172]"'),
|
||||
)
|
||||
for hierarchy in cases:
|
||||
with self.subTest(), self.assertRaises(SkuSelectionError):
|
||||
SkuSelectionFlow(UiautomatorSkuPanelAdapter(_RawDevice(hierarchy), 10)).select_sku_options(resolve_task_selection(_TASK_COLOR, _TASK_SIZE))
|
||||
|
||||
def test_invalid_bounds_stop_before_action(self) -> None:
|
||||
for bounds in ("", "[1,2][1,3]", "[1,2][3,2]", "[0,0][1081,1]", "[0,0][1,2377]", "[a,0][1,1]"):
|
||||
with self.subTest(bounds=bounds), self.assertRaises(SkuSelectionError):
|
||||
_action_bounds(bounds)
|
||||
|
||||
device = _RawDevice(_PRODUCT_PAGE.replace("[900,1312][1056,1355]", "[0,0][1081,1]"))
|
||||
with self.assertRaises(SkuSelectionError):
|
||||
SkuSelectionFlow(UiautomatorSkuPanelAdapter(device, 10)).open_sku_panel(_TARGET_URL)
|
||||
self.assertEqual(_actions(device, "click"), [])
|
||||
|
||||
def test_color_readback_failure_never_attempts_second_option(self) -> None:
|
||||
device = _RawDevice()
|
||||
device.fail_color_readback = True
|
||||
flow = SkuSelectionFlow(UiautomatorSkuPanelAdapter(device, 10))
|
||||
flow.open_sku_panel(_TARGET_URL)
|
||||
device.select_alternates()
|
||||
with self.assertRaises(SkuSelectionError):
|
||||
flow.select_sku_options(resolve_task_selection(_TASK_COLOR, _TASK_SIZE))
|
||||
self.assertEqual(_tap_centers(device), [(978, 1333), (282, 1086)])
|
||||
|
||||
def test_non_target_selection_restores_each_dimension_once(self) -> None:
|
||||
device = _RawDevice()
|
||||
flow = SkuSelectionFlow(UiautomatorSkuPanelAdapter(device, 10))
|
||||
flow.open_sku_panel(_TARGET_URL)
|
||||
device.select_alternates()
|
||||
flow.select_sku_options(resolve_task_selection(_TASK_COLOR, _TASK_SIZE))
|
||||
self.assertEqual(
|
||||
_tap_centers(device),
|
||||
[(978, 1333), (282, 1086), (635, 1772)],
|
||||
)
|
||||
|
||||
def test_price_rejects_coupon_prefix_extra_amount_and_bottom_action(self) -> None:
|
||||
for replacement in ("券后 ¥12.88", "会员补贴 ¥12.88", "到手 ¥12.88", "实付 ¥12.88", "区间 ¥12.88", "原价 ¥12.88", "划线价 ¥12.88", "最低 ¥12.88", "低至 ¥12.88", "起价 ¥12.88", "快卖完 1 ¥12.88", "快卖完 ¥12.88 ¥11.88"):
|
||||
with self.subTest(replacement=replacement):
|
||||
device = _RawDevice(_FIXTURE.read_text(encoding="utf-8").replace("快卖完 ¥12.88", replacement))
|
||||
with self.assertRaises(SkuSelectionError):
|
||||
SkuSelectionFlow(UiautomatorSkuPanelAdapter(device, 10)).read_sku_unit_price()
|
||||
device = _RawDevice(_FIXTURE.read_text(encoding="utf-8").replace("快卖完 ¥12.88", "提交订单 ¥12.88"))
|
||||
with self.assertRaises(SkuSelectionError):
|
||||
SkuSelectionFlow(UiautomatorSkuPanelAdapter(device, 10)).read_sku_unit_price()
|
||||
clickable_parent = _FIXTURE.read_text(encoding="utf-8").replace(
|
||||
'<node package="" class="android.view.ViewGroup" bounds="[396,498][895,570]">',
|
||||
'<node package="" class="android.view.ViewGroup" clickable="true" bounds="[396,498][895,570]">',
|
||||
)
|
||||
with self.assertRaises(SkuSelectionError):
|
||||
SkuSelectionFlow(UiautomatorSkuPanelAdapter(_RawDevice(clickable_parent), 10)).read_sku_unit_price()
|
||||
|
||||
def test_public_api_and_protocol_have_no_broad_or_order_operations(self) -> None:
|
||||
forbidden = {"quantity", "confirm", "authorization", "fence", "submit", "payment", "click"}
|
||||
self.assertTrue(forbidden.isdisjoint(SkuSelectionFlow.__dict__))
|
||||
self.assertTrue(forbidden.isdisjoint(SkuPanelDevice.__dict__))
|
||||
self.assertTrue(forbidden.isdisjoint(pdd.__all__))
|
||||
|
||||
def test_static_ast_boundary_limits_flow_runner_adapter_and_cli(self) -> None:
|
||||
root = Path(__file__).resolve().parents[2]
|
||||
files = (
|
||||
root / "src" / "cmbuyer_client" / "pdd" / "sku_selection.py",
|
||||
root / "src" / "cmbuyer_client" / "pdd" / "sku_selection_runner.py",
|
||||
root / "scripts" / "run_t103_sku_selection.py",
|
||||
)
|
||||
forbidden = ("quantity", "confirm", "authorization", "fence", "submit_order", "payment")
|
||||
for path in files:
|
||||
source = path.read_text(encoding="utf-8")
|
||||
with self.subTest(path=path.name):
|
||||
self.assertTrue(all(token not in source.lower() for token in forbidden))
|
||||
tree = ast.parse(source)
|
||||
self.assertFalse(any(isinstance(node, ast.ImportFrom) and node.module in {"selenium", "requests"} for node in ast.walk(tree)))
|
||||
runner_tree = ast.parse(files[1].read_text(encoding="utf-8"))
|
||||
click_calls = [node for node in ast.walk(runner_tree) if isinstance(node, ast.Constant) and node.value == "click"]
|
||||
self.assertEqual(len(click_calls), 1)
|
||||
|
||||
def test_entry_wait_rejects_unchanged_or_duplicate_page_without_click(self) -> None:
|
||||
now = [0.0]
|
||||
device = _RawDevice()
|
||||
flow = SkuSelectionFlow(UiautomatorSkuPanelAdapter(device, 10), 0.01, 0.01, lambda: now[0], lambda seconds: now.__setitem__(0, now[0] + seconds))
|
||||
with self.assertRaises(SkuSelectionError):
|
||||
flow.open_sku_panel(_TARGET_URL, _PRODUCT_PAGE)
|
||||
self.assertEqual(_actions(device, "click"), [])
|
||||
|
||||
def test_action_postcondition_wait_never_repeats_entry_click(self) -> None:
|
||||
class NoPanelAfterEntry(_RawDevice):
|
||||
def jsonrpc_call(self, method: str, params: object = None, timeout: float = 10) -> str:
|
||||
if method == "click":
|
||||
self.calls.append(("jsonrpc", method, params, timeout))
|
||||
return ""
|
||||
return super().jsonrpc_call(method, params, timeout)
|
||||
|
||||
device = NoPanelAfterEntry()
|
||||
with self.assertRaises(SkuSelectionError):
|
||||
SkuSelectionFlow(UiautomatorSkuPanelAdapter(device, 10)).open_sku_panel(_TARGET_URL)
|
||||
self.assertEqual(_tap_centers(device), [(978, 1333)])
|
||||
|
||||
duplicate = _duplicate_entry()
|
||||
device = _RawDevice(duplicate)
|
||||
with self.assertRaises(SkuSelectionError):
|
||||
SkuSelectionFlow(UiautomatorSkuPanelAdapter(device, 10)).open_sku_panel(_TARGET_URL)
|
||||
self.assertEqual(_actions(device, "click"), [])
|
||||
|
||||
def test_fixture_contains_no_address_phone_or_payment_credentials(self) -> None:
|
||||
for fixture in (_FIXTURE, _ENTRY_FIXTURE):
|
||||
content = fixture.read_text(encoding="utf-8")
|
||||
with self.subTest(fixture=fixture.name):
|
||||
self.assertNotRegex(content, r"1[3-9]\d{9}")
|
||||
for forbidden in ("地址", "收货", "支付", "银行卡", "身份证"):
|
||||
self.assertNotIn(forbidden, content)
|
||||
content = _FIXTURE.read_text(encoding="utf-8")
|
||||
root = ElementTree.fromstring(content)
|
||||
leaf = next(node for node in root.iter("node") if node.get("text") == "提交订单 ¥12.88")
|
||||
self.assertEqual(leaf.get("clickable"), "false")
|
||||
self.assertEqual(leaf.get("bounds"), "[369,2225][710,2284]")
|
||||
|
||||
|
||||
class SkuSelectionRunnerTests(unittest.TestCase):
|
||||
def _runner(self, adb: _FakeAdb, device: _RawDevice) -> SkuSelectionRunner:
|
||||
device.hierarchy = "<hierarchy />"
|
||||
adb.on_intent = lambda: setattr(device, "hierarchy", _PRODUCT_PAGE.replace("<hierarchy>", '<hierarchy post-intent="1">'))
|
||||
return SkuSelectionRunner(adb, lambda serial: device, 10)
|
||||
|
||||
def test_runner_atomically_publishes_screenshot_and_redacted_manifest(self) -> None:
|
||||
adb = _FakeAdb()
|
||||
device = _RawDevice()
|
||||
with TemporaryDirectory() as temporary:
|
||||
target = Path(temporary) / "result"
|
||||
result = self._runner(adb, device).run("device-1", _TARGET_URL, _TASK_COLOR, _TASK_SIZE, target)
|
||||
|
||||
self.assertEqual(result.unit_price, "12.88")
|
||||
manifest = result.manifest_path.read_text(encoding="utf-8")
|
||||
self.assertTrue(result.screenshot_path.is_file())
|
||||
self.assertNotIn("device-1", manifest)
|
||||
self.assertNotIn("hierarchy", manifest)
|
||||
self.assertNotIn("已选", manifest)
|
||||
self.assertIn('"unit_price": "12.88"', manifest)
|
||||
self.assertIn('"selection_status": "restored"', manifest)
|
||||
self.assertIn('"panel_status": "verified"', manifest)
|
||||
self.assertIn('"safe_exit": "completed"', manifest)
|
||||
self.assertFalse((target / "hierarchy.xml").exists())
|
||||
self.assertEqual(_actions(device, "pressKey"), [("jsonrpc", "pressKey", ["back"], 10)])
|
||||
|
||||
def test_target_created_during_publish_is_preserved_without_staging_residue(self) -> None:
|
||||
with TemporaryDirectory() as temporary:
|
||||
target = Path(temporary) / "result"
|
||||
original_rename = runner_module.os.rename
|
||||
|
||||
def create_target_then_rename(source: str | Path, destination: str | Path) -> None:
|
||||
Path(destination).mkdir()
|
||||
(Path(destination) / "sentinel").write_text("keep", encoding="utf-8")
|
||||
original_rename(source, destination)
|
||||
|
||||
with patch.object(runner_module.os, "rename", side_effect=create_target_then_rename), self.assertRaises(SkuSelectionRunError):
|
||||
self._runner(_FakeAdb(), _RawDevice()).run("device-1", _TARGET_URL, _TASK_COLOR, _TASK_SIZE, target)
|
||||
self.assertEqual((target / "sentinel").read_text(encoding="utf-8"), "keep")
|
||||
self.assertEqual(list(Path(temporary).glob(".result.staging-*")), [])
|
||||
|
||||
def test_bad_screenshot_or_existing_target_never_publishes_manifest(self) -> None:
|
||||
with TemporaryDirectory() as temporary:
|
||||
target = Path(temporary) / "result"
|
||||
with self.assertRaises(SkuSelectionScreenshotError):
|
||||
self._runner(_FakeAdb(), _RawDevice(screenshot="not-image")).run("device-1", _TARGET_URL, _TASK_COLOR, _TASK_SIZE, target)
|
||||
self.assertFalse(target.exists())
|
||||
self.assertEqual(list(Path(temporary).glob(".result.staging-*")), [])
|
||||
|
||||
target = Path(temporary) / "write-failure"
|
||||
with patch.object(runner_module, "_save_base64_screenshot", side_effect=OSError("private path")):
|
||||
with self.assertRaises(SkuSelectionScreenshotError):
|
||||
self._runner(_FakeAdb(), _RawDevice()).run("device-1", _TARGET_URL, _TASK_COLOR, _TASK_SIZE, target)
|
||||
self.assertFalse(target.exists())
|
||||
self.assertEqual(list(Path(temporary).glob(".write-failure.staging-*")), [])
|
||||
|
||||
adb = _FakeAdb()
|
||||
device = _RawDevice()
|
||||
target.mkdir()
|
||||
sentinel = target / "keep"
|
||||
sentinel.write_text("keep", encoding="utf-8")
|
||||
with self.assertRaises(SkuSelectionRunError):
|
||||
self._runner(adb, device).run("device-1", _TARGET_URL, _TASK_COLOR, _TASK_SIZE, target)
|
||||
self.assertEqual(adb.calls, [])
|
||||
self.assertEqual(device.calls, [])
|
||||
self.assertEqual(sentinel.read_text(encoding="utf-8"), "keep")
|
||||
|
||||
def test_device_screen_and_output_preflight_fail_before_any_click(self) -> None:
|
||||
with TemporaryDirectory() as temporary:
|
||||
adb = _FakeAdb()
|
||||
adb.inspection = DeviceInspection(AdbDevice(serial="device-1", state="device"), "wrong", "16")
|
||||
device = _RawDevice()
|
||||
with self.assertRaises(SkuSelectionRunError):
|
||||
self._runner(adb, device).run("device-1", _TARGET_URL, _TASK_COLOR, _TASK_SIZE, Path(temporary) / "result")
|
||||
self.assertEqual(device.calls, [])
|
||||
|
||||
class WrongScreenDevice(_RawDevice):
|
||||
def window_size(self) -> tuple[int, int]:
|
||||
return 1080, 1920
|
||||
|
||||
device = WrongScreenDevice()
|
||||
with self.assertRaises(SkuSelectionRunError):
|
||||
self._runner(_FakeAdb(), device).run("device-1", _TARGET_URL, _TASK_COLOR, _TASK_SIZE, Path(temporary) / "screen")
|
||||
self.assertEqual(_actions(device, "click"), [])
|
||||
|
||||
parent_file = Path(temporary) / "not-a-directory"
|
||||
parent_file.write_text("x", encoding="utf-8")
|
||||
adb = _FakeAdb()
|
||||
device = _RawDevice()
|
||||
with self.assertRaises(SkuSelectionRunError):
|
||||
self._runner(adb, device).run("device-1", _TARGET_URL, _TASK_COLOR, _TASK_SIZE, parent_file / "result")
|
||||
self.assertEqual(adb.calls, [])
|
||||
self.assertEqual(device.calls, [])
|
||||
|
||||
def test_small_but_valid_png_is_not_accepted(self) -> None:
|
||||
image = Image.new("RGB", (1, 1), "white")
|
||||
raw = BytesIO(); image.save(raw, format="PNG")
|
||||
with TemporaryDirectory() as temporary, self.assertRaises(SkuSelectionScreenshotError):
|
||||
self._runner(_FakeAdb(), _RawDevice(screenshot=base64.b64encode(raw.getvalue()).decode("ascii"))).run(
|
||||
"device-1", _TARGET_URL, _TASK_COLOR, _TASK_SIZE, Path(temporary) / "result"
|
||||
)
|
||||
|
||||
def test_failure_after_entry_attempts_one_safe_exit_and_hides_device_detail(self) -> None:
|
||||
adb = _FakeAdb()
|
||||
device = _RawDevice()
|
||||
device.fail_color_readback = True
|
||||
device.select_alternates()
|
||||
with TemporaryDirectory() as temporary:
|
||||
with self.assertRaises(SkuSelectionError):
|
||||
self._runner(adb, device).run("device-1", _TARGET_URL, _TASK_COLOR, _TASK_SIZE, Path(temporary) / "result")
|
||||
self.assertEqual(_actions(device, "pressKey"), [])
|
||||
|
||||
class FailingRawDevice(_RawDevice):
|
||||
def app_info(self, package_name: str) -> dict[str, str]:
|
||||
raise RuntimeError("device-1 <xml>private</xml>")
|
||||
|
||||
with self.assertRaises(SkuSelectionDeviceAdapterError) as raised:
|
||||
UiautomatorSkuPanelAdapter(FailingRawDevice(), 10).app_info("com.xunmeng.pinduoduo")
|
||||
self.assertNotIn("device-1", str(raised.exception))
|
||||
self.assertNotIn("private", str(raised.exception))
|
||||
|
||||
def test_unverified_failure_never_sends_blind_back(self) -> None:
|
||||
class InvalidAfterOptionDevice(_RawDevice):
|
||||
def jsonrpc_call(self, method: str, params: object = None, timeout: float = 10) -> str:
|
||||
value = super().jsonrpc_call(method, params, timeout)
|
||||
if method == "click" and "[396,498][895,570]" in self.hierarchy:
|
||||
self.hierarchy = "<hierarchy />"
|
||||
return value
|
||||
|
||||
device = InvalidAfterOptionDevice()
|
||||
device.select_alternates()
|
||||
with TemporaryDirectory() as temporary, self.assertRaises(SkuSelectionError):
|
||||
self._runner(_FakeAdb(), device).run("device-1", _TARGET_URL, _TASK_COLOR, _TASK_SIZE, Path(temporary) / "result")
|
||||
self.assertEqual(_actions(device, "pressKey"), [])
|
||||
|
||||
def test_adapter_timeout_is_mapped_without_third_party_detail(self) -> None:
|
||||
class TimeoutRawDevice(_RawDevice):
|
||||
def jsonrpc_call(self, method: str, params: object = None, timeout: float = 10) -> str:
|
||||
raise TimeoutError("device-1 <hierarchy>private</hierarchy>")
|
||||
|
||||
with self.assertRaises(SkuSelectionRunError) as raised:
|
||||
UiautomatorSkuPanelAdapter(TimeoutRawDevice(), 10).dump_window_hierarchy()
|
||||
self.assertNotIn("device-1", str(raised.exception))
|
||||
self.assertNotIn("private", str(raised.exception))
|
||||
|
||||
def test_entry_attempt_is_recorded_before_unconfirmed_click_and_not_retried(self) -> None:
|
||||
class TimeoutTapDevice(_RawDevice):
|
||||
def jsonrpc_call(self, method: str, params: object = None, timeout: float = 10) -> str:
|
||||
self.calls.append(("jsonrpc", method, params, timeout))
|
||||
if method == "click":
|
||||
raise TimeoutError("device detail")
|
||||
return super().jsonrpc_call(method, params, timeout)
|
||||
|
||||
adapter = UiautomatorSkuPanelAdapter(TimeoutTapDevice(), 10)
|
||||
with self.assertRaises(SkuSelectionRunError):
|
||||
adapter.tap_sku_entry("[900,1312][1056,1355]")
|
||||
self.assertTrue(adapter.entry_was_tapped)
|
||||
self.assertEqual(_actions(adapter._device, "click"), [("jsonrpc", "click", [978, 1333], 10)])
|
||||
|
||||
def test_entry_stability_interruptions_never_click(self) -> None:
|
||||
now = [0.0]
|
||||
class SequenceDevice(_RawDevice):
|
||||
def __init__(self) -> None:
|
||||
super().__init__(); self.frames = [_PRODUCT_PAGE, "<hierarchy />", _PRODUCT_PAGE]
|
||||
def jsonrpc_call(self, method: str, params: object = None, timeout: float = 10) -> str:
|
||||
if method == "dumpWindowHierarchy" and self.frames:
|
||||
self.hierarchy = self.frames.pop(0)
|
||||
return super().jsonrpc_call(method, params, timeout)
|
||||
device = SequenceDevice()
|
||||
flow = SkuSelectionFlow(UiautomatorSkuPanelAdapter(device, 10), .02, .01, lambda: now[0], lambda x: now.__setitem__(0, now[0] + x))
|
||||
with self.assertRaises(SkuSelectionError): flow.open_sku_panel(_TARGET_URL, "<hierarchy />")
|
||||
self.assertEqual(_actions(device, "click"), [])
|
||||
|
||||
def test_screenshot_drift_and_foreground_drift_publish_nothing_and_never_back(self) -> None:
|
||||
for drift in ("color", "size", "price"):
|
||||
class DriftDevice(_RawDevice):
|
||||
def jsonrpc_call(self, method: str, params: object = None, timeout: float = 10) -> str:
|
||||
value = super().jsonrpc_call(method, params, timeout)
|
||||
if method == "takeScreenshot":
|
||||
if drift == "price":
|
||||
self.hierarchy = self.hierarchy.replace("快卖完 ¥12.88", "快卖完 ¥13.88")
|
||||
else:
|
||||
root = ElementTree.fromstring(self.hierarchy)
|
||||
if drift == "color":
|
||||
for node in root.iter("node"):
|
||||
if node.get("selected") is not None and ",1000]" in node.get("bounds", ""):
|
||||
node.set("selected", "false")
|
||||
next(node for node in root.iter("node") if node.get("content-desc") == "粉红").set("selected", "true")
|
||||
else:
|
||||
for node in root.iter("node"):
|
||||
if node.get("selected") is not None and ",1730]" in node.get("bounds", ""):
|
||||
node.set("selected", "false")
|
||||
next(node for node in root.iter("node") if node.get("text") == "L(建议115-130)").set("selected", "true")
|
||||
self.hierarchy = ElementTree.tostring(root, encoding="unicode")
|
||||
return value
|
||||
with self.subTest(drift=drift), TemporaryDirectory() as temporary:
|
||||
target = Path(temporary) / "out"
|
||||
with self.assertRaises((SkuSelectionError, SkuSelectionRunError)):
|
||||
self._runner(_FakeAdb(), DriftDevice()).run("device-1", _TARGET_URL, _TASK_COLOR, _TASK_SIZE, target)
|
||||
self.assertFalse(target.exists())
|
||||
self.assertFalse((target / "manifest.json").exists())
|
||||
self.assertEqual(list(Path(temporary).glob(".out.staging-*")), [])
|
||||
|
||||
device = _RawDevice(); device.select_alternates()
|
||||
device.package = "other"
|
||||
with self.assertRaises(SkuSelectionError): SkuSelectionFlow(UiautomatorSkuPanelAdapter(device, 10)).exit_sku_panel_safely()
|
||||
self.assertEqual(_actions(device, "pressKey"), [])
|
||||
|
||||
def test_screenshot_then_foreground_drift_publishes_nothing_and_never_back(self) -> None:
|
||||
class ForegroundDriftDevice(_RawDevice):
|
||||
def jsonrpc_call(self, method: str, params: object = None, timeout: float = 10) -> str:
|
||||
value = super().jsonrpc_call(method, params, timeout)
|
||||
if method == "takeScreenshot": self.package = "other"
|
||||
return value
|
||||
|
||||
device = ForegroundDriftDevice()
|
||||
with TemporaryDirectory() as temporary:
|
||||
target = Path(temporary) / "out"
|
||||
with self.assertRaises(SkuSelectionError):
|
||||
self._runner(_FakeAdb(), device).run("device-1", _TARGET_URL, _TASK_COLOR, _TASK_SIZE, target)
|
||||
self.assertFalse(target.exists())
|
||||
self.assertFalse((target / "manifest.json").exists())
|
||||
self.assertEqual(list(Path(temporary).glob(".out.staging-*")), [])
|
||||
self.assertEqual(_actions(device, "pressKey"), [])
|
||||
|
||||
def test_option_timeout_reconciliation_controls_back_once(self) -> None:
|
||||
class OptionTimeoutDevice(_RawDevice):
|
||||
def __init__(self, delivered: bool) -> None:
|
||||
super().__init__(); self.delivered = delivered; self.clicks = 0
|
||||
def jsonrpc_call(self, method: str, params: object = None, timeout: float = 10) -> str:
|
||||
if method == "click":
|
||||
self.clicks += 1
|
||||
if self.clicks == 2:
|
||||
if self.delivered: super().jsonrpc_call(method, params, timeout)
|
||||
else: self.calls.append(("jsonrpc", method, params, timeout))
|
||||
raise TimeoutError("uncertain option")
|
||||
return super().jsonrpc_call(method, params, timeout)
|
||||
|
||||
for delivered, expected_back in ((False, 0), (True, 1)):
|
||||
with self.subTest(delivered=delivered), TemporaryDirectory() as temporary:
|
||||
device = OptionTimeoutDevice(delivered); device.select_alternates()
|
||||
adb = _FakeAdb(); device.hierarchy = "<hierarchy />"
|
||||
adb.on_intent = lambda: setattr(device, "hierarchy", _PRODUCT_PAGE.replace("<hierarchy>", '<hierarchy post-intent="1">'))
|
||||
runner = SkuSelectionRunner(adb, lambda serial: device, .03)
|
||||
with self.assertRaises(SkuSelectionRunError):
|
||||
runner.run("device-1", _TARGET_URL, _TASK_COLOR, _TASK_SIZE, Path(temporary) / "out")
|
||||
self.assertEqual(len(_actions(device, "click")), 2)
|
||||
self.assertEqual(len(_actions(device, "pressKey")), expected_back)
|
||||
|
||||
def test_back_timeout_is_never_retried(self) -> None:
|
||||
class BackTimeoutDevice(_RawDevice):
|
||||
def jsonrpc_call(self, method: str, params: object = None, timeout: float = 10) -> str:
|
||||
if method == "pressKey":
|
||||
super().jsonrpc_call(method, params, timeout)
|
||||
raise TimeoutError("back uncertain")
|
||||
return super().jsonrpc_call(method, params, timeout)
|
||||
|
||||
device = BackTimeoutDevice()
|
||||
with TemporaryDirectory() as temporary, self.assertRaises(SkuSelectionRunError):
|
||||
self._runner(_FakeAdb(), device).run("device-1", _TARGET_URL, _TASK_COLOR, _TASK_SIZE, Path(temporary) / "out")
|
||||
self.assertEqual(len(_actions(device, "pressKey")), 1)
|
||||
|
||||
def test_entry_click_timeout_reconciles_only_through_verified_flow_exit(self) -> None:
|
||||
class DeliveredThenTimeout(_RawDevice):
|
||||
def jsonrpc_call(self, method: str, params: object = None, timeout: float = 10) -> str:
|
||||
if method == "click" and self.hierarchy != _FIXTURE.read_text(encoding="utf-8"):
|
||||
super().jsonrpc_call(method, params, timeout)
|
||||
raise TimeoutError("delivery uncertain")
|
||||
return super().jsonrpc_call(method, params, timeout)
|
||||
|
||||
device = DeliveredThenTimeout()
|
||||
with TemporaryDirectory() as temporary, self.assertRaises(SkuSelectionRunError):
|
||||
self._runner(_FakeAdb(), device).run("device-1", _TARGET_URL, _TASK_COLOR, _TASK_SIZE, Path(temporary) / "result")
|
||||
self.assertEqual(len(_actions(device, "click")), 1)
|
||||
self.assertEqual(len(_actions(device, "pressKey")), 1)
|
||||
|
||||
|
||||
class SkuSelectionCliTests(unittest.TestCase):
|
||||
def test_cli_accepts_only_target_url_and_task_values(self) -> None:
|
||||
script = _load_runner_script()
|
||||
valid = {
|
||||
"serial": "device-1",
|
||||
"url": _TARGET_URL,
|
||||
"color": _TASK_COLOR,
|
||||
"size": _TASK_SIZE,
|
||||
"output_dir": Path("evidence"),
|
||||
"timeout": 10.0,
|
||||
"adb": "adb",
|
||||
}
|
||||
script.validate_arguments(type("Arguments", (), valid)())
|
||||
for field, value in (("serial", ""), ("url", "https://mobile.yangkeduo.com/goods.html?goods_id=1"), ("color", "黑色 CHA (纯棉)"), ("size", "M(建议100-115)"), ("timeout", 0), ("timeout", float("inf"))):
|
||||
with self.subTest(field=field, value=value), self.assertRaises((ValueError, SkuSelectionError)):
|
||||
script.validate_arguments(type("Arguments", (), valid | {field: value})())
|
||||
|
||||
def test_cli_main_catches_flow_error_without_traceback_or_page_body(self) -> None:
|
||||
script = _load_runner_script()
|
||||
|
||||
class FlowFailingRunner:
|
||||
def __init__(self, *args: object, **kwargs: object) -> None: pass
|
||||
def run(self, *args: object, **kwargs: object) -> object:
|
||||
raise SkuSelectionError("<hierarchy>page-body</hierarchy>")
|
||||
|
||||
stderr = BytesIO()
|
||||
# TextIOWrapper keeps the assertion independent from host console encoding.
|
||||
import io
|
||||
text_stderr = io.TextIOWrapper(stderr, encoding="utf-8")
|
||||
with patch.object(script, "SkuSelectionRunner", FlowFailingRunner), redirect_stderr(text_stderr):
|
||||
status = script.main([
|
||||
"--serial", "device-1", "--url", _TARGET_URL, "--color", _TASK_COLOR,
|
||||
"--size", _TASK_SIZE, "--output-dir", "evidence",
|
||||
])
|
||||
text_stderr.flush()
|
||||
output = stderr.getvalue().decode("utf-8")
|
||||
self.assertEqual(status, 1)
|
||||
self.assertNotIn("Traceback", output)
|
||||
self.assertNotIn("page-body", output)
|
||||
|
||||
|
||||
def _load_runner_script() -> object:
|
||||
path = Path(__file__).resolve().parents[2] / "scripts" / "run_t103_sku_selection.py"
|
||||
specification = importlib.util.spec_from_file_location("run_t103_sku_selection_test", path)
|
||||
if specification is None or specification.loader is None:
|
||||
raise RuntimeError("无法加载 T-103 运行脚本。")
|
||||
module = importlib.util.module_from_spec(specification)
|
||||
specification.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user