Compare commits
59
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6145fc4468 | ||
|
|
f6cd65208d | ||
|
|
7ea1c5349f | ||
|
|
57a5b2e91c | ||
|
|
63c2c59b41 | ||
|
|
cfb238d022 | ||
|
|
31f07ac245 | ||
|
|
488005ac93 | ||
|
|
4186f1315a | ||
|
|
3a0a41db00 | ||
|
|
8600c33391 | ||
|
|
8092431208 | ||
|
|
cd47d0c959 | ||
|
|
ec42640123 | ||
|
|
5f060f60ee | ||
|
|
19f81a5e58 | ||
|
|
c1cee49fb1 | ||
|
|
772e379fd6 | ||
|
|
83b244ff7c | ||
|
|
d9a31cbfa6 | ||
|
|
014405466a | ||
|
|
8c50e1579e | ||
|
|
35d7ce11d6 | ||
|
|
41e54e313e | ||
|
|
e5de76503f | ||
|
|
542b2283f4 | ||
|
|
eb29bcd8b7 | ||
|
|
526af1eb31 | ||
|
|
d9381a644b | ||
|
|
e863a29641 | ||
|
|
5c97954fa1 | ||
|
|
bd4c855ad2 | ||
|
|
ee90d76893 | ||
|
|
4a65138fd7 | ||
|
|
8ec740dcef | ||
|
|
590c84660a | ||
|
|
233188297a | ||
|
|
e674b7f131 | ||
|
|
66355a7f89 | ||
|
|
41881e81f3 | ||
|
|
e3c87fdec5 | ||
|
|
871cd24d68 | ||
|
|
4f8e71b256 | ||
|
|
a6ad560f5d | ||
|
|
6a5547b323 | ||
|
|
46fccc2120 | ||
|
|
89648880bc | ||
|
|
4829be972c | ||
|
|
3b2fa3536e | ||
|
|
9a4d11f74b | ||
|
|
79284576ef | ||
|
|
20aaba0919 | ||
|
|
03a067e29d | ||
|
|
ce9d6ca285 | ||
|
|
44586fef38 | ||
|
|
13547728fc | ||
|
|
0b5c561ed6 | ||
|
|
5f650f18b1 | ||
|
|
b49a9b4abe |
@@ -12,6 +12,9 @@
|
||||
| `CMBUYER_AUTHORIZATION_TTL` | 一次性授权的正 Go duration,例如 `10m`。 |
|
||||
| `CMBUYER_MAX_TASK_QUANTITY` | 每条任务允许的正整数数量上限。 |
|
||||
| `CMBUYER_MAX_TOTAL_PRICE` | 每条任务允许的规范正数总价上限,例如 `999.99`。 |
|
||||
| `CMBUYER_EVIDENCE_DIR` | 内部原始截图的绝对私有目录;不得指向仓库或公开静态目录。 |
|
||||
| `CMBUYER_CLAIM_TOKEN_SECRET` | claim token 专用 32 字节密钥的 64 位小写十六进制;不得复用 session 或设备 token。 |
|
||||
| `CMBUYER_CLAIM_LEASE_TTL` | 正 Go duration,且必须严格短于 `CMBUYER_AUTHORIZATION_TTL`。 |
|
||||
|
||||
示例仅展示变量名,不提供可运行凭据:
|
||||
|
||||
@@ -24,9 +27,42 @@ $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 = '<内部截图绝对目录>'
|
||||
$env:CMBUYER_CLAIM_TOKEN_SECRET = '<64 位小写十六进制随机值>'
|
||||
$env:CMBUYER_CLAIM_LEASE_TTL = '1m'
|
||||
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")
|
||||
}
|
||||
@@ -7,12 +7,18 @@ import (
|
||||
|
||||
"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/taskclaim"
|
||||
"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 {
|
||||
@@ -35,12 +41,32 @@ func run() error {
|
||||
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
|
||||
}
|
||||
claimStore, err := taskclaim.NewStore(database, configuration.ClaimTokenSecret, configuration.ClaimLeaseTTL)
|
||||
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,
|
||||
TaskClaims: claimStore,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -2,9 +2,12 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -21,6 +24,9 @@ const (
|
||||
authorizationTTLEnv = "CMBUYER_AUTHORIZATION_TTL"
|
||||
maxTaskQuantityEnv = "CMBUYER_MAX_TASK_QUANTITY"
|
||||
maxTotalPriceEnv = "CMBUYER_MAX_TOTAL_PRICE"
|
||||
evidenceDirectoryEnv = "CMBUYER_EVIDENCE_DIR"
|
||||
claimTokenSecretEnv = "CMBUYER_CLAIM_TOKEN_SECRET"
|
||||
claimLeaseTTLEnv = "CMBUYER_CLAIM_LEASE_TTL"
|
||||
minimumSecretLength = 32
|
||||
)
|
||||
|
||||
@@ -34,6 +40,9 @@ type Config struct {
|
||||
AuthorizationTTL time.Duration
|
||||
MaxTaskQuantity int
|
||||
MaxTotalPrice string
|
||||
EvidenceDirectory string
|
||||
ClaimTokenSecret []byte
|
||||
ClaimLeaseTTL time.Duration
|
||||
}
|
||||
|
||||
// LoadFromEnv 从进程环境读取配置。错误只指出缺失或非法的变量名,绝不回显秘密。
|
||||
@@ -102,6 +111,34 @@ func Load(lookup func(string) (string, bool)) (Config, error) {
|
||||
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)
|
||||
}
|
||||
claimSecretText, err := required(lookup, claimTokenSecretEnv)
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
claimSecret, err := hex.DecodeString(claimSecretText)
|
||||
if err != nil || len(claimSecret) != 32 || hex.EncodeToString(claimSecret) != claimSecretText {
|
||||
return Config{}, fmt.Errorf("%s must be exactly 64 lowercase hexadecimal characters", claimTokenSecretEnv)
|
||||
}
|
||||
// Claim ownership, admin sessions and device authentication are separate security domains.
|
||||
// Reject both identical configuration text and identical effective key bytes.
|
||||
if claimSecretText == secret || bytes.Equal(claimSecret, []byte(secret)) {
|
||||
return Config{}, fmt.Errorf("%s must be isolated from %s", claimTokenSecretEnv, sessionSecretEnv)
|
||||
}
|
||||
claimTTLText, err := required(lookup, claimLeaseTTLEnv)
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
claimTTL, err := time.ParseDuration(claimTTLText)
|
||||
if err != nil || claimTTL <= 0 || claimTTL >= ttl {
|
||||
return Config{}, fmt.Errorf("%s must be positive and shorter than %s", claimLeaseTTLEnv, authorizationTTLEnv)
|
||||
}
|
||||
|
||||
return Config{
|
||||
AdminUsername: username,
|
||||
@@ -110,6 +147,9 @@ func Load(lookup func(string) (string, bool)) (Config, error) {
|
||||
CookieSecure: cookieSecure,
|
||||
DatabaseSource: databaseSource,
|
||||
AuthorizationTTL: ttl, MaxTaskQuantity: maxQuantity, MaxTotalPrice: maxPrice,
|
||||
EvidenceDirectory: evidenceDirectory,
|
||||
ClaimTokenSecret: claimSecret,
|
||||
ClaimLeaseTTL: claimTTL,
|
||||
}, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ package config_test
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"cmbuyer/admin/internal/config"
|
||||
|
||||
@@ -24,13 +25,16 @@ func TestLoad(t *testing.T) {
|
||||
"CMBUYER_AUTHORIZATION_TTL": "10m",
|
||||
"CMBUYER_MAX_TASK_QUANTITY": "99",
|
||||
"CMBUYER_MAX_TOTAL_PRICE": "999.99",
|
||||
"CMBUYER_EVIDENCE_DIR": t.TempDir(),
|
||||
"CMBUYER_CLAIM_TOKEN_SECRET": strings.Repeat("ab", 32),
|
||||
"CMBUYER_CLAIM_LEASE_TTL": "1m",
|
||||
}
|
||||
|
||||
got, err := config.Load(lookup(values))
|
||||
if err != nil {
|
||||
t.Fatalf("Load: %v", err)
|
||||
}
|
||||
if got.AdminUsername != "admin" || !got.CookieSecure {
|
||||
if got.AdminUsername != "admin" || !got.CookieSecure || len(got.ClaimTokenSecret) != 32 || got.ClaimLeaseTTL != time.Minute {
|
||||
t.Fatalf("Load returned unexpected public configuration: %#v", got)
|
||||
}
|
||||
}
|
||||
@@ -49,6 +53,9 @@ func TestLoadRejectsMissingOrInvalidConfiguration(t *testing.T) {
|
||||
"CMBUYER_AUTHORIZATION_TTL": "10m",
|
||||
"CMBUYER_MAX_TASK_QUANTITY": "99",
|
||||
"CMBUYER_MAX_TOTAL_PRICE": "999.99",
|
||||
"CMBUYER_EVIDENCE_DIR": t.TempDir(),
|
||||
"CMBUYER_CLAIM_TOKEN_SECRET": strings.Repeat("ab", 32),
|
||||
"CMBUYER_CLAIM_LEASE_TTL": "1m",
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
@@ -64,6 +71,14 @@ func TestLoadRejectsMissingOrInvalidConfiguration(t *testing.T) {
|
||||
{"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"},
|
||||
{"invalid claim secret", func(values map[string]string) { values["CMBUYER_CLAIM_TOKEN_SECRET"] = strings.Repeat("A", 64) }, "CMBUYER_CLAIM_TOKEN_SECRET"},
|
||||
{"claim secret same raw session secret", func(values map[string]string) {
|
||||
values["CMBUYER_SESSION_SECRET"] = values["CMBUYER_CLAIM_TOKEN_SECRET"]
|
||||
}, "CMBUYER_CLAIM_TOKEN_SECRET"},
|
||||
{"claim secret same decoded session secret", func(values map[string]string) { values["CMBUYER_SESSION_SECRET"] = strings.Repeat("\xab", 32) }, "CMBUYER_CLAIM_TOKEN_SECRET"},
|
||||
{"invalid claim lease ttl", func(values map[string]string) { values["CMBUYER_CLAIM_LEASE_TTL"] = "10m" }, "CMBUYER_CLAIM_LEASE_TTL"},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
|
||||
@@ -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,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)
|
||||
}
|
||||
@@ -26,18 +26,41 @@ func TestUpDownAndIdempotence(t *testing.T) {
|
||||
if err := migrations.Up(context, database, directory); err != nil {
|
||||
t.Fatalf("apply migrations: %v", err)
|
||||
}
|
||||
assertVersion(t, database, 2)
|
||||
assertVersion(t, database, 5)
|
||||
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, "purchase_attempt_claims", 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, 5)
|
||||
|
||||
if err := migrations.Down(context, database, directory); err != nil {
|
||||
t.Fatalf("roll back task claim migration: %v", err)
|
||||
}
|
||||
assertVersion(t, database, 4)
|
||||
assertTableExists(t, database, "purchase_attempt_claims", false)
|
||||
assertTableExists(t, database, "device_credentials", true)
|
||||
|
||||
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)
|
||||
@@ -50,7 +73,7 @@ func TestUpDownAndIdempotence(t *testing.T) {
|
||||
if err := migrations.Up(context, database, directory); err != nil {
|
||||
t.Fatalf("reapply v2 after rollback: %v", err)
|
||||
}
|
||||
assertVersion(t, database, 2)
|
||||
assertVersion(t, database, 5)
|
||||
}
|
||||
|
||||
func TestUpgradePreservesManualDraftLosslessly(t *testing.T) {
|
||||
@@ -69,7 +92,7 @@ func TestUpgradePreservesManualDraftLosslessly(t *testing.T) {
|
||||
if err := migrations.Up(context.Background(), database, migrationDirectory(t)); err != nil {
|
||||
t.Fatalf("upgrade v1 draft: %v", err)
|
||||
}
|
||||
assertVersion(t, database, 2)
|
||||
assertVersion(t, database, 5)
|
||||
var got struct {
|
||||
id, source, sourceRef, title, goodsID, color, size, maxPrice, assetID, status, created, updated string
|
||||
quantity, version int
|
||||
@@ -218,6 +241,246 @@ func TestV2SchemaConstraintsAndRelationships(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvidenceSchemaConstraintsAndDowngradeGuard(t *testing.T) {
|
||||
database := openTestDatabase(t)
|
||||
migrateToV3(t, database)
|
||||
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.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)
|
||||
migrateToV4(t, database)
|
||||
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 TestTaskClaimMigrationGuardsOwnershipConstraintsAndDowngradeFacts(t *testing.T) {
|
||||
t.Run("upgrade rejects unmappable execution facts atomically", func(t *testing.T) {
|
||||
database := openTestDatabase(t)
|
||||
migrateToV4(t, database)
|
||||
insertV2Task(t, database, "legacy-task", "MANUAL", "DRAFT")
|
||||
insertV2Authorization(t, database, "legacy-auth", "legacy-task", 1, "legacy-start")
|
||||
insertV2Attempt(t, database, "legacy-attempt", "legacy-task", "legacy-auth", 1)
|
||||
if err := migrations.Up(context.Background(), database, migrationDirectory(t)); err == nil {
|
||||
t.Fatal("v5 upgrade accepted an attempt without device/session ownership")
|
||||
}
|
||||
assertVersion(t, database, 4)
|
||||
assertTableExists(t, database, "purchase_attempt_claims", false)
|
||||
var count int
|
||||
if err := database.QueryRow("SELECT COUNT(*) FROM purchase_attempts").Scan(&count); err != nil || count != 1 {
|
||||
t.Fatalf("legacy attempt after rejected upgrade = %d, err %v", count, err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("schema binds authorization device session generation and token", func(t *testing.T) {
|
||||
database := openTestDatabase(t)
|
||||
if err := migrations.Up(context.Background(), database, migrationDirectory(t)); err != nil {
|
||||
t.Fatalf("apply migrations: %v", err)
|
||||
}
|
||||
deviceA := "13c9f507-7473-4fa6-8d71-8786c34c6301"
|
||||
deviceB := "23c9f507-7473-4fa6-8d71-8786c34c6301"
|
||||
sessionA := "33c9f507-7473-4fa6-8d71-8786c34c6301"
|
||||
sessionB := "43c9f507-7473-4fa6-8d71-8786c34c6301"
|
||||
taskA := "53c9f507-7473-4fa6-8d71-8786c34c6301"
|
||||
authA := "63c9f507-7473-4fa6-8d71-8786c34c6301"
|
||||
attemptA := "73c9f507-7473-4fa6-8d71-8786c34c6301"
|
||||
taskB := "83c9f507-7473-4fa6-8d71-8786c34c6301"
|
||||
authB := "93c9f507-7473-4fa6-8d71-8786c34c6301"
|
||||
attemptB := "a3c9f507-7473-4fa6-8d71-8786c34c6301"
|
||||
tokenA := make([]byte, 32)
|
||||
for index := range tokenA {
|
||||
tokenA[index] = byte(index + 1)
|
||||
}
|
||||
for index, device := range []string{deviceA, deviceB} {
|
||||
hash := make([]byte, 32)
|
||||
hash[0] = byte(index + 100)
|
||||
if _, err := database.Exec(`INSERT INTO device_credentials
|
||||
(device_id,display_name,token_sha256,status,created_at,revoked_at)
|
||||
VALUES (?, ?, ?, 'ACTIVE', ?, NULL)`, device, "device "+strconv.Itoa(index), hash, migrationTime); err != nil {
|
||||
t.Fatalf("insert device: %v", err)
|
||||
}
|
||||
}
|
||||
insertV2Task(t, database, taskA, "MANUAL", "DRAFT")
|
||||
insertV2Authorization(t, database, authA, taskA, 1, "start-a")
|
||||
insertV2Attempt(t, database, attemptA, taskA, authA, 1)
|
||||
insertClaim := `INSERT INTO purchase_attempt_claims
|
||||
(attempt_id,task_id,authorization_id,claimed_by_device_id,session_id,claim_generation,
|
||||
task_version,task_title,authorization_task_version,goods_id,sku_color,sku_size,quantity,
|
||||
total_price_cap,authorization_expires_at,claim_nonce,claim_token_sha256,lease_expires_at,claimed_at,closed_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, 2, 'task', 1, 'goods', 'white', 'XL', 1, '1.00',
|
||||
'2026-08-04T01:00:00Z', ?, ?, '2026-08-04T00:05:00Z', ?, NULL)`
|
||||
if _, err := database.Exec(insertClaim, attemptA, taskA, authA, deviceA, sessionA, 1, make([]byte, 32), tokenA, migrationTime); err != nil {
|
||||
t.Fatalf("insert valid claim: %v", err)
|
||||
}
|
||||
if _, err := database.Exec(`INSERT INTO purchase_attempts
|
||||
(id,task_id,authorization_id,claim_generation,status,started_at)
|
||||
VALUES ('b3c9f507-7473-4fa6-8d71-8786c34c6301', ?, ?, 2, 'CLAIMED', ?)`, taskA, authA, migrationTime); err == nil {
|
||||
t.Fatal("second attempt for one authorization succeeded")
|
||||
}
|
||||
|
||||
insertV2Task(t, database, taskB, "MANUAL", "DRAFT")
|
||||
insertV2Authorization(t, database, authB, taskB, 1, "start-b")
|
||||
insertV2Attempt(t, database, attemptB, taskB, authB, 1)
|
||||
if _, err := database.Exec(insertClaim, attemptB, taskB, authB, deviceB, sessionB, 2, make([]byte, 32), make([]byte, 32), migrationTime); err == nil {
|
||||
t.Fatal("claim with generation different from its attempt succeeded")
|
||||
}
|
||||
if _, err := database.Exec(insertClaim, attemptB, taskB, authB, deviceA, sessionB, 1, make([]byte, 32), make([]byte, 32), migrationTime); err == nil {
|
||||
t.Fatal("second open claim for one device succeeded")
|
||||
}
|
||||
|
||||
claimRequest := `INSERT INTO task_claim_requests
|
||||
(claim_request_id,device_id,session_id,outcome,attempt_id,response_lease_expires_at,error_code,created_at)
|
||||
VALUES (?, ?, ?, 'CLAIMED', ?, '2026-08-04T00:05:00Z', NULL, ?)`
|
||||
if _, err := database.Exec(claimRequest, "c3c9f507-7473-4fa6-8d71-8786c34c6301", deviceA, sessionB, attemptA, migrationTime); err == nil {
|
||||
t.Fatal("claim request with another session succeeded")
|
||||
}
|
||||
if _, err := database.Exec(claimRequest, "d3c9f507-7473-4fa6-8d71-8786c34c6301", deviceA, sessionA, attemptA, migrationTime); err != nil {
|
||||
t.Fatalf("insert bound claim request: %v", err)
|
||||
}
|
||||
renewal := `INSERT INTO purchase_attempt_lease_renewals
|
||||
(renew_request_id,task_id,attempt_id,device_id,session_id,claim_generation,
|
||||
claim_token_sha256,expected_lease_expires_at,lease_expires_at,created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, '2026-08-04T00:05:00Z', '2026-08-04T00:06:00Z', ?)`
|
||||
if _, err := database.Exec(renewal, "e3c9f507-7473-4fa6-8d71-8786c34c6301", taskA, attemptA, deviceA, sessionA, 2, tokenA, migrationTime); err == nil {
|
||||
t.Fatal("renewal with another generation succeeded")
|
||||
}
|
||||
wrongHash := append([]byte(nil), tokenA...)
|
||||
wrongHash[0] ^= 0xff
|
||||
if _, err := database.Exec(renewal, "f3c9f507-7473-4fa6-8d71-8786c34c6301", taskA, attemptA, deviceA, sessionA, 1, wrongHash, migrationTime); err == nil {
|
||||
t.Fatal("renewal with another token hash succeeded")
|
||||
}
|
||||
if err := migrations.Down(context.Background(), database, migrationDirectory(t)); err == nil {
|
||||
t.Fatal("claim-bearing schema downgraded successfully")
|
||||
}
|
||||
assertVersion(t, database, 5)
|
||||
assertTableExists(t, database, "purchase_attempt_claims", true)
|
||||
})
|
||||
|
||||
t.Run("empty request alone blocks downgrade", func(t *testing.T) {
|
||||
database := openTestDatabase(t)
|
||||
if err := migrations.Up(context.Background(), database, migrationDirectory(t)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
device := "13c9f507-7473-4fa6-8d71-8786c34c6301"
|
||||
if _, err := database.Exec(`INSERT INTO device_credentials
|
||||
(device_id,display_name,token_sha256,status,created_at,revoked_at)
|
||||
VALUES (?, 'device', ?, 'ACTIVE', ?, NULL)`, device, make([]byte, 32), migrationTime); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := database.Exec(`INSERT INTO task_claim_requests
|
||||
(claim_request_id,device_id,session_id,outcome,attempt_id,response_lease_expires_at,error_code,created_at)
|
||||
VALUES ('23c9f507-7473-4fa6-8d71-8786c34c6301', ?,
|
||||
'33c9f507-7473-4fa6-8d71-8786c34c6301', 'EMPTY', NULL, NULL, NULL, ?)`, device, migrationTime); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := migrations.Down(context.Background(), database, migrationDirectory(t)); err == nil {
|
||||
t.Fatal("EMPTY request was silently dropped by downgrade")
|
||||
}
|
||||
assertVersion(t, database, 5)
|
||||
})
|
||||
}
|
||||
|
||||
func TestDowngradeRejectsV2BusinessDataAtomically(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
@@ -244,9 +507,7 @@ func TestDowngradeRejectsV2BusinessDataAtomically(t *testing.T) {
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
database := openTestDatabase(t)
|
||||
if err := migrations.Up(context.Background(), database, migrationDirectory(t)); err != nil {
|
||||
t.Fatalf("apply migrations: %v", err)
|
||||
}
|
||||
migrateToV2(t, database)
|
||||
test.setup(t, database)
|
||||
before := v2RowCount(t, database)
|
||||
if err := migrations.Down(context.Background(), database, migrationDirectory(t)); err == nil {
|
||||
@@ -271,6 +532,35 @@ func migrateToV1(t *testing.T, database *sql.DB) {
|
||||
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 migrateToV3(t *testing.T, database *sql.DB) {
|
||||
t.Helper()
|
||||
migrateToV2(t, database)
|
||||
if err := migrations.Run(context.Background(), database, migrationDirectory(t), "up-by-one"); err != nil {
|
||||
t.Fatalf("apply v3: %v", err)
|
||||
}
|
||||
assertVersion(t, database, 3)
|
||||
}
|
||||
|
||||
func migrateToV4(t *testing.T, database *sql.DB) {
|
||||
t.Helper()
|
||||
migrateToV3(t, database)
|
||||
if err := migrations.Run(context.Background(), database, migrationDirectory(t), "up-by-one"); err != nil {
|
||||
t.Fatalf("apply v4: %v", err)
|
||||
}
|
||||
assertVersion(t, database, 4)
|
||||
}
|
||||
|
||||
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 {
|
||||
@@ -390,12 +680,14 @@ func assertColumnType(t *testing.T, database *sql.DB, table, column, want string
|
||||
}
|
||||
}
|
||||
|
||||
func TestV2MigrationSQLDoesNotDisableForeignKeys(t *testing.T) {
|
||||
contents, err := os.ReadFile(filepath.Join(migrationDirectory(t), "00002_single_pass_model.sql"))
|
||||
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 migration: %v", err)
|
||||
t.Fatalf("read %s: %v", name, err)
|
||||
}
|
||||
if strings.Contains(strings.ToUpper(string(contents)), "PRAGMA FOREIGN_KEYS = OFF") {
|
||||
t.Fatal("migration disables foreign keys")
|
||||
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,478 @@
|
||||
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)
|
||||
}
|
||||
insertEvidenceClaim(t, database, issued.DeviceID)
|
||||
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)
|
||||
insertEvidenceClaimDevice(t, database, evidenceDeviceID)
|
||||
insertEvidenceClaim(t, database, evidenceDeviceID)
|
||||
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 insertEvidenceClaimDevice(t *testing.T, database *sql.DB, deviceID string) {
|
||||
t.Helper()
|
||||
digest := sha256.Sum256([]byte("fake evidence device"))
|
||||
if _, err := database.Exec(`INSERT INTO device_credentials
|
||||
(device_id,display_name,token_sha256,status,created_at,revoked_at)
|
||||
VALUES (?, 'fake evidence device', ?, 'ACTIVE', '2026-08-04T00:00:00Z', NULL)`, deviceID, digest[:]); err != nil {
|
||||
t.Fatalf("insert evidence device: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func insertEvidenceClaim(t *testing.T, database *sql.DB, deviceID string) {
|
||||
t.Helper()
|
||||
if _, err := database.Exec(`INSERT INTO purchase_attempt_claims
|
||||
(attempt_id,task_id,authorization_id,claimed_by_device_id,session_id,claim_generation,
|
||||
task_version,task_title,authorization_task_version,goods_id,sku_color,sku_size,quantity,
|
||||
total_price_cap,authorization_expires_at,claim_nonce,claim_token_sha256,lease_expires_at,claimed_at,closed_at)
|
||||
VALUES (?, ?, ?, ?, '23c9f507-7473-4fa6-8d71-8786c34c6301', 1, 1, 'task',
|
||||
1, '123', 'black', 'M', 1, '1.00', '2026-08-04T00:00:00Z', ?, ?,
|
||||
'2026-08-04T02:00:00Z', '2026-08-04T00:00:00Z', NULL)`, evidenceAttemptID,
|
||||
evidenceTaskID, evidenceAuthID, deviceID, bytes.Repeat([]byte{1}, 32), bytes.Repeat([]byte{2}, 32)); err != nil {
|
||||
t.Fatalf("insert evidence claim: %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
|
||||
}
|
||||
@@ -14,6 +14,10 @@ import (
|
||||
"unicode/utf8"
|
||||
|
||||
"cmbuyer/admin/internal/auth"
|
||||
"cmbuyer/admin/internal/deviceauth"
|
||||
"cmbuyer/admin/internal/evidence"
|
||||
"cmbuyer/admin/internal/taskclaim"
|
||||
"cmbuyer/admin/internal/taskdetail"
|
||||
"cmbuyer/admin/internal/tasks"
|
||||
"cmbuyer/admin/internal/transport/webui"
|
||||
|
||||
@@ -30,11 +34,15 @@ type Options struct {
|
||||
AdminPasswordBcrypt string
|
||||
Sessions *auth.Manager
|
||||
Tasks tasks.Store
|
||||
TaskDetails taskdetail.Store
|
||||
Evidence evidence.Store
|
||||
DeviceAuthenticator deviceauth.Authenticator
|
||||
TaskClaims taskclaim.Service
|
||||
}
|
||||
|
||||
// NewRouter 返回当前服务范围内的完整 HTTP 路由。
|
||||
func NewRouter(options Options) (*gin.Engine, error) {
|
||||
if options.AdminUsername == "" || options.AdminPasswordBcrypt == "" || options.Sessions == nil || options.Tasks == nil {
|
||||
if options.AdminUsername == "" || options.AdminPasswordBcrypt == "" || options.Sessions == nil || options.Tasks == nil || options.TaskDetails == nil || options.Evidence == nil || options.DeviceAuthenticator == nil || options.TaskClaims == nil {
|
||||
return nil, errors.New("server authentication options are incomplete")
|
||||
}
|
||||
|
||||
@@ -46,9 +54,14 @@ func NewRouter(options Options) (*gin.Engine, error) {
|
||||
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.POST("/api/v1/tasks/claim-next", claimNext(options))
|
||||
router.POST("/api/v1/tasks/:id/lease/renew", renewLease(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())
|
||||
})
|
||||
@@ -258,6 +271,10 @@ func newTaskPage(options Options) gin.HandlerFunc {
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package server_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
@@ -11,7 +12,11 @@ import (
|
||||
"time"
|
||||
|
||||
"cmbuyer/admin/internal/auth"
|
||||
"cmbuyer/admin/internal/deviceauth"
|
||||
"cmbuyer/admin/internal/evidence"
|
||||
"cmbuyer/admin/internal/server"
|
||||
"cmbuyer/admin/internal/taskclaim"
|
||||
"cmbuyer/admin/internal/taskdetail"
|
||||
"cmbuyer/admin/internal/tasks"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
@@ -316,6 +321,10 @@ func TestTasksPageKeepsOriginalShellAndRendersFilteredWorkbench(t *testing.T) {
|
||||
`创建时间(上海)`,
|
||||
`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`,
|
||||
@@ -382,8 +391,8 @@ func TestTasksPageRerendersAccessibleFilterErrorsAndKeepsValues(t *testing.T) {
|
||||
|
||||
func TestTaskCreationRequiresAuthenticationAndCSRF(t *testing.T) {
|
||||
router, _ := newRouter(t)
|
||||
if response := serve(router, http.MethodPost, "/tasks", url.Values{}, nil); response.Code != http.StatusForbidden {
|
||||
t.Fatalf("anonymous POST /tasks = %d, want 403", response.Code)
|
||||
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 {
|
||||
@@ -481,6 +490,14 @@ func newRouter(t *testing.T) (*gin.Engine, *auth.Manager) {
|
||||
}
|
||||
|
||||
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) {
|
||||
return newRouterWithClaimService(t, store, details, evidenceStore, deviceAuthenticator, emptyTaskClaimService{})
|
||||
}
|
||||
|
||||
func newRouterWithClaimService(t *testing.T, store tasks.Store, details taskdetail.Store, evidenceStore evidence.Store, deviceAuthenticator deviceauth.Authenticator, claims taskclaim.Service) (*gin.Engine, *auth.Manager) {
|
||||
t.Helper()
|
||||
gin.SetMode(gin.TestMode)
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte("test-password"), bcrypt.MinCost)
|
||||
@@ -493,6 +510,10 @@ func newRouterWithStore(t *testing.T, store tasks.Store) (*gin.Engine, *auth.Man
|
||||
AdminPasswordBcrypt: string(hash),
|
||||
Sessions: manager,
|
||||
Tasks: store,
|
||||
TaskDetails: details,
|
||||
Evidence: evidenceStore,
|
||||
DeviceAuthenticator: deviceAuthenticator,
|
||||
TaskClaims: claims,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("NewRouter: %v", err)
|
||||
@@ -500,11 +521,41 @@ func newRouterWithStore(t *testing.T, store tasks.Store) (*gin.Engine, *auth.Man
|
||||
return router, manager
|
||||
}
|
||||
|
||||
type emptyDetailStore struct{}
|
||||
|
||||
type emptyTaskClaimService struct{}
|
||||
|
||||
func (emptyTaskClaimService) ClaimNext(context.Context, string, taskclaim.ClaimCommand) (taskclaim.ClaimResponse, bool, error) {
|
||||
return taskclaim.ClaimResponse{}, false, nil
|
||||
}
|
||||
|
||||
func (emptyTaskClaimService) Renew(context.Context, string, taskclaim.RenewCommand) (taskclaim.RenewResponse, error) {
|
||||
return taskclaim.RenewResponse{}, taskclaim.ErrNotCurrent
|
||||
}
|
||||
|
||||
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) {
|
||||
@@ -535,6 +586,7 @@ func (store *memoryStore) ListTasks(_ context.Context, _ tasks.TaskFilter) ([]ta
|
||||
return result, nil
|
||||
}
|
||||
func (store *memoryStore) StartPurchases(_ context.Context, _ tasks.StartCommand, _ string) (tasks.StartResult, error) {
|
||||
store.startCalls++
|
||||
return tasks.StartResult{}, tasks.ErrInvalidStart
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"unicode/utf8"
|
||||
|
||||
"cmbuyer/admin/internal/deviceauth"
|
||||
"cmbuyer/admin/internal/taskclaim"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
const (
|
||||
maxClaimJSONBytes = 4096
|
||||
maxClaimResponseJSONBytes = 32 * 1024
|
||||
)
|
||||
|
||||
func claimNext(options Options) gin.HandlerFunc {
|
||||
return func(context *gin.Context) {
|
||||
principal, ok := authenticateDevice(context, options)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var command taskclaim.ClaimCommand
|
||||
if !decodeClaimJSON(context, &command) {
|
||||
return
|
||||
}
|
||||
response, found, err := options.TaskClaims.ClaimNext(context.Request.Context(), principal.ID, command)
|
||||
if err != nil {
|
||||
writeTaskClaimError(context, err)
|
||||
return
|
||||
}
|
||||
if !found {
|
||||
context.Status(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
if !taskclaim.ValidClaimResponse(response) {
|
||||
context.Status(http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
encoded, err := json.Marshal(response)
|
||||
if err != nil || len(encoded) > maxClaimResponseJSONBytes {
|
||||
context.Status(http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
context.Data(http.StatusOK, "application/json; charset=utf-8", encoded)
|
||||
}
|
||||
}
|
||||
|
||||
func renewLease(options Options) gin.HandlerFunc {
|
||||
return func(context *gin.Context) {
|
||||
principal, ok := authenticateDevice(context, options)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var command taskclaim.RenewCommand
|
||||
if !decodeClaimJSON(context, &command) {
|
||||
return
|
||||
}
|
||||
command.TaskID = context.Param("id")
|
||||
response, err := options.TaskClaims.Renew(context.Request.Context(), principal.ID, command)
|
||||
if err != nil {
|
||||
writeTaskClaimError(context, err)
|
||||
return
|
||||
}
|
||||
context.JSON(http.StatusOK, response)
|
||||
}
|
||||
}
|
||||
|
||||
// Authentication precedes path interpretation, Content-Type parsing and every body read. This
|
||||
// keeps rejected devices from using parsing differences as an oracle or making the server buffer data.
|
||||
func authenticateDevice(context *gin.Context, options Options) (deviceauth.Principal, bool) {
|
||||
principal, err := options.DeviceAuthenticator.Authenticate(context.Request)
|
||||
if errors.Is(err, deviceauth.ErrUnauthenticated) {
|
||||
context.Header("WWW-Authenticate", "Bearer")
|
||||
context.Status(http.StatusUnauthorized)
|
||||
return deviceauth.Principal{}, false
|
||||
}
|
||||
if err != nil || !deviceauth.ValidDeviceID(principal.ID) {
|
||||
context.Status(http.StatusServiceUnavailable)
|
||||
return deviceauth.Principal{}, false
|
||||
}
|
||||
return principal, true
|
||||
}
|
||||
|
||||
func decodeClaimJSON(context *gin.Context, target any) bool {
|
||||
if !isJSONContentType(context.GetHeader("Content-Type")) {
|
||||
writeFixedError(context, http.StatusUnsupportedMediaType, "unsupported_media_type")
|
||||
return false
|
||||
}
|
||||
context.Request.Body = http.MaxBytesReader(context.Writer, context.Request.Body, maxClaimJSONBytes)
|
||||
raw, err := io.ReadAll(context.Request.Body)
|
||||
if err != nil {
|
||||
var tooLarge *http.MaxBytesError
|
||||
if errors.As(err, &tooLarge) {
|
||||
writeFixedError(context, http.StatusRequestEntityTooLarge, "request_too_large")
|
||||
} else {
|
||||
writeFixedError(context, http.StatusBadRequest, "invalid_request")
|
||||
}
|
||||
return false
|
||||
}
|
||||
if len(raw) == 0 || !utf8.Valid(raw) {
|
||||
writeFixedError(context, http.StatusBadRequest, "invalid_request")
|
||||
return false
|
||||
}
|
||||
if !hasUniqueTopLevelJSONFields(raw) {
|
||||
writeFixedError(context, http.StatusBadRequest, "invalid_request")
|
||||
return false
|
||||
}
|
||||
decoder := json.NewDecoder(bytes.NewReader(raw))
|
||||
decoder.DisallowUnknownFields()
|
||||
if err := decoder.Decode(target); err != nil {
|
||||
writeFixedError(context, http.StatusBadRequest, "invalid_request")
|
||||
return false
|
||||
}
|
||||
var extra any
|
||||
if err := decoder.Decode(&extra); err != io.EOF {
|
||||
writeFixedError(context, http.StatusBadRequest, "invalid_request")
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func hasUniqueTopLevelJSONFields(raw []byte) bool {
|
||||
decoder := json.NewDecoder(bytes.NewReader(raw))
|
||||
first, err := decoder.Token()
|
||||
if err != nil || first != json.Delim('{') {
|
||||
return false
|
||||
}
|
||||
seen := make(map[string]struct{})
|
||||
for decoder.More() {
|
||||
key, err := decoder.Token()
|
||||
name, ok := key.(string)
|
||||
if err != nil || !ok {
|
||||
return false
|
||||
}
|
||||
if _, duplicate := seen[name]; duplicate {
|
||||
return false
|
||||
}
|
||||
seen[name] = struct{}{}
|
||||
var value json.RawMessage
|
||||
if err := decoder.Decode(&value); err != nil {
|
||||
return false
|
||||
}
|
||||
}
|
||||
last, err := decoder.Token()
|
||||
return err == nil && last == json.Delim('}')
|
||||
}
|
||||
|
||||
func writeTaskClaimError(context *gin.Context, err error) {
|
||||
switch {
|
||||
case errors.Is(err, taskclaim.ErrInvalid):
|
||||
writeFixedError(context, http.StatusBadRequest, "invalid_request")
|
||||
case errors.Is(err, taskclaim.ErrIdempotencyConflict):
|
||||
writeFixedError(context, http.StatusConflict, "idempotency_conflict")
|
||||
case errors.Is(err, taskclaim.ErrRequiresManual):
|
||||
writeFixedError(context, http.StatusConflict, "claim_requires_manual")
|
||||
case errors.Is(err, taskclaim.ErrNotCurrent):
|
||||
writeFixedError(context, http.StatusConflict, "claim_not_current")
|
||||
case errors.Is(err, taskclaim.ErrDeviceInactive):
|
||||
context.Header("WWW-Authenticate", "Bearer")
|
||||
context.Status(http.StatusUnauthorized)
|
||||
default:
|
||||
// Storage and transaction failures are intentionally bodyless: SQL, paths and candidate
|
||||
// details are server-only and must not become a device-facing diagnostic oracle.
|
||||
context.Status(http.StatusServiceUnavailable)
|
||||
}
|
||||
}
|
||||
|
||||
func writeFixedError(context *gin.Context, status int, code string) {
|
||||
context.JSON(status, gin.H{"error": code})
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
package server_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"math"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"cmbuyer/admin/internal/deviceauth"
|
||||
"cmbuyer/admin/internal/taskclaim"
|
||||
)
|
||||
|
||||
const (
|
||||
claimDeviceID = "10000000-0000-4000-8000-000000000001"
|
||||
claimSessionID = "20000000-0000-4000-8000-000000000001"
|
||||
claimRequestID = "30000000-0000-4000-8000-000000000001"
|
||||
claimTaskID = "40000000-0000-4000-8000-000000000001"
|
||||
claimAttemptID = "50000000-0000-4000-8000-000000000001"
|
||||
claimRenewID = "60000000-0000-4000-8000-000000000001"
|
||||
)
|
||||
|
||||
func TestTaskClaimEndpointsAuthenticateBeforeBody(t *testing.T) {
|
||||
for _, authentication := range []struct {
|
||||
name string
|
||||
err error
|
||||
status int
|
||||
}{
|
||||
{"unauthenticated", deviceauth.ErrUnauthenticated, http.StatusUnauthorized},
|
||||
{"authentication storage unavailable", deviceauth.ErrUnavailable, http.StatusServiceUnavailable},
|
||||
} {
|
||||
t.Run(authentication.name, func(t *testing.T) {
|
||||
authenticator := &fakeDeviceAuthenticator{err: authentication.err}
|
||||
service := &fakeTaskClaimService{}
|
||||
router, _ := newRouterWithClaimService(t, &memoryStore{}, emptyDetailStore{}, emptyEvidenceStore{}, authenticator, service)
|
||||
for _, path := range []string{"/api/v1/tasks/claim-next", "/api/v1/tasks/" + claimTaskID + "/lease/renew"} {
|
||||
body := &poisonBody{}
|
||||
request := httptest.NewRequest(http.MethodPost, path, nil)
|
||||
request.Body = body
|
||||
response := httptest.NewRecorder()
|
||||
router.ServeHTTP(response, request)
|
||||
if response.Code != authentication.status || response.Body.Len() != 0 || body.reads != 0 || service.calls != 0 {
|
||||
t.Fatalf("%s = status %d, body %q, reads %d, calls %d", path, response.Code, response.Body.String(), body.reads, service.calls)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestClaimNextStrictJSONSuccessEmptyAndErrors(t *testing.T) {
|
||||
authenticator := &fakeDeviceAuthenticator{principal: deviceauth.Principal{ID: claimDeviceID}}
|
||||
service := &fakeTaskClaimService{claimResponse: taskclaim.ClaimResponse{
|
||||
Task: taskclaim.ClaimedTask{ID: claimTaskID, Version: 3, Title: "测试", ProductURL: "https://mobile.yangkeduo.com/goods.html?goods_id=1", GoodsID: "1", SKUColor: "黑色", SKUSize: "M", Quantity: 1, MaxTotalPrice: "1.00"},
|
||||
Authorization: taskclaim.ClaimedAuthorization{ID: "70000000-0000-4000-8000-000000000001", TaskVersion: 2, ExpiresAt: "2026-08-04T01:10:00Z"},
|
||||
Attempt: taskclaim.ClaimedAttempt{ID: claimAttemptID, ClaimToken: strings.Repeat("a", 64), ClaimGeneration: 1, LeaseExpiresAt: "2026-08-04T01:03:00Z"},
|
||||
}, claimFound: true}
|
||||
router, _ := newRouterWithClaimService(t, &memoryStore{}, emptyDetailStore{}, emptyEvidenceStore{}, authenticator, service)
|
||||
valid := `{"session_id":"` + claimSessionID + `","claim_request_id":"` + claimRequestID + `"}`
|
||||
|
||||
response := serveClaimJSON(router, "/api/v1/tasks/claim-next", valid, "application/json; charset=utf-8")
|
||||
if response.Code != http.StatusOK || !strings.Contains(response.Body.String(), strings.Repeat("a", 64)) || service.claimCommand.ClaimRequestID != claimRequestID {
|
||||
t.Fatalf("claim success = %d %q command %#v", response.Code, response.Body.String(), service.claimCommand)
|
||||
}
|
||||
service.claimFound = false
|
||||
response = serveClaimJSON(router, "/api/v1/tasks/claim-next", valid, "application/json")
|
||||
if response.Code != http.StatusNoContent || response.Body.Len() != 0 {
|
||||
t.Fatalf("claim empty = %d %q", response.Code, response.Body.String())
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name, body, contentType, code string
|
||||
status int
|
||||
}{
|
||||
{"unsupported type", valid, "text/plain", "unsupported_media_type", http.StatusUnsupportedMediaType},
|
||||
{"unknown field", strings.TrimSuffix(valid, "}") + `,"device_id":"` + claimDeviceID + `"}`, "application/json", "invalid_request", http.StatusBadRequest},
|
||||
{"duplicate session", `{"session_id":"` + claimSessionID + `","session_id":"` + claimSessionID + `","claim_request_id":"` + claimRequestID + `"}`, "application/json", "invalid_request", http.StatusBadRequest},
|
||||
{"duplicate request", `{"session_id":"` + claimSessionID + `","claim_request_id":"` + claimRequestID + `","claim_request_id":"` + claimRequestID + `"}`, "application/json", "invalid_request", http.StatusBadRequest},
|
||||
{"extra json", valid + `{}`, "application/json", "invalid_request", http.StatusBadRequest},
|
||||
{"too large", strings.Repeat(" ", 4097), "application/json", "request_too_large", http.StatusRequestEntityTooLarge},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
response := serveClaimJSON(router, "/api/v1/tasks/claim-next", test.body, test.contentType)
|
||||
if response.Code != test.status || response.Body.String() != `{"error":"`+test.code+`"}` {
|
||||
t.Fatalf("response = %d %q", response.Code, response.Body.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
invalidUTF8 := httptest.NewRequest(http.MethodPost, "/api/v1/tasks/claim-next", bytes.NewReader([]byte{'{', 0xff, '}'}))
|
||||
invalidUTF8.Header.Set("Content-Type", "application/json")
|
||||
invalidResponse := httptest.NewRecorder()
|
||||
router.ServeHTTP(invalidResponse, invalidUTF8)
|
||||
if invalidResponse.Code != http.StatusBadRequest || invalidResponse.Body.String() != `{"error":"invalid_request"}` {
|
||||
t.Fatalf("invalid UTF-8 = %d %q", invalidResponse.Code, invalidResponse.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestClaimResponseWorstLegalFieldsStayBelowCapAndInvalidServiceOutputFailsClosed(t *testing.T) {
|
||||
goodsID := strings.Repeat("1", 32)
|
||||
worst := taskclaim.ClaimResponse{
|
||||
Task: taskclaim.ClaimedTask{
|
||||
ID: claimTaskID, Version: math.MaxInt, Title: strings.Repeat("<", 120),
|
||||
ProductURL: "https://mobile.yangkeduo.com/goods.html?goods_id=" + goodsID,
|
||||
GoodsID: goodsID, SKUColor: strings.Repeat("<", 80), SKUSize: strings.Repeat("<", 80),
|
||||
Quantity: 9_223_372_036_854_775_807, MaxTotalPrice: strings.Repeat("9", 29) + ".00",
|
||||
},
|
||||
Authorization: taskclaim.ClaimedAuthorization{ID: "70000000-0000-4000-8000-000000000001", TaskVersion: math.MaxInt - 1, ExpiresAt: "9999-12-31T23:59:59.999999999Z"},
|
||||
Attempt: taskclaim.ClaimedAttempt{ID: claimAttemptID, ClaimToken: strings.Repeat("a", 64), ClaimGeneration: 9_223_372_036_854_775_807, LeaseExpiresAt: "9999-12-31T23:59:59.999999999Z"},
|
||||
}
|
||||
authenticator := &fakeDeviceAuthenticator{principal: deviceauth.Principal{ID: claimDeviceID}}
|
||||
service := &fakeTaskClaimService{claimResponse: worst, claimFound: true}
|
||||
router, _ := newRouterWithClaimService(t, &memoryStore{}, emptyDetailStore{}, emptyEvidenceStore{}, authenticator, service)
|
||||
request := `{"session_id":"` + claimSessionID + `","claim_request_id":"` + claimRequestID + `"}`
|
||||
response := serveClaimJSON(router, "/api/v1/tasks/claim-next", request, "application/json")
|
||||
if response.Code != http.StatusOK || !json.Valid(response.Body.Bytes()) || response.Body.Len() >= 32*1024 {
|
||||
t.Fatalf("worst legal response = status %d, bytes %d, valid JSON %v", response.Code, response.Body.Len(), json.Valid(response.Body.Bytes()))
|
||||
}
|
||||
|
||||
mutations := map[string]func(*taskclaim.ClaimResponse){
|
||||
"invalid utf8 title": func(response *taskclaim.ClaimResponse) { response.Task.Title = string([]byte{0xff}) },
|
||||
"c0 separator title": func(response *taskclaim.ClaimResponse) { response.Task.Title = "visible\u001dhidden" },
|
||||
"overlong title": func(response *taskclaim.ClaimResponse) { response.Task.Title += "<" },
|
||||
"overlong goods id": func(response *taskclaim.ClaimResponse) {
|
||||
response.Task.GoodsID += "1"
|
||||
response.Task.ProductURL += "1"
|
||||
},
|
||||
"overlong color": func(response *taskclaim.ClaimResponse) { response.Task.SKUColor += "<" },
|
||||
"overlong size": func(response *taskclaim.ClaimResponse) { response.Task.SKUSize += "<" },
|
||||
"overlong money": func(response *taskclaim.ClaimResponse) { response.Task.MaxTotalPrice = strings.Repeat("9", 30) + ".00" },
|
||||
}
|
||||
for name, mutate := range mutations {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
invalid := worst
|
||||
mutate(&invalid)
|
||||
service := &fakeTaskClaimService{claimResponse: invalid, claimFound: true}
|
||||
router, _ := newRouterWithClaimService(t, &memoryStore{}, emptyDetailStore{}, emptyEvidenceStore{}, authenticator, service)
|
||||
response := serveClaimJSON(router, "/api/v1/tasks/claim-next", request, "application/json")
|
||||
if response.Code != http.StatusServiceUnavailable || response.Body.Len() != 0 {
|
||||
t.Fatalf("invalid service response = %d %q", response.Code, response.Body.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenewStrictBindingResponseAndFixedErrors(t *testing.T) {
|
||||
authenticator := &fakeDeviceAuthenticator{principal: deviceauth.Principal{ID: claimDeviceID}}
|
||||
service := &fakeTaskClaimService{renewResponse: taskclaim.RenewResponse{TaskID: claimTaskID, AttemptID: claimAttemptID, ClaimGeneration: 1, LeaseExpiresAt: "2026-08-04T01:04:00Z"}}
|
||||
router, _ := newRouterWithClaimService(t, &memoryStore{}, emptyDetailStore{}, emptyEvidenceStore{}, authenticator, service)
|
||||
body := `{"renew_request_id":"` + claimRenewID + `","session_id":"` + claimSessionID + `","attempt_id":"` + claimAttemptID + `","claim_generation":1,"claim_token":"` + strings.Repeat("a", 64) + `","expected_lease_expires_at":"2026-08-04T01:03:00Z"}`
|
||||
response := serveClaimJSON(router, "/api/v1/tasks/"+claimTaskID+"/lease/renew", body, "application/json")
|
||||
if response.Code != http.StatusOK || strings.Contains(response.Body.String(), "claim_token") || service.renewCommand.TaskID != claimTaskID {
|
||||
t.Fatalf("renew response = %d %q command %#v", response.Code, response.Body.String(), service.renewCommand)
|
||||
}
|
||||
|
||||
duplicateToken := strings.Replace(body, `"expected_lease_expires_at"`, `"claim_token":"`+strings.Repeat("a", 64)+`","expected_lease_expires_at"`, 1)
|
||||
response = serveClaimJSON(router, "/api/v1/tasks/"+claimTaskID+"/lease/renew", duplicateToken, "application/json")
|
||||
if response.Code != http.StatusBadRequest {
|
||||
t.Fatalf("duplicate token status = %d", response.Code)
|
||||
}
|
||||
|
||||
errorsToCodes := []struct {
|
||||
err error
|
||||
status int
|
||||
body string
|
||||
}{
|
||||
{taskclaim.ErrIdempotencyConflict, http.StatusConflict, `{"error":"idempotency_conflict"}`},
|
||||
{taskclaim.ErrRequiresManual, http.StatusConflict, `{"error":"claim_requires_manual"}`},
|
||||
{taskclaim.ErrNotCurrent, http.StatusConflict, `{"error":"claim_not_current"}`},
|
||||
{taskclaim.ErrDeviceInactive, http.StatusUnauthorized, ""},
|
||||
{errors.New("database path and SQL must stay private"), http.StatusServiceUnavailable, ""},
|
||||
}
|
||||
for _, test := range errorsToCodes {
|
||||
service.renewErr = test.err
|
||||
response = serveClaimJSON(router, "/api/v1/tasks/"+claimTaskID+"/lease/renew", body, "application/json")
|
||||
if response.Code != test.status || response.Body.String() != test.body {
|
||||
t.Fatalf("error %v = %d %q", test.err, response.Code, response.Body.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type fakeTaskClaimService struct {
|
||||
claimResponse taskclaim.ClaimResponse
|
||||
claimFound bool
|
||||
claimErr error
|
||||
renewResponse taskclaim.RenewResponse
|
||||
renewErr error
|
||||
claimCommand taskclaim.ClaimCommand
|
||||
renewCommand taskclaim.RenewCommand
|
||||
calls int
|
||||
}
|
||||
|
||||
func (service *fakeTaskClaimService) ClaimNext(_ context.Context, _ string, command taskclaim.ClaimCommand) (taskclaim.ClaimResponse, bool, error) {
|
||||
service.calls++
|
||||
service.claimCommand = command
|
||||
return service.claimResponse, service.claimFound, service.claimErr
|
||||
}
|
||||
|
||||
func (service *fakeTaskClaimService) Renew(_ context.Context, _ string, command taskclaim.RenewCommand) (taskclaim.RenewResponse, error) {
|
||||
service.calls++
|
||||
service.renewCommand = command
|
||||
return service.renewResponse, service.renewErr
|
||||
}
|
||||
|
||||
func serveClaimJSON(router http.Handler, path, body, contentType string) *httptest.ResponseRecorder {
|
||||
request := httptest.NewRequest(http.MethodPost, path, io.NopCloser(strings.NewReader(body)))
|
||||
request.Header.Set("Content-Type", contentType)
|
||||
response := httptest.NewRecorder()
|
||||
router.ServeHTTP(response, request)
|
||||
return response
|
||||
}
|
||||
@@ -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,550 @@
|
||||
// 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 ownedClaimCount int
|
||||
if err := transaction.QueryRowContext(ctx, `SELECT COUNT(*) FROM purchase_attempt_claims
|
||||
WHERE task_id = ? AND attempt_id = ? AND claimed_by_device_id = ? AND closed_at IS NULL`,
|
||||
metadata.TaskID, metadata.AttemptID, principal.ID).Scan(&ownedClaimCount); err != nil {
|
||||
return core.Asset{}, false, err
|
||||
}
|
||||
// Evidence is auditable only when the authenticated device owns the current attempt. The
|
||||
// idempotent asset lookup above deliberately remains first so closing a claim later cannot
|
||||
// destroy stable replay of an already committed screenshot.
|
||||
if ownedClaimCount != 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,604 @@
|
||||
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 TestCommitRequiresCurrentClaimOwnerButClosedClaimKeepsHistoricalReplay(t *testing.T) {
|
||||
database, store := newTestStore(t)
|
||||
insertAttemptFixture(t, database)
|
||||
pngBytes := makePNG(t, 4, 3)
|
||||
metadata := testMetadata(sha256Hex(pngBytes))
|
||||
stage := func() core.StagedFile {
|
||||
staged, err := store.Stage(bytes.NewReader(pngBytes), core.PNGContentType)
|
||||
if err != nil {
|
||||
t.Fatalf("Stage: %v", err)
|
||||
}
|
||||
return staged
|
||||
}
|
||||
otherDevice := deviceauth.Principal{ID: "73c9f507-7473-4fa6-8d71-8786c34c6301"}
|
||||
if _, _, err := store.Commit(context.Background(), otherDevice, metadata, stage()); !errors.Is(err, core.ErrInvalid) {
|
||||
t.Fatalf("device B upload to device A attempt error = %v", err)
|
||||
}
|
||||
principal := deviceauth.Principal{ID: testDeviceID}
|
||||
asset, replayed, err := store.Commit(context.Background(), principal, metadata, stage())
|
||||
if err != nil || replayed {
|
||||
t.Fatalf("owner first Commit = replayed %v, err %v", replayed, err)
|
||||
}
|
||||
if _, err := database.Exec("UPDATE purchase_attempt_claims SET closed_at='2026-08-04T03:00:00Z' WHERE attempt_id=?", testAttemptID); err != nil {
|
||||
t.Fatalf("close claim: %v", err)
|
||||
}
|
||||
replayedAsset, replayed, err := store.Commit(context.Background(), principal, metadata, stage())
|
||||
if err != nil || !replayed || replayedAsset.ID != asset.ID {
|
||||
t.Fatalf("closed claim historical replay = %#v replayed %v err %v", replayedAsset, replayed, err)
|
||||
}
|
||||
newMetadata := metadata
|
||||
newMetadata.UploadKey = "83c9f507-7473-4fa6-8d71-8786c34c6301"
|
||||
if _, _, err := store.Commit(context.Background(), principal, newMetadata, stage()); !errors.Is(err, core.ErrInvalid) {
|
||||
t.Fatalf("closed claim new upload 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"
|
||||
tokenHash := sha256.Sum256([]byte("evidence-device-token"))
|
||||
if _, err := database.Exec(`INSERT INTO device_credentials
|
||||
(device_id,display_name,token_sha256,status,created_at,revoked_at)
|
||||
VALUES (?, 'evidence device', ?, 'ACTIVE', ?, NULL)`, testDeviceID, tokenHash[:], timestamp); err != nil {
|
||||
t.Fatalf("insert device: %v", 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 (?, '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)
|
||||
}
|
||||
if _, err := database.Exec(`INSERT INTO purchase_attempt_claims
|
||||
(attempt_id,task_id,authorization_id,claimed_by_device_id,session_id,claim_generation,
|
||||
task_version,task_title,authorization_task_version,goods_id,sku_color,sku_size,quantity,
|
||||
total_price_cap,authorization_expires_at,claim_nonce,claim_token_sha256,lease_expires_at,claimed_at,closed_at)
|
||||
VALUES (?, ?, ?, ?, '63c9f507-7473-4fa6-8d71-8786c34c6301', 1, 1, 'task',
|
||||
1, '123', 'black', 'M', 1, '1.00', ?, ?, ?, '2026-08-04T02:00:00Z', ?, NULL)`,
|
||||
testAttemptID, testTaskID, testAuthID, testDeviceID, timestamp, bytes.Repeat([]byte{1}, 32), bytes.Repeat([]byte{2}, 32), timestamp); err != nil {
|
||||
t.Fatalf("insert claim: %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[:])
|
||||
}
|
||||
@@ -0,0 +1,823 @@
|
||||
package taskclaim
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"database/sql"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"math"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"cmbuyer/admin/internal/deviceauth"
|
||||
taskmodel "cmbuyer/admin/internal/tasks"
|
||||
)
|
||||
|
||||
const writeTimeout = 2 * time.Second
|
||||
|
||||
type Store struct {
|
||||
database *sql.DB
|
||||
secret []byte
|
||||
leaseTTL time.Duration
|
||||
now func() time.Time
|
||||
random io.Reader
|
||||
randomMu sync.Mutex
|
||||
writeGate chan struct{}
|
||||
// The unexported linearization hooks let package tests coordinate real SQLite
|
||||
// transactions at the first write. Production construction always leaves them nil.
|
||||
beforeLinearization func()
|
||||
afterLinearization func()
|
||||
}
|
||||
|
||||
func NewStore(database *sql.DB, secret []byte, leaseTTL time.Duration) (*Store, error) {
|
||||
if database == nil {
|
||||
return nil, errors.New("task claim database is required")
|
||||
}
|
||||
if len(secret) != sha256.Size {
|
||||
return nil, errors.New("task claim secret must be 32 bytes")
|
||||
}
|
||||
if leaseTTL <= 0 {
|
||||
return nil, errors.New("task claim lease TTL must be positive")
|
||||
}
|
||||
if _, err := database.Exec("SELECT attempt_id, claim_nonce, claim_token_sha256 FROM purchase_attempt_claims LIMIT 1"); err != nil {
|
||||
return nil, errors.New("task claim migration is not available")
|
||||
}
|
||||
store := &Store{
|
||||
database: database, secret: append([]byte(nil), secret...), leaseTTL: leaseTTL,
|
||||
now: time.Now, random: rand.Reader, writeGate: make(chan struct{}, 1),
|
||||
}
|
||||
if err := store.validateSecretIsolation(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := store.validateStoredClaims(context.Background()); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return store, nil
|
||||
}
|
||||
|
||||
// validateSecretIsolation ensures the HMAC key cannot also authenticate a device. The session
|
||||
// secret comparison is performed while parsing configuration, before either secret is discarded.
|
||||
func (store *Store) validateSecretIsolation() error {
|
||||
digest := sha256.Sum256(store.secret)
|
||||
var count int
|
||||
if err := store.database.QueryRow(`SELECT COUNT(*) FROM device_credentials WHERE token_sha256 = ?`, digest[:]).Scan(&count); err != nil {
|
||||
return errors.New("validate task claim secret isolation")
|
||||
}
|
||||
if count != 0 {
|
||||
return errors.New("task claim secret must be isolated from device credentials")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateStoredClaims covers open and closed claims. Replacing the secret must fail startup;
|
||||
// silently signing a new token would destroy idempotent recovery and the ownership audit chain.
|
||||
func (store *Store) validateStoredClaims(ctx context.Context) error {
|
||||
rows, err := store.database.QueryContext(ctx, `SELECT claims.claimed_by_device_id, claims.task_id, claims.authorization_id,
|
||||
claims.attempt_id, claims.claim_generation, claims.claim_nonce, typeof(claims.claim_nonce), length(claims.claim_nonce),
|
||||
claims.claim_token_sha256, typeof(claims.claim_token_sha256), length(claims.claim_token_sha256),
|
||||
claims.task_title, claims.authorization_task_version, claims.goods_id, claims.sku_color, claims.sku_size,
|
||||
claims.quantity, claims.total_price_cap, claims.authorization_expires_at, claims.closed_at,
|
||||
attempts.claim_generation, attempts.status, authorizations.status, tasks.status
|
||||
FROM purchase_attempt_claims AS claims
|
||||
LEFT JOIN purchase_attempts AS attempts ON attempts.id = claims.attempt_id
|
||||
LEFT JOIN order_authorizations AS authorizations ON authorizations.id = claims.authorization_id
|
||||
LEFT JOIN tasks ON tasks.id = claims.task_id
|
||||
ORDER BY claims.attempt_id`)
|
||||
if err != nil {
|
||||
return errors.New("validate stored task claims")
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var deviceID, taskID, authorizationID, attemptID string
|
||||
var generation, authorizationTaskVersion, quantity int
|
||||
var nonce, storedHash []byte
|
||||
var nonceType, hashType, title, goodsID, color, size, price, expires string
|
||||
var nonceLength, hashLength int
|
||||
var closed, attemptStatus, authorizationStatus, taskStatus sql.NullString
|
||||
var attemptGeneration sql.NullInt64
|
||||
if err := rows.Scan(&deviceID, &taskID, &authorizationID, &attemptID, &generation,
|
||||
&nonce, &nonceType, &nonceLength, &storedHash, &hashType, &hashLength,
|
||||
&title, &authorizationTaskVersion, &goodsID, &color, &size, &quantity, &price, &expires, &closed,
|
||||
&attemptGeneration, &attemptStatus, &authorizationStatus, &taskStatus); err != nil {
|
||||
return errors.New("validate stored task claims")
|
||||
}
|
||||
if !deviceauth.ValidDeviceID(deviceID) || !validUUID(taskID) || !validUUID(authorizationID) || !validUUID(attemptID) ||
|
||||
generation <= 0 || nonceType != "blob" || nonceLength != sha256.Size || len(nonce) != sha256.Size ||
|
||||
hashType != "blob" || hashLength != sha256.Size || len(storedHash) != sha256.Size ||
|
||||
authorizationTaskVersion <= 0 || !taskmodel.ValidTaskWireFields(title, goodsID, color, size, price) || quantity <= 0 ||
|
||||
!validCanonicalTime(expires) || (closed.Valid && !validCanonicalTime(closed.String)) ||
|
||||
!attemptGeneration.Valid || attemptGeneration.Int64 != int64(generation) ||
|
||||
!validAttemptStatus(attemptStatus) || !validAuthorizationStatus(authorizationStatus) || !validTaskStatus(taskStatus) {
|
||||
return errors.New("stored task claim metadata is invalid")
|
||||
}
|
||||
token := deriveToken(store.secret, deviceID, taskID, authorizationID, attemptID, generation, nonce)
|
||||
if !matchingHash(tokenHash(token), storedHash) {
|
||||
return errors.New("task claim secret does not match stored claims")
|
||||
}
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return errors.New("validate stored task claims")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (store *Store) ClaimNext(ctx context.Context, deviceID string, command ClaimCommand) (ClaimResponse, bool, error) {
|
||||
if !deviceauth.ValidDeviceID(deviceID) || !validUUID(command.SessionID) || !validUUID(command.ClaimRequestID) {
|
||||
return ClaimResponse{}, false, ErrInvalid
|
||||
}
|
||||
writeCtx, cancel := context.WithTimeout(ctx, writeTimeout)
|
||||
defer cancel()
|
||||
select {
|
||||
case store.writeGate <- struct{}{}:
|
||||
defer func() { <-store.writeGate }()
|
||||
case <-writeCtx.Done():
|
||||
return ClaimResponse{}, false, writeCtx.Err()
|
||||
}
|
||||
|
||||
transaction, err := store.database.BeginTx(writeCtx, nil)
|
||||
if err != nil {
|
||||
return ClaimResponse{}, false, err
|
||||
}
|
||||
defer transaction.Rollback()
|
||||
|
||||
// This must be the transaction's first database statement. The no-op conditional UPDATE takes
|
||||
// SQLite's write position and linearizes a concurrent credential revocation before any replay,
|
||||
// EMPTY response, conflict response, candidate read, or other business write is possible.
|
||||
if store.beforeLinearization != nil {
|
||||
store.beforeLinearization()
|
||||
}
|
||||
active, err := transaction.ExecContext(writeCtx, `UPDATE device_credentials SET status = status
|
||||
WHERE device_id = ? AND status = 'ACTIVE' AND revoked_at IS NULL`, deviceID)
|
||||
if err != nil {
|
||||
return ClaimResponse{}, false, err
|
||||
}
|
||||
if ok, err := exactlyOne(active); err != nil {
|
||||
return ClaimResponse{}, false, err
|
||||
} else if !ok {
|
||||
return ClaimResponse{}, false, ErrDeviceInactive
|
||||
}
|
||||
if store.afterLinearization != nil {
|
||||
store.afterLinearization()
|
||||
}
|
||||
|
||||
now, err := store.serverNow()
|
||||
if err != nil {
|
||||
return ClaimResponse{}, false, err
|
||||
}
|
||||
request, found, err := findClaimRequest(writeCtx, transaction, command.ClaimRequestID)
|
||||
if err != nil {
|
||||
return ClaimResponse{}, false, err
|
||||
}
|
||||
if found {
|
||||
if request.DeviceID != deviceID || request.SessionID != command.SessionID {
|
||||
return ClaimResponse{}, false, ErrIdempotencyConflict
|
||||
}
|
||||
switch request.Outcome {
|
||||
case "EMPTY":
|
||||
if err := transaction.Commit(); err != nil {
|
||||
return ClaimResponse{}, false, err
|
||||
}
|
||||
return ClaimResponse{}, false, nil
|
||||
case "BLOCKED":
|
||||
if err := transaction.Commit(); err != nil {
|
||||
return ClaimResponse{}, false, err
|
||||
}
|
||||
return ClaimResponse{}, false, ErrRequiresManual
|
||||
case "CLAIMED":
|
||||
record, found, err := store.loadClaimByAttempt(writeCtx, transaction, request.AttemptID)
|
||||
if err != nil || !found {
|
||||
if err == nil {
|
||||
err = errors.New("stored claim request has no claim")
|
||||
}
|
||||
return ClaimResponse{}, false, err
|
||||
}
|
||||
response, err := store.responseFor(record, request.ResponseLeaseExpiresAt)
|
||||
if err != nil {
|
||||
return ClaimResponse{}, false, err
|
||||
}
|
||||
if err := transaction.Commit(); err != nil {
|
||||
return ClaimResponse{}, false, err
|
||||
}
|
||||
return response, true, nil
|
||||
default:
|
||||
return ClaimResponse{}, false, errors.New("stored claim request outcome is invalid")
|
||||
}
|
||||
}
|
||||
|
||||
existing, found, err := store.loadOpenClaimByDevice(writeCtx, transaction, deviceID)
|
||||
if err != nil {
|
||||
return ClaimResponse{}, false, err
|
||||
}
|
||||
if found {
|
||||
current := existing.SessionID == command.SessionID && existing.ClosedAt == "" &&
|
||||
existing.LeaseExpiresAt.After(now) && existing.AuthorizationExpiresAt.After(now) &&
|
||||
existing.CurrentAuthorizationExpiresAt.After(now) && existing.AuthorizationStatus == "CLAIMED" &&
|
||||
existing.authorizationConsistent() && existing.recoverableBusinessState()
|
||||
if !current {
|
||||
if err := insertClaimRequest(writeCtx, transaction, command.ClaimRequestID, deviceID, command.SessionID, "BLOCKED", "", "", "manual_recovery_required", now); err != nil {
|
||||
return ClaimResponse{}, false, err
|
||||
}
|
||||
if err := transaction.Commit(); err != nil {
|
||||
return ClaimResponse{}, false, err
|
||||
}
|
||||
return ClaimResponse{}, false, ErrRequiresManual
|
||||
}
|
||||
response, err := store.responseFor(existing, existing.LeaseExpiresText)
|
||||
if err != nil {
|
||||
return ClaimResponse{}, false, err
|
||||
}
|
||||
if err := insertClaimRequest(writeCtx, transaction, command.ClaimRequestID, deviceID, command.SessionID, "CLAIMED", existing.AttemptID, existing.LeaseExpiresText, "", now); err != nil {
|
||||
return ClaimResponse{}, false, err
|
||||
}
|
||||
if err := transaction.Commit(); err != nil {
|
||||
return ClaimResponse{}, false, err
|
||||
}
|
||||
return response, true, nil
|
||||
}
|
||||
|
||||
candidate, found, err := findCandidate(writeCtx, transaction, now)
|
||||
if err != nil {
|
||||
return ClaimResponse{}, false, err
|
||||
}
|
||||
if !found {
|
||||
if err := insertClaimRequest(writeCtx, transaction, command.ClaimRequestID, deviceID, command.SessionID, "EMPTY", "", "", "", now); err != nil {
|
||||
return ClaimResponse{}, false, err
|
||||
}
|
||||
if err := transaction.Commit(); err != nil {
|
||||
return ClaimResponse{}, false, err
|
||||
}
|
||||
return ClaimResponse{}, false, nil
|
||||
}
|
||||
|
||||
generation, err := nextGeneration(writeCtx, transaction, candidate.TaskID)
|
||||
if err != nil {
|
||||
return ClaimResponse{}, false, err
|
||||
}
|
||||
attemptID, err := store.newUUID()
|
||||
if err != nil {
|
||||
return ClaimResponse{}, false, err
|
||||
}
|
||||
nonce, err := store.randomBytes(sha256.Size)
|
||||
if err != nil {
|
||||
return ClaimResponse{}, false, err
|
||||
}
|
||||
token := deriveToken(store.secret, deviceID, candidate.TaskID, candidate.AuthorizationID, attemptID, generation, nonce)
|
||||
storedTokenHash := tokenHash(token)
|
||||
leaseExpires := now.Add(store.leaseTTL)
|
||||
if candidate.AuthorizationExpiresAt.Before(leaseExpires) {
|
||||
leaseExpires = candidate.AuthorizationExpiresAt
|
||||
}
|
||||
leaseText := formatTime(leaseExpires)
|
||||
nowText := formatTime(now)
|
||||
|
||||
authorizationUpdate, err := transaction.ExecContext(writeCtx, `UPDATE order_authorizations SET status = 'CLAIMED'
|
||||
WHERE id = ? AND task_id = ? AND status = 'ACTIVE' AND task_version = ?
|
||||
AND goods_id = ? AND sku_color = ? AND sku_size = ? AND quantity = ?
|
||||
AND total_price_cap = ? AND expires_at = ?`,
|
||||
candidate.AuthorizationID, candidate.TaskID, candidate.TaskVersion, candidate.GoodsID,
|
||||
candidate.SKUColor, candidate.SKUSize, candidate.Quantity, candidate.TotalPriceCap,
|
||||
candidate.AuthorizationExpiresText)
|
||||
if err != nil {
|
||||
return ClaimResponse{}, false, err
|
||||
}
|
||||
if ok, err := exactlyOne(authorizationUpdate); err != nil || !ok {
|
||||
if err == nil {
|
||||
err = errors.New("authorization changed during claim")
|
||||
}
|
||||
return ClaimResponse{}, false, err
|
||||
}
|
||||
taskUpdate, err := transaction.ExecContext(writeCtx, `UPDATE tasks SET status = 'CLAIMED', version = version + 1, updated_at = ?
|
||||
WHERE id = ? AND status = 'PENDING' AND version = ? AND title = ? AND goods_id = ?
|
||||
AND sku_color = ? AND sku_size = ? AND quantity = ? AND max_total_price = ?`,
|
||||
nowText, candidate.TaskID, candidate.TaskVersion, candidate.Title, candidate.GoodsID,
|
||||
candidate.SKUColor, candidate.SKUSize, candidate.Quantity, candidate.TotalPriceCap)
|
||||
if err != nil {
|
||||
return ClaimResponse{}, false, err
|
||||
}
|
||||
if ok, err := exactlyOne(taskUpdate); err != nil || !ok {
|
||||
if err == nil {
|
||||
err = errors.New("task changed during claim")
|
||||
}
|
||||
return ClaimResponse{}, false, err
|
||||
}
|
||||
if _, err := transaction.ExecContext(writeCtx, `INSERT INTO purchase_attempts
|
||||
(id, task_id, authorization_id, claim_generation, status, started_at)
|
||||
VALUES (?, ?, ?, ?, 'CLAIMED', ?)`, attemptID, candidate.TaskID, candidate.AuthorizationID, generation, nowText); err != nil {
|
||||
return ClaimResponse{}, false, err
|
||||
}
|
||||
if _, err := transaction.ExecContext(writeCtx, `INSERT INTO purchase_attempt_claims
|
||||
(attempt_id, task_id, authorization_id, claimed_by_device_id, session_id, claim_generation,
|
||||
task_version, task_title, authorization_task_version, goods_id, sku_color, sku_size, quantity,
|
||||
total_price_cap, authorization_expires_at, claim_nonce, claim_token_sha256,
|
||||
lease_expires_at, claimed_at, closed_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL)`,
|
||||
attemptID, candidate.TaskID, candidate.AuthorizationID, deviceID, command.SessionID, generation,
|
||||
candidate.TaskVersion+1, candidate.Title, candidate.TaskVersion, candidate.GoodsID,
|
||||
candidate.SKUColor, candidate.SKUSize, candidate.Quantity, candidate.TotalPriceCap,
|
||||
candidate.AuthorizationExpiresText, nonce, storedTokenHash, leaseText, nowText); err != nil {
|
||||
return ClaimResponse{}, false, err
|
||||
}
|
||||
if err := insertClaimRequest(writeCtx, transaction, command.ClaimRequestID, deviceID, command.SessionID, "CLAIMED", attemptID, leaseText, "", now); err != nil {
|
||||
return ClaimResponse{}, false, err
|
||||
}
|
||||
response := ClaimResponse{
|
||||
Task: ClaimedTask{ID: candidate.TaskID, Version: candidate.TaskVersion + 1, Title: candidate.Title,
|
||||
ProductURL: productURL(candidate.GoodsID), GoodsID: candidate.GoodsID, SKUColor: candidate.SKUColor,
|
||||
SKUSize: candidate.SKUSize, Quantity: candidate.Quantity, MaxTotalPrice: candidate.TotalPriceCap},
|
||||
Authorization: ClaimedAuthorization{ID: candidate.AuthorizationID, TaskVersion: candidate.TaskVersion, ExpiresAt: candidate.AuthorizationExpiresText},
|
||||
Attempt: ClaimedAttempt{ID: attemptID, ClaimToken: hex.EncodeToString(token), ClaimGeneration: generation, LeaseExpiresAt: leaseText},
|
||||
}
|
||||
if err := transaction.Commit(); err != nil {
|
||||
return ClaimResponse{}, false, err
|
||||
}
|
||||
return response, true, nil
|
||||
}
|
||||
|
||||
func (store *Store) Renew(ctx context.Context, deviceID string, command RenewCommand) (RenewResponse, error) {
|
||||
providedToken, tokenOK := decodeToken(command.ClaimToken)
|
||||
if !deviceauth.ValidDeviceID(deviceID) || !validUUID(command.TaskID) || !validUUID(command.RenewRequestID) ||
|
||||
!validUUID(command.SessionID) || !validUUID(command.AttemptID) || command.ClaimGeneration <= 0 ||
|
||||
!tokenOK || !validCanonicalTime(command.ExpectedLeaseExpiresAt) {
|
||||
return RenewResponse{}, ErrInvalid
|
||||
}
|
||||
providedHash := tokenHash(providedToken)
|
||||
writeCtx, cancel := context.WithTimeout(ctx, writeTimeout)
|
||||
defer cancel()
|
||||
select {
|
||||
case store.writeGate <- struct{}{}:
|
||||
defer func() { <-store.writeGate }()
|
||||
case <-writeCtx.Done():
|
||||
return RenewResponse{}, writeCtx.Err()
|
||||
}
|
||||
transaction, err := store.database.BeginTx(writeCtx, nil)
|
||||
if err != nil {
|
||||
return RenewResponse{}, err
|
||||
}
|
||||
defer transaction.Rollback()
|
||||
|
||||
// As in ClaimNext, this is deliberately the first database statement in the transaction.
|
||||
if store.beforeLinearization != nil {
|
||||
store.beforeLinearization()
|
||||
}
|
||||
active, err := transaction.ExecContext(writeCtx, `UPDATE device_credentials SET status = status
|
||||
WHERE device_id = ? AND status = 'ACTIVE' AND revoked_at IS NULL`, deviceID)
|
||||
if err != nil {
|
||||
return RenewResponse{}, err
|
||||
}
|
||||
if ok, err := exactlyOne(active); err != nil {
|
||||
return RenewResponse{}, err
|
||||
} else if !ok {
|
||||
return RenewResponse{}, ErrDeviceInactive
|
||||
}
|
||||
if store.afterLinearization != nil {
|
||||
store.afterLinearization()
|
||||
}
|
||||
|
||||
renewal, found, err := findRenewal(writeCtx, transaction, command.RenewRequestID)
|
||||
if err != nil {
|
||||
return RenewResponse{}, err
|
||||
}
|
||||
if found {
|
||||
if renewal.TaskID != command.TaskID || renewal.AttemptID != command.AttemptID || renewal.DeviceID != deviceID ||
|
||||
renewal.SessionID != command.SessionID || renewal.Generation != command.ClaimGeneration ||
|
||||
renewal.ExpectedLeaseExpiresAt != command.ExpectedLeaseExpiresAt || !matchingHash(renewal.TokenHash, providedHash) {
|
||||
return RenewResponse{}, ErrIdempotencyConflict
|
||||
}
|
||||
response := RenewResponse{TaskID: renewal.TaskID, AttemptID: renewal.AttemptID, ClaimGeneration: renewal.Generation, LeaseExpiresAt: renewal.LeaseExpiresAt}
|
||||
if err := transaction.Commit(); err != nil {
|
||||
return RenewResponse{}, err
|
||||
}
|
||||
return response, nil
|
||||
}
|
||||
|
||||
now, err := store.serverNow()
|
||||
if err != nil {
|
||||
return RenewResponse{}, err
|
||||
}
|
||||
record, found, err := store.loadClaimByAttempt(writeCtx, transaction, command.AttemptID)
|
||||
if err != nil {
|
||||
return RenewResponse{}, err
|
||||
}
|
||||
if !found || record.TaskID != command.TaskID || record.DeviceID != deviceID || record.SessionID != command.SessionID ||
|
||||
record.Generation != command.ClaimGeneration || !matchingHash(record.TokenHash, providedHash) {
|
||||
return RenewResponse{}, ErrNotCurrent
|
||||
}
|
||||
stateCurrent := record.ClosedAt == "" && record.LeaseExpiresAt.After(now) && record.AuthorizationExpiresAt.After(now) &&
|
||||
record.CurrentAuthorizationExpiresAt.After(now) && record.AuthorizationStatus == "CLAIMED" &&
|
||||
record.authorizationConsistent() && record.recoverableBusinessState()
|
||||
if !stateCurrent || record.LeaseExpiresText != command.ExpectedLeaseExpiresAt {
|
||||
return RenewResponse{}, ErrNotCurrent
|
||||
}
|
||||
leaseExpires := now.Add(store.leaseTTL)
|
||||
if record.AuthorizationExpiresAt.Before(leaseExpires) {
|
||||
leaseExpires = record.AuthorizationExpiresAt
|
||||
}
|
||||
leaseText := formatTime(leaseExpires)
|
||||
updated, err := transaction.ExecContext(writeCtx, `UPDATE purchase_attempt_claims SET lease_expires_at = ?
|
||||
WHERE attempt_id = ? AND lease_expires_at = ? AND closed_at IS NULL`, leaseText, command.AttemptID, command.ExpectedLeaseExpiresAt)
|
||||
if err != nil {
|
||||
return RenewResponse{}, err
|
||||
}
|
||||
if ok, err := exactlyOne(updated); err != nil || !ok {
|
||||
if err == nil {
|
||||
err = ErrNotCurrent
|
||||
}
|
||||
return RenewResponse{}, err
|
||||
}
|
||||
if _, err := transaction.ExecContext(writeCtx, `INSERT INTO purchase_attempt_lease_renewals
|
||||
(renew_request_id, task_id, attempt_id, device_id, session_id, claim_generation,
|
||||
claim_token_sha256, expected_lease_expires_at, lease_expires_at, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
command.RenewRequestID, command.TaskID, command.AttemptID, deviceID, command.SessionID,
|
||||
command.ClaimGeneration, record.TokenHash, command.ExpectedLeaseExpiresAt, leaseText, formatTime(now)); err != nil {
|
||||
return RenewResponse{}, err
|
||||
}
|
||||
response := RenewResponse{TaskID: command.TaskID, AttemptID: command.AttemptID, ClaimGeneration: command.ClaimGeneration, LeaseExpiresAt: leaseText}
|
||||
if err := transaction.Commit(); err != nil {
|
||||
return RenewResponse{}, err
|
||||
}
|
||||
return response, nil
|
||||
}
|
||||
|
||||
type claimRequestRecord struct {
|
||||
DeviceID, SessionID, Outcome, AttemptID, ResponseLeaseExpiresAt string
|
||||
}
|
||||
|
||||
func findClaimRequest(ctx context.Context, transaction *sql.Tx, requestID string) (claimRequestRecord, bool, error) {
|
||||
var record claimRequestRecord
|
||||
var attemptID, responseLease sql.NullString
|
||||
err := transaction.QueryRowContext(ctx, `SELECT device_id, session_id, outcome, attempt_id, response_lease_expires_at
|
||||
FROM task_claim_requests WHERE claim_request_id = ?`, requestID).
|
||||
Scan(&record.DeviceID, &record.SessionID, &record.Outcome, &attemptID, &responseLease)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return claimRequestRecord{}, false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return claimRequestRecord{}, false, err
|
||||
}
|
||||
record.AttemptID, record.ResponseLeaseExpiresAt = attemptID.String, responseLease.String
|
||||
return record, true, nil
|
||||
}
|
||||
|
||||
func insertClaimRequest(ctx context.Context, transaction *sql.Tx, requestID, deviceID, sessionID, outcome, attemptID, responseLease, errorCode string, now time.Time) error {
|
||||
var attempt, lease, code any
|
||||
if attemptID != "" {
|
||||
attempt = attemptID
|
||||
}
|
||||
if responseLease != "" {
|
||||
lease = responseLease
|
||||
}
|
||||
if errorCode != "" {
|
||||
code = errorCode
|
||||
}
|
||||
_, err := transaction.ExecContext(ctx, `INSERT INTO task_claim_requests
|
||||
(claim_request_id, device_id, session_id, outcome, attempt_id, response_lease_expires_at, error_code, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, requestID, deviceID, sessionID, outcome, attempt, lease, code, formatTime(now))
|
||||
return err
|
||||
}
|
||||
|
||||
type renewalRecord struct {
|
||||
TaskID, AttemptID, DeviceID, SessionID string
|
||||
Generation int
|
||||
TokenHash []byte
|
||||
ExpectedLeaseExpiresAt, LeaseExpiresAt string
|
||||
}
|
||||
|
||||
func findRenewal(ctx context.Context, transaction *sql.Tx, requestID string) (renewalRecord, bool, error) {
|
||||
var record renewalRecord
|
||||
err := transaction.QueryRowContext(ctx, `SELECT task_id, attempt_id, device_id, session_id,
|
||||
claim_generation, claim_token_sha256, expected_lease_expires_at, lease_expires_at
|
||||
FROM purchase_attempt_lease_renewals WHERE renew_request_id = ?`, requestID).
|
||||
Scan(&record.TaskID, &record.AttemptID, &record.DeviceID, &record.SessionID, &record.Generation,
|
||||
&record.TokenHash, &record.ExpectedLeaseExpiresAt, &record.LeaseExpiresAt)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return renewalRecord{}, false, nil
|
||||
}
|
||||
return record, err == nil, err
|
||||
}
|
||||
|
||||
type claimRecord struct {
|
||||
AttemptID, TaskID, AuthorizationID, DeviceID, SessionID string
|
||||
Generation, TaskVersion, CurrentTaskVersion int
|
||||
TaskTitle string
|
||||
Nonce, TokenHash []byte
|
||||
LeaseExpiresText, ClaimedAt, ClosedAt string
|
||||
LeaseExpiresAt time.Time
|
||||
AuthorizationTaskVersion int
|
||||
GoodsID, SKUColor, SKUSize, TotalPriceCap string
|
||||
Quantity int
|
||||
AuthorizationExpiresText, AuthorizationStatus string
|
||||
AuthorizationExpiresAt time.Time
|
||||
CurrentAuthorizationTaskVersion int
|
||||
CurrentGoodsID, CurrentSKUColor, CurrentSKUSize string
|
||||
CurrentQuantity int
|
||||
CurrentTotalPriceCap, CurrentAuthorizationExpiresText string
|
||||
CurrentAuthorizationExpiresAt time.Time
|
||||
AttemptStatus, TaskStatus string
|
||||
CurrentTaskTitle, CurrentTaskGoodsID string
|
||||
CurrentTaskSKUColor, CurrentTaskSKUSize string
|
||||
CurrentTaskQuantity int
|
||||
CurrentTaskMaxTotalPrice string
|
||||
CurrentAttemptGeneration int
|
||||
}
|
||||
|
||||
const claimSelect = `SELECT claims.attempt_id, claims.task_id, claims.authorization_id,
|
||||
claims.claimed_by_device_id, claims.session_id, claims.claim_generation, claims.task_version,
|
||||
claims.task_title, claims.authorization_task_version, claims.goods_id, claims.sku_color,
|
||||
claims.sku_size, claims.quantity, claims.total_price_cap, claims.authorization_expires_at,
|
||||
claims.claim_nonce, claims.claim_token_sha256, claims.lease_expires_at,
|
||||
claims.claimed_at, claims.closed_at, authorizations.task_version, authorizations.goods_id,
|
||||
authorizations.sku_color, authorizations.sku_size, authorizations.quantity,
|
||||
authorizations.total_price_cap, authorizations.expires_at, authorizations.status,
|
||||
attempts.claim_generation, attempts.status, tasks.status, tasks.version, tasks.title, tasks.goods_id,
|
||||
tasks.sku_color, tasks.sku_size, tasks.quantity, tasks.max_total_price
|
||||
FROM purchase_attempt_claims AS claims
|
||||
JOIN order_authorizations AS authorizations
|
||||
ON authorizations.task_id = claims.task_id AND authorizations.id = claims.authorization_id
|
||||
JOIN purchase_attempts AS attempts ON attempts.id = claims.attempt_id
|
||||
JOIN tasks ON tasks.id = claims.task_id `
|
||||
|
||||
func (store *Store) loadOpenClaimByDevice(ctx context.Context, transaction *sql.Tx, deviceID string) (claimRecord, bool, error) {
|
||||
return store.scanClaim(transaction.QueryRowContext(ctx, claimSelect+`WHERE claims.claimed_by_device_id = ? AND claims.closed_at IS NULL`, deviceID))
|
||||
}
|
||||
|
||||
func (store *Store) loadClaimByAttempt(ctx context.Context, transaction *sql.Tx, attemptID string) (claimRecord, bool, error) {
|
||||
return store.scanClaim(transaction.QueryRowContext(ctx, claimSelect+`WHERE claims.attempt_id = ?`, attemptID))
|
||||
}
|
||||
|
||||
type rowScanner interface{ Scan(...any) error }
|
||||
|
||||
func (store *Store) scanClaim(row rowScanner) (claimRecord, bool, error) {
|
||||
var record claimRecord
|
||||
var closed sql.NullString
|
||||
err := row.Scan(&record.AttemptID, &record.TaskID, &record.AuthorizationID, &record.DeviceID,
|
||||
&record.SessionID, &record.Generation, &record.TaskVersion, &record.TaskTitle,
|
||||
&record.AuthorizationTaskVersion, &record.GoodsID, &record.SKUColor, &record.SKUSize,
|
||||
&record.Quantity, &record.TotalPriceCap, &record.AuthorizationExpiresText,
|
||||
&record.Nonce, &record.TokenHash, &record.LeaseExpiresText, &record.ClaimedAt, &closed,
|
||||
&record.CurrentAuthorizationTaskVersion, &record.CurrentGoodsID, &record.CurrentSKUColor,
|
||||
&record.CurrentSKUSize, &record.CurrentQuantity, &record.CurrentTotalPriceCap,
|
||||
&record.CurrentAuthorizationExpiresText,
|
||||
&record.AuthorizationStatus, &record.CurrentAttemptGeneration, &record.AttemptStatus,
|
||||
&record.TaskStatus, &record.CurrentTaskVersion,
|
||||
&record.CurrentTaskTitle, &record.CurrentTaskGoodsID, &record.CurrentTaskSKUColor,
|
||||
&record.CurrentTaskSKUSize, &record.CurrentTaskQuantity, &record.CurrentTaskMaxTotalPrice)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return claimRecord{}, false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return claimRecord{}, false, err
|
||||
}
|
||||
record.ClosedAt = closed.String
|
||||
if !validUUID(record.AttemptID) || !validUUID(record.TaskID) || !validUUID(record.AuthorizationID) ||
|
||||
!deviceauth.ValidDeviceID(record.DeviceID) || !validUUID(record.SessionID) || record.Generation <= 0 ||
|
||||
record.CurrentAttemptGeneration != record.Generation ||
|
||||
record.TaskVersion <= 0 || record.AuthorizationTaskVersion <= 0 ||
|
||||
!taskmodel.ValidTaskWireFields(record.TaskTitle, record.GoodsID, record.SKUColor, record.SKUSize, record.TotalPriceCap) ||
|
||||
record.Quantity <= 0 || len(record.Nonce) != sha256.Size || len(record.TokenHash) != sha256.Size {
|
||||
return claimRecord{}, false, errors.New("stored task claim metadata is invalid")
|
||||
}
|
||||
record.LeaseExpiresAt, err = parseCanonicalTime(record.LeaseExpiresText)
|
||||
if err != nil {
|
||||
return claimRecord{}, false, errors.New("stored task claim lease is invalid")
|
||||
}
|
||||
record.AuthorizationExpiresAt, err = parseCanonicalTime(record.AuthorizationExpiresText)
|
||||
if err != nil {
|
||||
return claimRecord{}, false, errors.New("stored authorization expiry is invalid")
|
||||
}
|
||||
record.CurrentAuthorizationExpiresAt, err = parseCanonicalTime(record.CurrentAuthorizationExpiresText)
|
||||
if err != nil {
|
||||
return claimRecord{}, false, errors.New("current authorization expiry is invalid")
|
||||
}
|
||||
derived := deriveToken(store.secret, record.DeviceID, record.TaskID, record.AuthorizationID, record.AttemptID, record.Generation, record.Nonce)
|
||||
if !matchingHash(tokenHash(derived), record.TokenHash) {
|
||||
return claimRecord{}, false, errors.New("task claim secret does not match stored claim")
|
||||
}
|
||||
return record, true, nil
|
||||
}
|
||||
|
||||
func (record claimRecord) authorizationConsistent() bool {
|
||||
return taskmodel.ValidAuthorizationFields(record.CurrentGoodsID, record.CurrentSKUColor,
|
||||
record.CurrentSKUSize, record.CurrentTotalPriceCap) &&
|
||||
taskmodel.ValidTaskWireFields(record.CurrentTaskTitle, record.CurrentTaskGoodsID,
|
||||
record.CurrentTaskSKUColor, record.CurrentTaskSKUSize, record.CurrentTaskMaxTotalPrice) &&
|
||||
record.AuthorizationTaskVersion == record.CurrentAuthorizationTaskVersion &&
|
||||
record.GoodsID == record.CurrentGoodsID && record.SKUColor == record.CurrentSKUColor &&
|
||||
record.SKUSize == record.CurrentSKUSize && record.Quantity == record.CurrentQuantity &&
|
||||
record.TotalPriceCap == record.CurrentTotalPriceCap &&
|
||||
record.AuthorizationExpiresText == record.CurrentAuthorizationExpiresText &&
|
||||
record.TaskTitle == record.CurrentTaskTitle && record.GoodsID == record.CurrentTaskGoodsID &&
|
||||
record.SKUColor == record.CurrentTaskSKUColor && record.SKUSize == record.CurrentTaskSKUSize &&
|
||||
record.Quantity == record.CurrentTaskQuantity && record.TotalPriceCap == record.CurrentTaskMaxTotalPrice
|
||||
}
|
||||
|
||||
func (record claimRecord) recoverableBusinessState() bool {
|
||||
if record.TaskStatus == "CLAIMED" && record.AttemptStatus == "CLAIMED" {
|
||||
return record.CurrentTaskVersion == record.TaskVersion
|
||||
}
|
||||
// A later server task may advance this same attempt to ORDERING. A valid lease and identical
|
||||
// ownership recover that attempt; claim-next still cannot select another task.
|
||||
return record.TaskStatus == "ORDERING" && record.AttemptStatus == "ORDERING" &&
|
||||
record.TaskVersion < math.MaxInt && record.CurrentTaskVersion == record.TaskVersion+1
|
||||
}
|
||||
|
||||
func (store *Store) responseFor(record claimRecord, responseLease string) (ClaimResponse, error) {
|
||||
// Exact idempotent replay is allowed to ignore later source-row drift, but the
|
||||
// immutable response snapshot itself must still satisfy the current wire bounds.
|
||||
if !validCanonicalTime(responseLease) ||
|
||||
!taskmodel.ValidTaskWireFields(record.TaskTitle, record.GoodsID, record.SKUColor, record.SKUSize, record.TotalPriceCap) ||
|
||||
record.Quantity <= 0 {
|
||||
return ClaimResponse{}, errors.New("stored claim response snapshot is invalid")
|
||||
}
|
||||
token := deriveToken(store.secret, record.DeviceID, record.TaskID, record.AuthorizationID, record.AttemptID, record.Generation, record.Nonce)
|
||||
return ClaimResponse{
|
||||
Task: ClaimedTask{ID: record.TaskID, Version: record.TaskVersion, Title: record.TaskTitle,
|
||||
ProductURL: productURL(record.GoodsID), GoodsID: record.GoodsID, SKUColor: record.SKUColor,
|
||||
SKUSize: record.SKUSize, Quantity: record.Quantity, MaxTotalPrice: record.TotalPriceCap},
|
||||
Authorization: ClaimedAuthorization{ID: record.AuthorizationID, TaskVersion: record.AuthorizationTaskVersion, ExpiresAt: record.AuthorizationExpiresText},
|
||||
Attempt: ClaimedAttempt{ID: record.AttemptID, ClaimToken: hex.EncodeToString(token), ClaimGeneration: record.Generation, LeaseExpiresAt: responseLease},
|
||||
}, nil
|
||||
}
|
||||
|
||||
type candidate struct {
|
||||
AuthorizationID, TaskID, Title, GoodsID, SKUColor, SKUSize, TotalPriceCap string
|
||||
TaskVersion, Quantity int
|
||||
AuthorizationTaskVersion, AuthorizationQuantity int
|
||||
AuthorizationGoodsID, AuthorizationSKUColor, AuthorizationSKUSize string
|
||||
AuthorizationTotalPriceCap string
|
||||
AuthorizationExpiresText string
|
||||
AuthorizationExpiresAt time.Time
|
||||
}
|
||||
|
||||
func findCandidate(ctx context.Context, transaction *sql.Tx, now time.Time) (candidate, bool, error) {
|
||||
rows, err := transaction.QueryContext(ctx, `SELECT authorizations.id, tasks.id, tasks.version,
|
||||
tasks.title, tasks.goods_id, tasks.sku_color, tasks.sku_size, tasks.quantity,
|
||||
tasks.max_total_price, authorizations.task_version, authorizations.goods_id,
|
||||
authorizations.sku_color, authorizations.sku_size, authorizations.quantity,
|
||||
authorizations.total_price_cap, authorizations.expires_at
|
||||
FROM order_authorizations AS authorizations
|
||||
JOIN tasks ON tasks.id = authorizations.task_id
|
||||
WHERE authorizations.status = 'ACTIVE' AND tasks.status = 'PENDING'
|
||||
ORDER BY authorizations.created_at, authorizations.rowid, authorizations.id`)
|
||||
if err != nil {
|
||||
return candidate{}, false, err
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var item candidate
|
||||
if err := rows.Scan(&item.AuthorizationID, &item.TaskID, &item.TaskVersion, &item.Title,
|
||||
&item.GoodsID, &item.SKUColor, &item.SKUSize, &item.Quantity, &item.TotalPriceCap,
|
||||
&item.AuthorizationTaskVersion, &item.AuthorizationGoodsID, &item.AuthorizationSKUColor,
|
||||
&item.AuthorizationSKUSize, &item.AuthorizationQuantity, &item.AuthorizationTotalPriceCap,
|
||||
&item.AuthorizationExpiresText); err != nil {
|
||||
return candidate{}, false, err
|
||||
}
|
||||
item.AuthorizationExpiresAt, err = parseCanonicalTime(item.AuthorizationExpiresText)
|
||||
if err != nil {
|
||||
return candidate{}, false, errors.New("stored authorization expiry is invalid")
|
||||
}
|
||||
if !validCandidate(item) {
|
||||
return candidate{}, false, errors.New("stored claim candidate is invalid")
|
||||
}
|
||||
if !candidateSnapshotMatches(item) {
|
||||
continue
|
||||
}
|
||||
if item.AuthorizationExpiresAt.After(now) {
|
||||
if err := rows.Close(); err != nil {
|
||||
return candidate{}, false, err
|
||||
}
|
||||
return item, true, nil
|
||||
}
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return candidate{}, false, err
|
||||
}
|
||||
return candidate{}, false, nil
|
||||
}
|
||||
|
||||
func validCandidate(item candidate) bool {
|
||||
return validUUID(item.AuthorizationID) && validUUID(item.TaskID) && item.TaskVersion > 0 && item.TaskVersion < math.MaxInt &&
|
||||
taskmodel.ValidTaskWireFields(item.Title, item.GoodsID, item.SKUColor, item.SKUSize, item.TotalPriceCap) &&
|
||||
item.Quantity > 0 && item.AuthorizationTaskVersion > 0 && item.AuthorizationTaskVersion < math.MaxInt &&
|
||||
taskmodel.ValidAuthorizationFields(item.AuthorizationGoodsID, item.AuthorizationSKUColor,
|
||||
item.AuthorizationSKUSize, item.AuthorizationTotalPriceCap) && item.AuthorizationQuantity > 0
|
||||
}
|
||||
|
||||
func candidateSnapshotMatches(item candidate) bool {
|
||||
return item.AuthorizationTaskVersion == item.TaskVersion && item.AuthorizationGoodsID == item.GoodsID &&
|
||||
item.AuthorizationSKUColor == item.SKUColor && item.AuthorizationSKUSize == item.SKUSize &&
|
||||
item.AuthorizationQuantity == item.Quantity && item.AuthorizationTotalPriceCap == item.TotalPriceCap
|
||||
}
|
||||
|
||||
func validAttemptStatus(value sql.NullString) bool {
|
||||
return value.Valid && oneOf(value.String, "CLAIMED", "ORDERING", "FAILED", "FENCED", "ABANDONED")
|
||||
}
|
||||
|
||||
func validAuthorizationStatus(value sql.NullString) bool {
|
||||
return value.Valid && oneOf(value.String, "ACTIVE", "CLAIMED", "FENCED", "CONSUMED", "EXPIRED", "ABANDONED")
|
||||
}
|
||||
|
||||
func validTaskStatus(value sql.NullString) bool {
|
||||
return value.Valid && oneOf(value.String, "DRAFT", "PENDING", "CLAIMED", "ORDERING", "NEEDS_MANUAL",
|
||||
"WAITING_PAYMENT", "RECONCILIATION_REQUIRED", "SUCCEEDED", "FAILED", "CANCELED")
|
||||
}
|
||||
|
||||
func oneOf(value string, allowed ...string) bool {
|
||||
for _, item := range allowed {
|
||||
if value == item {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func nextGeneration(ctx context.Context, transaction *sql.Tx, taskID string) (int, error) {
|
||||
var maximum int64
|
||||
if err := transaction.QueryRowContext(ctx, `SELECT COALESCE(MAX(claim_generation), 0) FROM purchase_attempts WHERE task_id = ?`, taskID).Scan(&maximum); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if maximum < 0 || maximum >= int64(math.MaxInt) {
|
||||
return 0, errors.New("task claim generation is exhausted")
|
||||
}
|
||||
return int(maximum) + 1, nil
|
||||
}
|
||||
|
||||
func (store *Store) serverNow() (time.Time, error) {
|
||||
now := store.now().UTC()
|
||||
if now.IsZero() {
|
||||
return time.Time{}, errors.New("task claim clock is invalid")
|
||||
}
|
||||
return now, nil
|
||||
}
|
||||
|
||||
func (store *Store) randomBytes(size int) ([]byte, error) {
|
||||
value := make([]byte, size)
|
||||
store.randomMu.Lock()
|
||||
_, err := io.ReadFull(store.random, value)
|
||||
store.randomMu.Unlock()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("generate task claim randomness: %w", err)
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
|
||||
func (store *Store) newUUID() (string, error) {
|
||||
value, err := store.randomBytes(16)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
value[6] = (value[6] & 0x0f) | 0x40
|
||||
value[8] = (value[8] & 0x3f) | 0x80
|
||||
encoded := hex.EncodeToString(value)
|
||||
return encoded[:8] + "-" + encoded[8:12] + "-" + encoded[12:16] + "-" + encoded[16:20] + "-" + encoded[20:], nil
|
||||
}
|
||||
|
||||
func exactlyOne(result sql.Result) (bool, error) {
|
||||
rows, err := result.RowsAffected()
|
||||
return rows == 1, err
|
||||
}
|
||||
|
||||
func formatTime(value time.Time) string { return value.UTC().Format(time.RFC3339Nano) }
|
||||
|
||||
func parseCanonicalTime(value string) (time.Time, error) {
|
||||
if !strings.HasSuffix(value, "Z") || strings.TrimSpace(value) != value {
|
||||
return time.Time{}, ErrInvalid
|
||||
}
|
||||
parsed, err := time.Parse(time.RFC3339Nano, value)
|
||||
if err != nil || parsed.Location() != time.UTC || formatTime(parsed) != value {
|
||||
return time.Time{}, ErrInvalid
|
||||
}
|
||||
return parsed, nil
|
||||
}
|
||||
|
||||
func validCanonicalTime(value string) bool {
|
||||
_, err := parseCanonicalTime(value)
|
||||
return err == 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 productURL(goodsID string) string {
|
||||
return "https://mobile.yangkeduo.com/goods.html?goods_id=" + goodsID
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,52 @@
|
||||
package taskclaim
|
||||
|
||||
import (
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"crypto/subtle"
|
||||
"encoding/binary"
|
||||
"encoding/hex"
|
||||
"hash"
|
||||
)
|
||||
|
||||
const tokenDomain = "cmbuyer/task-claim-token/v1\x00"
|
||||
|
||||
func deriveToken(secret []byte, deviceID, taskID, authorizationID, attemptID string, generation int, nonce []byte) []byte {
|
||||
mac := hmac.New(sha256.New, secret)
|
||||
_, _ = mac.Write([]byte(tokenDomain))
|
||||
writeTokenField(mac, deviceID)
|
||||
writeTokenField(mac, taskID)
|
||||
writeTokenField(mac, authorizationID)
|
||||
writeTokenField(mac, attemptID)
|
||||
var number [8]byte
|
||||
binary.BigEndian.PutUint64(number[:], uint64(generation))
|
||||
_, _ = mac.Write(number[:])
|
||||
writeTokenBytes(mac, nonce)
|
||||
return mac.Sum(nil)
|
||||
}
|
||||
|
||||
func writeTokenField(writer hash.Hash, value string) { writeTokenBytes(writer, []byte(value)) }
|
||||
|
||||
func writeTokenBytes(writer hash.Hash, value []byte) {
|
||||
var size [4]byte
|
||||
binary.BigEndian.PutUint32(size[:], uint32(len(value)))
|
||||
_, _ = writer.Write(size[:])
|
||||
_, _ = writer.Write(value)
|
||||
}
|
||||
|
||||
func tokenHash(token []byte) []byte {
|
||||
sum := sha256.Sum256(token)
|
||||
return sum[:]
|
||||
}
|
||||
|
||||
func matchingHash(left, right []byte) bool {
|
||||
return len(left) == sha256.Size && len(right) == sha256.Size && subtle.ConstantTimeCompare(left, right) == 1
|
||||
}
|
||||
|
||||
func decodeToken(value string) ([]byte, bool) {
|
||||
if len(value) != sha256.Size*2 {
|
||||
return nil, false
|
||||
}
|
||||
decoded, err := hex.DecodeString(value)
|
||||
return decoded, err == nil && hex.EncodeToString(decoded) == value
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
// Package taskclaim owns the atomic task-claim and lease-renewal boundary.
|
||||
// A claim token proves only ownership of one attempt; it is never permission to submit an order.
|
||||
package taskclaim
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"math"
|
||||
|
||||
taskmodel "cmbuyer/admin/internal/tasks"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrInvalid = errors.New("invalid task claim request")
|
||||
ErrIdempotencyConflict = errors.New("task claim idempotency conflict")
|
||||
ErrRequiresManual = errors.New("task claim requires manual recovery")
|
||||
ErrNotCurrent = errors.New("task claim is not current")
|
||||
ErrDeviceInactive = errors.New("task claim device is inactive")
|
||||
)
|
||||
|
||||
type ClaimCommand struct {
|
||||
SessionID string `json:"session_id"`
|
||||
ClaimRequestID string `json:"claim_request_id"`
|
||||
}
|
||||
|
||||
type RenewCommand struct {
|
||||
TaskID string `json:"-"`
|
||||
RenewRequestID string `json:"renew_request_id"`
|
||||
SessionID string `json:"session_id"`
|
||||
AttemptID string `json:"attempt_id"`
|
||||
ClaimGeneration int `json:"claim_generation"`
|
||||
ClaimToken string `json:"claim_token"`
|
||||
ExpectedLeaseExpiresAt string `json:"expected_lease_expires_at"`
|
||||
}
|
||||
|
||||
type ClaimedTask struct {
|
||||
ID string `json:"id"`
|
||||
Version int `json:"version"`
|
||||
Title string `json:"title"`
|
||||
ProductURL string `json:"product_url"`
|
||||
GoodsID string `json:"goods_id"`
|
||||
SKUColor string `json:"sku_color"`
|
||||
SKUSize string `json:"sku_size"`
|
||||
Quantity int `json:"quantity"`
|
||||
MaxTotalPrice string `json:"max_total_price"`
|
||||
}
|
||||
|
||||
type ClaimedAuthorization struct {
|
||||
ID string `json:"id"`
|
||||
TaskVersion int `json:"task_version"`
|
||||
ExpiresAt string `json:"expires_at"`
|
||||
}
|
||||
|
||||
type ClaimedAttempt struct {
|
||||
ID string `json:"id"`
|
||||
ClaimToken string `json:"claim_token"`
|
||||
ClaimGeneration int `json:"claim_generation"`
|
||||
LeaseExpiresAt string `json:"lease_expires_at"`
|
||||
}
|
||||
|
||||
type ClaimResponse struct {
|
||||
Task ClaimedTask `json:"task"`
|
||||
Authorization ClaimedAuthorization `json:"authorization"`
|
||||
Attempt ClaimedAttempt `json:"attempt"`
|
||||
}
|
||||
|
||||
// ValidClaimResponse closes the service-to-HTTP boundary as well as the SQLite
|
||||
// boundary. A fake or future Service implementation cannot bypass the same field
|
||||
// limits enforced while creating and claiming the task.
|
||||
func ValidClaimResponse(response ClaimResponse) bool {
|
||||
authorizationExpires, authorizationErr := parseCanonicalTime(response.Authorization.ExpiresAt)
|
||||
leaseExpires, leaseErr := parseCanonicalTime(response.Attempt.LeaseExpiresAt)
|
||||
return validUUID(response.Task.ID) && response.Task.Version > 0 &&
|
||||
response.Authorization.TaskVersion > 0 && response.Authorization.TaskVersion < math.MaxInt &&
|
||||
response.Task.Version == response.Authorization.TaskVersion+1 &&
|
||||
taskmodel.ValidTaskWireFields(response.Task.Title, response.Task.GoodsID,
|
||||
response.Task.SKUColor, response.Task.SKUSize, response.Task.MaxTotalPrice) &&
|
||||
response.Task.ProductURL == productURL(response.Task.GoodsID) && response.Task.Quantity > 0 &&
|
||||
validUUID(response.Authorization.ID) && authorizationErr == nil &&
|
||||
validUUID(response.Attempt.ID) && response.Attempt.ClaimGeneration > 0 &&
|
||||
len(response.Attempt.ClaimToken) == 64 && tokenTextValid(response.Attempt.ClaimToken) &&
|
||||
leaseErr == nil && !leaseExpires.After(authorizationExpires)
|
||||
}
|
||||
|
||||
func tokenTextValid(value string) bool {
|
||||
_, ok := decodeToken(value)
|
||||
return ok
|
||||
}
|
||||
|
||||
type RenewResponse struct {
|
||||
TaskID string `json:"task_id"`
|
||||
AttemptID string `json:"attempt_id"`
|
||||
ClaimGeneration int `json:"claim_generation"`
|
||||
LeaseExpiresAt string `json:"lease_expires_at"`
|
||||
}
|
||||
|
||||
type Service interface {
|
||||
ClaimNext(context.Context, string, ClaimCommand) (ClaimResponse, bool, error)
|
||||
Renew(context.Context, string, RenewCommand) (RenewResponse, error)
|
||||
}
|
||||
@@ -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,97 @@
|
||||
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"
|
||||
detailDevice = "e3c9f507-7473-4fa6-8d71-8786c34c6301"
|
||||
)
|
||||
|
||||
func TestSQLiteStoreReturnsOnlyPersistedAuditFacts(t *testing.T) {
|
||||
database := openDetailDatabase(t)
|
||||
timestamp := "2026-08-04T00:00:00Z"
|
||||
if _, err := database.Exec(`INSERT INTO device_credentials
|
||||
(device_id,display_name,token_sha256,status,created_at,revoked_at)
|
||||
VALUES (?, 'detail test device', zeroblob(32), 'ACTIVE', ?, NULL)`, detailDevice, timestamp); err != nil {
|
||||
t.Fatalf("insert device: %v", 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 (?, '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)
|
||||
}
|
||||
if _, err := database.Exec(`INSERT INTO purchase_attempt_claims
|
||||
(attempt_id,task_id,authorization_id,claimed_by_device_id,session_id,claim_generation,
|
||||
task_version,task_title,authorization_task_version,goods_id,sku_color,sku_size,quantity,
|
||||
total_price_cap,authorization_expires_at,claim_nonce,claim_token_sha256,lease_expires_at,claimed_at,closed_at)
|
||||
VALUES (?, ?, ?, ?, 'f3c9f507-7473-4fa6-8d71-8786c34c6301', 1, 3, 'shirt',
|
||||
2, '123', 'black', 'M', 2, '30.00', ?, zeroblob(32), zeroblob(32),
|
||||
'2026-08-04T00:05:00Z', ?, NULL)`, detailTry, detailTask, detailAuth, detailDevice, timestamp, timestamp); err != nil {
|
||||
t.Fatalf("insert claim: %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, ?, ?, ?, ?)`, detailTask, detailTry, hash, "aa/"+hash+".png", detailDevice, 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
|
||||
}
|
||||
@@ -50,7 +50,7 @@ type TaskRow struct {
|
||||
}
|
||||
|
||||
func normalizeCents(value string) (string, *big.Int, bool) {
|
||||
if value == "" || strings.TrimSpace(value) != value {
|
||||
if value == "" || len(value) > MaxMoneyASCIICharacters || strings.TrimSpace(value) != value {
|
||||
return "", nil, false
|
||||
}
|
||||
parts := strings.Split(value, ".")
|
||||
@@ -71,6 +71,11 @@ func normalizeCents(value string) (string, *big.Int, bool) {
|
||||
return value, cents, true
|
||||
}
|
||||
|
||||
func ValidCanonicalMoney(value string) bool {
|
||||
canonical, _, ok := normalizeCents(value)
|
||||
return ok && canonical == value
|
||||
}
|
||||
|
||||
func startItems(command StartCommand) ([]StartItem, error) {
|
||||
if !validUUID(command.StartKey) || len(command.Tasks) == 0 || len(command.Tasks) > maxStartItems {
|
||||
return nil, ErrInvalidStart
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"math"
|
||||
"reflect"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -164,12 +165,27 @@ func TestStartPurchasesRejectsEveryTaskConflictWithoutAuthorization(t *testing.T
|
||||
"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)
|
||||
},
|
||||
"overlong goods id": func(t *testing.T, store *SQLiteStore, id string, _ *StartItem) {
|
||||
execTestSQL(t, store.database, `UPDATE tasks SET goods_id=? WHERE id=?`, strings.Repeat("1", MaxGoodsIDCharacters+1), id)
|
||||
},
|
||||
"invalid utf8 title": func(t *testing.T, store *SQLiteStore, id string, _ *StartItem) {
|
||||
execTestSQL(t, store.database, `UPDATE tasks SET title=? WHERE id=?`, string([]byte{0xff}), id)
|
||||
},
|
||||
"overlong title": func(t *testing.T, store *SQLiteStore, id string, _ *StartItem) {
|
||||
execTestSQL(t, store.database, `UPDATE tasks SET title=? WHERE id=?`, strings.Repeat("😀", MaxTitleCodePoints+1), id)
|
||||
},
|
||||
"empty color": func(t *testing.T, store *SQLiteStore, id string, _ *StartItem) {
|
||||
execTestSQL(t, store.database, `UPDATE tasks SET sku_color='' WHERE id=?`, id)
|
||||
},
|
||||
"overlong color": func(t *testing.T, store *SQLiteStore, id string, _ *StartItem) {
|
||||
execTestSQL(t, store.database, `UPDATE tasks SET sku_color=? WHERE id=?`, strings.Repeat("色", MaxSKUTextCodePoints+1), id)
|
||||
},
|
||||
"empty size": func(t *testing.T, store *SQLiteStore, id string, _ *StartItem) {
|
||||
execTestSQL(t, store.database, `UPDATE tasks SET sku_size='' WHERE id=?`, id)
|
||||
},
|
||||
"overlong size": func(t *testing.T, store *SQLiteStore, id string, _ *StartItem) {
|
||||
execTestSQL(t, store.database, `UPDATE tasks SET sku_size=? WHERE id=?`, strings.Repeat("码", MaxSKUTextCodePoints+1), id)
|
||||
},
|
||||
"quantity over policy": func(_ *testing.T, store *SQLiteStore, _ string, _ *StartItem) {
|
||||
store.policy.MaxQuantity = 1
|
||||
},
|
||||
@@ -179,6 +195,9 @@ func TestStartPurchasesRejectsEveryTaskConflictWithoutAuthorization(t *testing.T
|
||||
"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)
|
||||
},
|
||||
"overlong canonical price": func(t *testing.T, store *SQLiteStore, id string, _ *StartItem) {
|
||||
execTestSQL(t, store.database, `UPDATE tasks SET max_total_price=? WHERE id=?`, strings.Repeat("1", MaxMoneyASCIICharacters-2)+".00", id)
|
||||
},
|
||||
"price over policy": func(_ *testing.T, store *SQLiteStore, _ string, _ *StartItem) {
|
||||
store.policy.MaxTotalPrice = "12.79"
|
||||
},
|
||||
@@ -199,6 +218,43 @@ func TestStartPurchasesRejectsEveryTaskConflictWithoutAuthorization(t *testing.T
|
||||
}
|
||||
}
|
||||
|
||||
func TestStartPurchasesReplayRejectsMalformedAuthorizationOrTaskSnapshot(t *testing.T) {
|
||||
mutations := map[string]func(*testing.T, *sql.DB, string){
|
||||
"authorization goods id": func(t *testing.T, database *sql.DB, id string) {
|
||||
execTestSQL(t, database, `UPDATE order_authorizations SET goods_id=? WHERE task_id=?`, strings.Repeat("1", MaxGoodsIDCharacters+1), id)
|
||||
},
|
||||
"authorization color": func(t *testing.T, database *sql.DB, id string) {
|
||||
execTestSQL(t, database, `UPDATE order_authorizations SET sku_color=? WHERE task_id=?`, strings.Repeat("色", MaxSKUTextCodePoints+1), id)
|
||||
},
|
||||
"authorization size": func(t *testing.T, database *sql.DB, id string) {
|
||||
execTestSQL(t, database, `UPDATE order_authorizations SET sku_size=? WHERE task_id=?`, strings.Repeat("码", MaxSKUTextCodePoints+1), id)
|
||||
},
|
||||
"authorization money": func(t *testing.T, database *sql.DB, id string) {
|
||||
execTestSQL(t, database, `UPDATE order_authorizations SET total_price_cap=? WHERE task_id=?`, strings.Repeat("1", MaxMoneyASCIICharacters-2)+".00", id)
|
||||
},
|
||||
"task title": func(t *testing.T, database *sql.DB, id string) {
|
||||
execTestSQL(t, database, `UPDATE tasks SET title=? WHERE id=?`, strings.Repeat("😀", MaxTitleCodePoints+1), id)
|
||||
},
|
||||
}
|
||||
for name, mutate := range mutations {
|
||||
t.Run(name, 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}}}
|
||||
if _, err := store.StartPurchases(context.Background(), command, "admin"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mutate(t, database, id)
|
||||
if _, err := store.StartPurchases(context.Background(), command, "admin"); !errors.Is(err, ErrStartConflict) {
|
||||
t.Fatalf("replay error = %v, want ErrStartConflict", err)
|
||||
}
|
||||
assertAuthorizationCount(t, database, 1)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestStartPurchasesRollsBackWholeBatchForLateConflictAndSQLFailure(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
|
||||
@@ -142,7 +142,9 @@ func (store *SQLiteStore) StartPurchases(ctx context.Context, command StartComma
|
||||
}
|
||||
return StartResult{}, err
|
||||
}
|
||||
if status != "DRAFT" || version != item.ExpectedTaskVersion || !goodsIDValid(goods) || color == "" || size == "" || quantity < 1 || quantity > store.policy.MaxQuantity {
|
||||
if status != "DRAFT" || version != item.ExpectedTaskVersion ||
|
||||
!ValidTaskWireFields(title, goods, color, size, price) ||
|
||||
quantity < 1 || quantity > store.policy.MaxQuantity {
|
||||
return StartResult{}, ErrStartConflict
|
||||
}
|
||||
canonical, cents, ok := normalizeCents(price)
|
||||
@@ -179,20 +181,15 @@ func (store *SQLiteStore) StartPurchases(ctx context.Context, command StartComma
|
||||
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)
|
||||
rows, err := tx.QueryContext(ctx, `SELECT authorizations.id,authorizations.task_id,
|
||||
authorizations.task_version,authorizations.expires_at,authorizations.goods_id,
|
||||
authorizations.sku_color,authorizations.sku_size,authorizations.quantity,
|
||||
authorizations.total_price_cap,tasks.title,tasks.goods_id,tasks.sku_color,
|
||||
tasks.sku_size,tasks.quantity,tasks.max_total_price
|
||||
FROM order_authorizations AS authorizations
|
||||
JOIN tasks ON tasks.id = authorizations.task_id
|
||||
WHERE authorizations.start_key=? ORDER BY authorizations.task_id`, startKey)
|
||||
if err != nil {
|
||||
return StartResult{}, false, err
|
||||
}
|
||||
@@ -201,9 +198,22 @@ func replayStart(ctx context.Context, tx *sql.Tx, startKey string, items []Start
|
||||
for rows.Next() {
|
||||
var item AuthorizedTask
|
||||
var expires string
|
||||
if err := rows.Scan(&item.AuthorizationID, &item.TaskID, &item.TaskVersion, &expires); err != nil {
|
||||
var authorizationGoodsID, authorizationColor, authorizationSize, authorizationPrice string
|
||||
var taskTitle, taskGoodsID, taskColor, taskSize, taskPrice string
|
||||
var authorizationQuantity, taskQuantity int
|
||||
if err := rows.Scan(&item.AuthorizationID, &item.TaskID, &item.TaskVersion, &expires,
|
||||
&authorizationGoodsID, &authorizationColor, &authorizationSize, &authorizationQuantity,
|
||||
&authorizationPrice, &taskTitle, &taskGoodsID, &taskColor, &taskSize, &taskQuantity,
|
||||
&taskPrice); err != nil {
|
||||
return StartResult{}, false, err
|
||||
}
|
||||
if !ValidAuthorizationFields(authorizationGoodsID, authorizationColor, authorizationSize, authorizationPrice) ||
|
||||
authorizationQuantity <= 0 ||
|
||||
!ValidTaskWireFields(taskTitle, taskGoodsID, taskColor, taskSize, taskPrice) || taskQuantity <= 0 ||
|
||||
authorizationGoodsID != taskGoodsID || authorizationColor != taskColor ||
|
||||
authorizationSize != taskSize || authorizationQuantity != taskQuantity || authorizationPrice != taskPrice {
|
||||
return StartResult{}, false, ErrStartConflict
|
||||
}
|
||||
item.ExpiresAt, err = time.Parse(time.RFC3339Nano, expires)
|
||||
if err != nil {
|
||||
return StartResult{}, false, err
|
||||
|
||||
@@ -37,6 +37,11 @@ func NewSQLiteStore(database *sql.DB) (*SQLiteStore, error) {
|
||||
}
|
||||
|
||||
func (store *SQLiteStore) CreateDraft(ctx context.Context, draft Draft) (Draft, error) {
|
||||
// Validate again at the persistence boundary. HTTP form validation is not the only
|
||||
// caller, and a malformed row here would later make an authorized claim unencodable.
|
||||
if !validUUID(draft.ID) || !ValidTaskWireFields(draft.Title, draft.GoodsID, draft.SKUColor, draft.SKUSize, draft.MaxTotalPrice) || draft.Quantity <= 0 {
|
||||
return Draft{}, ErrInvalidDraft
|
||||
}
|
||||
writeContext, cancel := context.WithTimeout(ctx, sqliteWriteTimeout)
|
||||
defer cancel()
|
||||
// SQLite permits one writer at a time. Serializing this store's short create
|
||||
|
||||
@@ -9,14 +9,21 @@ import (
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
const (
|
||||
maxTitleLength = 120
|
||||
maxSKUText = 80
|
||||
MaxTitleCodePoints = 120
|
||||
MaxSKUTextCodePoints = 80
|
||||
MaxGoodsIDCharacters = 32
|
||||
MaxMoneyASCIICharacters = 32
|
||||
maxSKUText = MaxSKUTextCodePoints
|
||||
)
|
||||
|
||||
var ErrCreateKeyConflict = errors.New("create key conflicts with a different task")
|
||||
var (
|
||||
ErrCreateKeyConflict = errors.New("create key conflicts with a different task")
|
||||
ErrInvalidDraft = errors.New("invalid draft")
|
||||
)
|
||||
|
||||
type Draft struct {
|
||||
ID string
|
||||
@@ -41,13 +48,13 @@ func Validate(form Form) (Draft, Errors) {
|
||||
if !validUUID(draft.ID) {
|
||||
errors["create_key"] = "创建请求已过期,请重新打开表单。"
|
||||
}
|
||||
if draft.Title == "" || len([]rune(draft.Title)) > maxTitleLength {
|
||||
if !validBoundedText(draft.Title, MaxTitleCodePoints) {
|
||||
errors["title"] = "任务名称不能为空,且不能超过 120 个字符。"
|
||||
}
|
||||
if draft.SKUColor == "" || len([]rune(draft.SKUColor)) > maxSKUText {
|
||||
if !validBoundedText(draft.SKUColor, MaxSKUTextCodePoints) {
|
||||
errors["sku_color"] = "颜色分类不能为空,且不能超过 80 个字符。"
|
||||
}
|
||||
if draft.SKUSize == "" || len([]rune(draft.SKUSize)) > maxSKUText {
|
||||
if !validBoundedText(draft.SKUSize, MaxSKUTextCodePoints) {
|
||||
errors["sku_size"] = "尺码不能为空,且不能超过 80 个字符。"
|
||||
}
|
||||
goodsID, ok := CanonicalGoodsID(strings.TrimSpace(form.ProductURL))
|
||||
@@ -93,6 +100,9 @@ func CanonicalGoodsID(value string) (string, bool) {
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
if !ValidGoodsID(goodsIDs[0]) {
|
||||
return "", false
|
||||
}
|
||||
return goodsIDs[0], true
|
||||
}
|
||||
|
||||
@@ -155,5 +165,54 @@ func normalizeMoney(value string) (string, bool) {
|
||||
if whole == "0" && strings.Trim(fraction, "0") == "" {
|
||||
return "", false
|
||||
}
|
||||
return whole + "." + (fraction + "00")[:2], true
|
||||
canonical := whole + "." + (fraction + "00")[:2]
|
||||
if len(canonical) > MaxMoneyASCIICharacters {
|
||||
return "", false
|
||||
}
|
||||
return canonical, true
|
||||
}
|
||||
|
||||
// ValidTaskWireFields is shared by creation, authorization and claim. Keeping one
|
||||
// bounded domain prevents a database row from being valid in one stage but impossible
|
||||
// to encode inside the fixed claim response budget in another stage.
|
||||
func ValidTaskWireFields(title, goodsID, skuColor, skuSize, maxTotalPrice string) bool {
|
||||
return validBoundedText(title, MaxTitleCodePoints) &&
|
||||
ValidAuthorizationFields(goodsID, skuColor, skuSize, maxTotalPrice)
|
||||
}
|
||||
|
||||
func ValidAuthorizationFields(goodsID, skuColor, skuSize, totalPriceCap string) bool {
|
||||
return ValidGoodsID(goodsID) &&
|
||||
validBoundedText(skuColor, MaxSKUTextCodePoints) &&
|
||||
validBoundedText(skuSize, MaxSKUTextCodePoints) &&
|
||||
ValidCanonicalMoney(totalPriceCap)
|
||||
}
|
||||
|
||||
func ValidGoodsID(value string) bool {
|
||||
if value == "" || len(value) > MaxGoodsIDCharacters {
|
||||
return false
|
||||
}
|
||||
for index := 0; index < len(value); index++ {
|
||||
if value[index] < '0' || value[index] > '9' {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func validBoundedText(value string, maximum int) bool {
|
||||
// RuneCountInString replaces malformed byte sequences with RuneError. Validate first
|
||||
// so corrupt SQLite text cannot consume the code-point budget as if it were legitimate.
|
||||
if !utf8.ValidString(value) || value == "" || strings.TrimSpace(value) != value ||
|
||||
utf8.RuneCountInString(value) > maximum {
|
||||
return false
|
||||
}
|
||||
for _, character := range value {
|
||||
// Python str.strip treats these four C0 separators as whitespace while Go
|
||||
// TrimSpace does not. Reject them everywhere so both wire models have one
|
||||
// explicit persisted-text domain instead of runtime-dependent trimming.
|
||||
if character >= '\u001c' && character <= '\u001f' {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"runtime"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -39,7 +40,10 @@ 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 = " " },
|
||||
"invalid utf8 title": func(form *Form) { form.Title = string([]byte{0xff}) },
|
||||
"long title": func(form *Form) { form.Title = strings.Repeat("😀", MaxTitleCodePoints+1) },
|
||||
"long color": func(form *Form) { form.SKUColor = string(make([]rune, maxSKUText+1)) },
|
||||
"invalid utf8 size": func(form *Form) { form.SKUSize = string([]byte{0xff}) },
|
||||
"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" },
|
||||
@@ -67,6 +71,7 @@ func TestValidateRejectsInvalidFieldsAndURLs(t *testing.T) {
|
||||
"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",
|
||||
"https://mobile.yangkeduo.com/goods.html?goods_id=" + strings.Repeat("1", MaxGoodsIDCharacters+1),
|
||||
} {
|
||||
if _, ok := CanonicalGoodsID(value); ok {
|
||||
t.Fatalf("CanonicalGoodsID accepted %q", value)
|
||||
@@ -75,19 +80,62 @@ func TestValidateRejectsInvalidFieldsAndURLs(t *testing.T) {
|
||||
}
|
||||
|
||||
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"} {
|
||||
maximum := strings.Repeat("9", MaxMoneyASCIICharacters-3) + ".00"
|
||||
for value, want := range map[string]string{"1": "1.00", "1.2": "1.20", "000.01": "0.01", "999999999999999999": "999999999999999999.00", maximum: maximum} {
|
||||
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"} {
|
||||
for _, value := range []string{"0", "0.0", "0.00", "1.", ".1", "1.000", "-1", "1e2", " 1", strings.Repeat("9", MaxMoneyASCIICharacters-2) + ".00"} {
|
||||
if got, ok := normalizeMoney(value); ok {
|
||||
t.Fatalf("normalizeMoney(%q) = %q, want rejection", value, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateAcceptsWorstLegalUnicodeFieldBounds(t *testing.T) {
|
||||
goodsID := strings.Repeat("1", MaxGoodsIDCharacters)
|
||||
draft, validation := Validate(Form{
|
||||
CreateKey: testKey, Title: strings.Repeat("😀", MaxTitleCodePoints),
|
||||
ProductURL: CanonicalURL(goodsID), SKUColor: strings.Repeat("色", MaxSKUTextCodePoints),
|
||||
SKUSize: strings.Repeat("码", MaxSKUTextCodePoints), Quantity: "1",
|
||||
MaxTotalPrice: strings.Repeat("9", MaxMoneyASCIICharacters-3) + ".00",
|
||||
})
|
||||
if !validation.Valid() || !ValidTaskWireFields(draft.Title, draft.GoodsID, draft.SKUColor, draft.SKUSize, draft.MaxTotalPrice) {
|
||||
t.Fatalf("worst legal draft = %#v, validation = %#v", draft, validation)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPersistedTextHasRuntimeIndependentC0AndNBSPDomain(t *testing.T) {
|
||||
for name, invalid := range map[string]string{
|
||||
"c0 prefix": "\u001cvalue",
|
||||
"c0 suffix": "value\u001f",
|
||||
"c0 interior": "value\u001dinside",
|
||||
"nbsp prefix": "\u00a0value",
|
||||
"nbsp suffix": "value\u00a0",
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
if validBoundedText(invalid, MaxTitleCodePoints) {
|
||||
t.Fatalf("validBoundedText(%q) accepted runtime-dependent text", invalid)
|
||||
}
|
||||
})
|
||||
}
|
||||
if !validBoundedText("left\u00a0right", MaxTitleCodePoints) {
|
||||
t.Fatal("interior NBSP must remain a valid Unicode code point")
|
||||
}
|
||||
|
||||
// Manual form input is normalized with Go TrimSpace before persistence.
|
||||
draft, validation := Validate(Form{
|
||||
CreateKey: testKey, Title: "\u00a0title\u00a0",
|
||||
ProductURL: CanonicalURL("1"), SKUColor: "\u00a0black\u00a0",
|
||||
SKUSize: "\u00a0M\u00a0", Quantity: "1", MaxTotalPrice: "1",
|
||||
})
|
||||
if !validation.Valid() || draft.Title != "title" || draft.SKUColor != "black" || draft.SKUSize != "M" {
|
||||
t.Fatalf("NBSP form normalization = %#v, errors = %#v", draft, validation)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewCreateKeyIsUUIDv4(t *testing.T) {
|
||||
key, err := NewCreateKey()
|
||||
if err != nil {
|
||||
@@ -157,6 +205,37 @@ func TestSQLiteStoreCreatesListsAndHandlesIdempotency(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSQLiteStoreRejectsInvalidDraftAtPersistenceBoundary(t *testing.T) {
|
||||
mutations := map[string]func(*Draft){
|
||||
"untrimmed title": func(draft *Draft) { draft.Title = " title" },
|
||||
"c0 interior title": func(draft *Draft) { draft.Title = "title\u001dhidden" },
|
||||
"invalid utf8 title": func(draft *Draft) { draft.Title = string([]byte{0xff}) },
|
||||
"long title": func(draft *Draft) { draft.Title = strings.Repeat("😀", MaxTitleCodePoints+1) },
|
||||
"long color": func(draft *Draft) { draft.SKUColor = strings.Repeat("色", MaxSKUTextCodePoints+1) },
|
||||
"long size": func(draft *Draft) { draft.SKUSize = strings.Repeat("码", MaxSKUTextCodePoints+1) },
|
||||
"long goods id": func(draft *Draft) { draft.GoodsID = strings.Repeat("1", MaxGoodsIDCharacters+1) },
|
||||
"long money": func(draft *Draft) { draft.MaxTotalPrice = strings.Repeat("1", MaxMoneyASCIICharacters-2) + ".00" },
|
||||
}
|
||||
for name, mutate := range mutations {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
database := migratedDatabase(t)
|
||||
store, err := NewSQLiteStore(database)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
draft := testDraft(testKey, "title")
|
||||
mutate(&draft)
|
||||
if _, err := store.CreateDraft(context.Background(), draft); !errors.Is(err, ErrInvalidDraft) {
|
||||
t.Fatalf("CreateDraft error = %v, want ErrInvalidDraft", err)
|
||||
}
|
||||
var count int
|
||||
if err := database.QueryRow("SELECT COUNT(*) FROM tasks").Scan(&count); err != nil || count != 0 {
|
||||
t.Fatalf("tasks after invalid create = %d, err %v", count, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSQLiteStoreRollsBackFailedCreate(t *testing.T) {
|
||||
database := migratedDatabase(t)
|
||||
store, err := NewSQLiteStore(database)
|
||||
|
||||
@@ -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)),
|
||||
};
|
||||
}
|
||||
@@ -58,3 +58,110 @@
|
||||
});
|
||||
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,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
@@ -3,10 +3,12 @@ package webui
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"io"
|
||||
"time"
|
||||
|
||||
"cmbuyer/admin/internal/taskdetail"
|
||||
"cmbuyer/admin/internal/tasks"
|
||||
)
|
||||
|
||||
@@ -23,6 +25,14 @@ var templates = template.Must(template.New("webui").Funcs(template.FuncMap{
|
||||
"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 是登录页面所需的非敏感展示数据。
|
||||
@@ -49,6 +59,8 @@ type TasksData struct {
|
||||
Success bool
|
||||
}
|
||||
|
||||
type TaskDetailData struct{ Detail taskdetail.Detail }
|
||||
|
||||
// RenderLogin 写入登录页。
|
||||
func RenderLogin(writer io.Writer, data LoginData) error {
|
||||
return templates.ExecuteTemplate(writer, "login.html", data)
|
||||
@@ -59,6 +71,14 @@ 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 {
|
||||
@@ -79,3 +99,64 @@ func statusLabel(status string) string {
|
||||
}
|
||||
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,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,272 @@
|
||||
-- +goose Up
|
||||
-- v4 中的 attempt、submission 或证据没有设备/session/租约归属,不能安全猜测成 claim。
|
||||
-- 在同一迁移事务中拒绝这类数据库,避免补出虚假的所有权审计链。
|
||||
CREATE TABLE task_claim_upgrade_guard (
|
||||
valid INTEGER NOT NULL CHECK (valid = 1)
|
||||
);
|
||||
|
||||
INSERT INTO task_claim_upgrade_guard (valid)
|
||||
SELECT CASE WHEN
|
||||
(SELECT COUNT(*) FROM purchase_attempts) = 0
|
||||
AND (SELECT COUNT(*) FROM order_submissions) = 0
|
||||
AND (SELECT COUNT(*) FROM evidence_assets) = 0
|
||||
THEN 1 ELSE 0 END;
|
||||
|
||||
DROP TABLE task_claim_upgrade_guard;
|
||||
|
||||
-- 该唯一索引把“一条授权只能产生一个 attempt”下沉到数据库;应用层检查不能替代它。
|
||||
CREATE UNIQUE INDEX purchase_attempts_one_per_authorization_idx
|
||||
ON purchase_attempts (authorization_id);
|
||||
|
||||
-- claim_generation 是 attempt lineage 的组成部分,不能只在应用层比较。
|
||||
CREATE UNIQUE INDEX purchase_attempts_claim_lineage_idx
|
||||
ON purchase_attempts (task_id, authorization_id, id, claim_generation);
|
||||
|
||||
CREATE TABLE purchase_attempt_claims (
|
||||
attempt_id TEXT PRIMARY KEY,
|
||||
task_id TEXT NOT NULL,
|
||||
authorization_id TEXT NOT NULL UNIQUE,
|
||||
claimed_by_device_id TEXT NOT NULL,
|
||||
session_id TEXT NOT NULL CHECK (
|
||||
length(session_id) = 36
|
||||
AND substr(session_id, 9, 1) = '-'
|
||||
AND substr(session_id, 14, 1) = '-'
|
||||
AND substr(session_id, 19, 1) = '-'
|
||||
AND substr(session_id, 24, 1) = '-'
|
||||
AND length(replace(session_id, '-', '')) = 32
|
||||
AND replace(session_id, '-', '') NOT GLOB '*[^0-9a-f]*'
|
||||
AND substr(session_id, 15, 1) = '4'
|
||||
AND substr(session_id, 20, 1) IN ('8', '9', 'a', 'b')
|
||||
),
|
||||
claim_generation INTEGER NOT NULL CHECK (
|
||||
typeof(claim_generation) = 'integer' AND claim_generation > 0
|
||||
),
|
||||
task_version INTEGER NOT NULL CHECK (
|
||||
typeof(task_version) = 'integer' AND task_version > 0
|
||||
),
|
||||
task_title TEXT NOT NULL CHECK (trim(task_title) <> ''),
|
||||
authorization_task_version INTEGER NOT NULL CHECK (
|
||||
typeof(authorization_task_version) = 'integer' AND authorization_task_version > 0
|
||||
),
|
||||
goods_id TEXT NOT NULL CHECK (trim(goods_id) <> ''),
|
||||
sku_color TEXT NOT NULL CHECK (trim(sku_color) <> ''),
|
||||
sku_size TEXT NOT NULL CHECK (trim(sku_size) <> ''),
|
||||
quantity INTEGER NOT NULL CHECK (typeof(quantity) = 'integer' AND quantity > 0),
|
||||
total_price_cap TEXT NOT NULL CHECK (trim(total_price_cap) <> ''),
|
||||
authorization_expires_at TEXT NOT NULL CHECK (
|
||||
authorization_expires_at = trim(authorization_expires_at)
|
||||
AND length(authorization_expires_at) >= 20
|
||||
AND substr(authorization_expires_at, 11, 1) = 'T'
|
||||
AND substr(authorization_expires_at, -1, 1) = 'Z'
|
||||
AND julianday(authorization_expires_at) IS NOT NULL
|
||||
),
|
||||
claim_nonce BLOB NOT NULL CHECK (
|
||||
typeof(claim_nonce) = 'blob' AND length(claim_nonce) = 32
|
||||
),
|
||||
claim_token_sha256 BLOB NOT NULL CHECK (
|
||||
typeof(claim_token_sha256) = 'blob' AND length(claim_token_sha256) = 32
|
||||
),
|
||||
lease_expires_at TEXT NOT NULL CHECK (
|
||||
lease_expires_at = trim(lease_expires_at)
|
||||
AND length(lease_expires_at) >= 20
|
||||
AND substr(lease_expires_at, 11, 1) = 'T'
|
||||
AND substr(lease_expires_at, -1, 1) = 'Z'
|
||||
AND julianday(lease_expires_at) IS NOT NULL
|
||||
),
|
||||
claimed_at TEXT NOT NULL CHECK (
|
||||
claimed_at = trim(claimed_at)
|
||||
AND length(claimed_at) >= 20
|
||||
AND substr(claimed_at, 11, 1) = 'T'
|
||||
AND substr(claimed_at, -1, 1) = 'Z'
|
||||
AND julianday(claimed_at) IS NOT NULL
|
||||
),
|
||||
closed_at TEXT CHECK (
|
||||
closed_at IS NULL OR (
|
||||
closed_at = trim(closed_at)
|
||||
AND length(closed_at) >= 20
|
||||
AND substr(closed_at, 11, 1) = 'T'
|
||||
AND substr(closed_at, -1, 1) = 'Z'
|
||||
AND julianday(closed_at) IS NOT NULL
|
||||
AND julianday(closed_at) >= julianday(claimed_at)
|
||||
)
|
||||
),
|
||||
UNIQUE (task_id, attempt_id),
|
||||
UNIQUE (attempt_id, claimed_by_device_id, session_id),
|
||||
UNIQUE (
|
||||
task_id, attempt_id, claimed_by_device_id, session_id,
|
||||
claim_generation, claim_token_sha256
|
||||
),
|
||||
UNIQUE (task_id, authorization_id, attempt_id),
|
||||
FOREIGN KEY (task_id, authorization_id, attempt_id, claim_generation)
|
||||
REFERENCES purchase_attempts(task_id, authorization_id, id, claim_generation),
|
||||
FOREIGN KEY (claimed_by_device_id) REFERENCES device_credentials(device_id)
|
||||
);
|
||||
|
||||
-- 过期、撤销或停轮询都不会自动关闭 claim;partial unique 因而阻止另一条开放归属。
|
||||
CREATE UNIQUE INDEX purchase_attempt_claims_one_open_per_device_idx
|
||||
ON purchase_attempt_claims (claimed_by_device_id)
|
||||
WHERE closed_at IS NULL;
|
||||
|
||||
CREATE TABLE task_claim_requests (
|
||||
claim_request_id TEXT PRIMARY KEY CHECK (
|
||||
length(claim_request_id) = 36
|
||||
AND substr(claim_request_id, 9, 1) = '-'
|
||||
AND substr(claim_request_id, 14, 1) = '-'
|
||||
AND substr(claim_request_id, 19, 1) = '-'
|
||||
AND substr(claim_request_id, 24, 1) = '-'
|
||||
AND length(replace(claim_request_id, '-', '')) = 32
|
||||
AND replace(claim_request_id, '-', '') NOT GLOB '*[^0-9a-f]*'
|
||||
AND substr(claim_request_id, 15, 1) = '4'
|
||||
AND substr(claim_request_id, 20, 1) IN ('8', '9', 'a', 'b')
|
||||
),
|
||||
device_id TEXT NOT NULL,
|
||||
session_id TEXT NOT NULL CHECK (
|
||||
length(session_id) = 36
|
||||
AND substr(session_id, 9, 1) = '-'
|
||||
AND substr(session_id, 14, 1) = '-'
|
||||
AND substr(session_id, 19, 1) = '-'
|
||||
AND substr(session_id, 24, 1) = '-'
|
||||
AND length(replace(session_id, '-', '')) = 32
|
||||
AND replace(session_id, '-', '') NOT GLOB '*[^0-9a-f]*'
|
||||
AND substr(session_id, 15, 1) = '4'
|
||||
AND substr(session_id, 20, 1) IN ('8', '9', 'a', 'b')
|
||||
),
|
||||
outcome TEXT NOT NULL CHECK (outcome IN ('CLAIMED', 'EMPTY', 'BLOCKED')),
|
||||
attempt_id TEXT,
|
||||
response_lease_expires_at TEXT CHECK (
|
||||
response_lease_expires_at IS NULL OR (
|
||||
response_lease_expires_at = trim(response_lease_expires_at)
|
||||
AND length(response_lease_expires_at) >= 20
|
||||
AND substr(response_lease_expires_at, 11, 1) = 'T'
|
||||
AND substr(response_lease_expires_at, -1, 1) = 'Z'
|
||||
AND julianday(response_lease_expires_at) IS NOT NULL
|
||||
)
|
||||
),
|
||||
error_code TEXT CHECK (error_code IS NULL OR error_code = 'manual_recovery_required'),
|
||||
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
|
||||
),
|
||||
CHECK (
|
||||
(outcome = 'CLAIMED' AND attempt_id IS NOT NULL AND response_lease_expires_at IS NOT NULL AND error_code IS NULL)
|
||||
OR (outcome = 'EMPTY' AND attempt_id IS NULL AND response_lease_expires_at IS NULL AND error_code IS NULL)
|
||||
OR (outcome = 'BLOCKED' AND attempt_id IS NULL AND response_lease_expires_at IS NULL AND error_code = 'manual_recovery_required')
|
||||
),
|
||||
FOREIGN KEY (device_id) REFERENCES device_credentials(device_id),
|
||||
-- EMPTY/BLOCKED 行的 attempt_id 为 NULL,SQLite 会跳过复合 FK;CLAIMED 行则必须
|
||||
-- 同时匹配原 claim 的设备和 session,不能由应用 bug 写成跨设备重放。
|
||||
FOREIGN KEY (attempt_id, device_id, session_id)
|
||||
REFERENCES purchase_attempt_claims(attempt_id, claimed_by_device_id, session_id)
|
||||
);
|
||||
|
||||
CREATE TABLE purchase_attempt_lease_renewals (
|
||||
renew_request_id TEXT PRIMARY KEY CHECK (
|
||||
length(renew_request_id) = 36
|
||||
AND substr(renew_request_id, 9, 1) = '-'
|
||||
AND substr(renew_request_id, 14, 1) = '-'
|
||||
AND substr(renew_request_id, 19, 1) = '-'
|
||||
AND substr(renew_request_id, 24, 1) = '-'
|
||||
AND length(replace(renew_request_id, '-', '')) = 32
|
||||
AND replace(renew_request_id, '-', '') NOT GLOB '*[^0-9a-f]*'
|
||||
AND substr(renew_request_id, 15, 1) = '4'
|
||||
AND substr(renew_request_id, 20, 1) IN ('8', '9', 'a', 'b')
|
||||
),
|
||||
task_id TEXT NOT NULL,
|
||||
attempt_id TEXT NOT NULL,
|
||||
device_id TEXT NOT NULL,
|
||||
session_id TEXT NOT NULL CHECK (
|
||||
length(session_id) = 36
|
||||
AND substr(session_id, 9, 1) = '-'
|
||||
AND substr(session_id, 14, 1) = '-'
|
||||
AND substr(session_id, 19, 1) = '-'
|
||||
AND substr(session_id, 24, 1) = '-'
|
||||
AND length(replace(session_id, '-', '')) = 32
|
||||
AND replace(session_id, '-', '') NOT GLOB '*[^0-9a-f]*'
|
||||
AND substr(session_id, 15, 1) = '4'
|
||||
AND substr(session_id, 20, 1) IN ('8', '9', 'a', 'b')
|
||||
),
|
||||
claim_generation INTEGER NOT NULL CHECK (
|
||||
typeof(claim_generation) = 'integer' AND claim_generation > 0
|
||||
),
|
||||
claim_token_sha256 BLOB NOT NULL CHECK (
|
||||
typeof(claim_token_sha256) = 'blob' AND length(claim_token_sha256) = 32
|
||||
),
|
||||
expected_lease_expires_at TEXT NOT NULL CHECK (
|
||||
expected_lease_expires_at = trim(expected_lease_expires_at)
|
||||
AND length(expected_lease_expires_at) >= 20
|
||||
AND substr(expected_lease_expires_at, 11, 1) = 'T'
|
||||
AND substr(expected_lease_expires_at, -1, 1) = 'Z'
|
||||
AND julianday(expected_lease_expires_at) IS NOT NULL
|
||||
),
|
||||
lease_expires_at TEXT NOT NULL CHECK (
|
||||
lease_expires_at = trim(lease_expires_at)
|
||||
AND length(lease_expires_at) >= 20
|
||||
AND substr(lease_expires_at, 11, 1) = 'T'
|
||||
AND substr(lease_expires_at, -1, 1) = 'Z'
|
||||
AND julianday(lease_expires_at) IS NOT NULL
|
||||
),
|
||||
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
|
||||
),
|
||||
FOREIGN KEY (
|
||||
task_id, attempt_id, device_id, session_id,
|
||||
claim_generation, claim_token_sha256
|
||||
) REFERENCES purchase_attempt_claims(
|
||||
task_id, attempt_id, claimed_by_device_id, session_id,
|
||||
claim_generation, claim_token_sha256
|
||||
)
|
||||
);
|
||||
|
||||
CREATE INDEX order_authorizations_claim_candidate_idx
|
||||
ON order_authorizations (status, created_at, id);
|
||||
|
||||
-- 首次证据写入必须属于认证设备当前未关闭的 claim。历史资产的幂等重放不触发 INSERT,
|
||||
-- 因而未来人工关闭 claim 后仍可稳定返回原资产。
|
||||
-- +goose StatementBegin
|
||||
CREATE TRIGGER evidence_assets_claim_owner_insert
|
||||
BEFORE INSERT ON evidence_assets
|
||||
FOR EACH ROW
|
||||
WHEN NOT EXISTS (
|
||||
SELECT 1 FROM purchase_attempt_claims AS claims
|
||||
WHERE claims.task_id = NEW.task_id
|
||||
AND claims.attempt_id = NEW.attempt_id
|
||||
AND claims.claimed_by_device_id = NEW.uploaded_by_device_id
|
||||
AND claims.closed_at IS NULL
|
||||
)
|
||||
BEGIN
|
||||
SELECT RAISE(ABORT, 'evidence claim ownership required');
|
||||
END;
|
||||
-- +goose StatementEnd
|
||||
|
||||
-- +goose Down
|
||||
-- 请求、续租、attempt、submission 和证据都是领取或下游审计事实,回滚不得静默删除。
|
||||
CREATE TABLE task_claim_downgrade_guard (
|
||||
valid INTEGER NOT NULL CHECK (valid = 1)
|
||||
);
|
||||
|
||||
INSERT INTO task_claim_downgrade_guard (valid)
|
||||
SELECT CASE WHEN
|
||||
(SELECT COUNT(*) FROM task_claim_requests) = 0
|
||||
AND (SELECT COUNT(*) FROM purchase_attempt_lease_renewals) = 0
|
||||
AND (SELECT COUNT(*) FROM purchase_attempt_claims) = 0
|
||||
AND (SELECT COUNT(*) FROM purchase_attempts) = 0
|
||||
AND (SELECT COUNT(*) FROM order_submissions) = 0
|
||||
AND (SELECT COUNT(*) FROM evidence_assets) = 0
|
||||
THEN 1 ELSE 0 END;
|
||||
|
||||
DROP TABLE task_claim_downgrade_guard;
|
||||
DROP TRIGGER evidence_assets_claim_owner_insert;
|
||||
DROP INDEX order_authorizations_claim_candidate_idx;
|
||||
DROP TABLE purchase_attempt_lease_renewals;
|
||||
DROP TABLE task_claim_requests;
|
||||
DROP INDEX purchase_attempt_claims_one_open_per_device_idx;
|
||||
DROP TABLE purchase_attempt_claims;
|
||||
DROP INDEX purchase_attempts_claim_lineage_idx;
|
||||
DROP INDEX purchase_attempts_one_per_authorization_idx;
|
||||
@@ -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,44 @@
|
||||
"""与 UI、HTTP 和拼多多页面实现无关的客户端核心契约。"""
|
||||
|
||||
from .errors import (
|
||||
AmbiguousRemoteError,
|
||||
CredentialRemoteError,
|
||||
ManualRemoteError,
|
||||
ProtocolRemoteError,
|
||||
StateError,
|
||||
ValidationError,
|
||||
)
|
||||
from .models import (
|
||||
AssetReceipt,
|
||||
AuthorizationSnapshot,
|
||||
ClaimRequest,
|
||||
ClaimedTask,
|
||||
DeviceCredentials,
|
||||
EvidenceUpload,
|
||||
PurchaseTask,
|
||||
RenewRequest,
|
||||
RenewResult,
|
||||
SecretToken,
|
||||
)
|
||||
from .ports import EvidenceSink, TaskSource
|
||||
|
||||
__all__ = [
|
||||
"AmbiguousRemoteError",
|
||||
"AssetReceipt",
|
||||
"AuthorizationSnapshot",
|
||||
"ClaimRequest",
|
||||
"ClaimedTask",
|
||||
"CredentialRemoteError",
|
||||
"DeviceCredentials",
|
||||
"EvidenceSink",
|
||||
"EvidenceUpload",
|
||||
"ManualRemoteError",
|
||||
"ProtocolRemoteError",
|
||||
"PurchaseTask",
|
||||
"RenewRequest",
|
||||
"RenewResult",
|
||||
"SecretToken",
|
||||
"StateError",
|
||||
"TaskSource",
|
||||
"ValidationError",
|
||||
]
|
||||
@@ -0,0 +1,47 @@
|
||||
"""可安全呈现的客户端错误分类。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
class ClientError(RuntimeError):
|
||||
"""错误文本只使用固定 reason code,不携带凭据、响应或本机路径。"""
|
||||
|
||||
def __init__(self, reason: str) -> None:
|
||||
self.reason = reason
|
||||
super().__init__(reason)
|
||||
|
||||
|
||||
class ValidationError(ClientError):
|
||||
"""本地输入或 wire schema 不满足固定契约。"""
|
||||
|
||||
|
||||
class StateError(ClientError):
|
||||
"""本地状态无法安全推进;调用方必须停止而不是绕过。"""
|
||||
|
||||
|
||||
class ProtectionError(ClientError):
|
||||
"""秘密保护失败。"""
|
||||
|
||||
|
||||
class SingleInstanceError(ClientError):
|
||||
"""同一配置已经由另一个采购工具进程持有。"""
|
||||
|
||||
|
||||
class RemoteError(ClientError):
|
||||
"""服务端调用的稳定错误分类。"""
|
||||
|
||||
|
||||
class AmbiguousRemoteError(RemoteError):
|
||||
"""请求结果不明;只允许以原幂等键、原载荷显式恢复。"""
|
||||
|
||||
|
||||
class CredentialRemoteError(RemoteError):
|
||||
"""设备凭据无效或已撤销。"""
|
||||
|
||||
|
||||
class ProtocolRemoteError(RemoteError):
|
||||
"""请求/响应与固定协议不兼容,不得自动重试。"""
|
||||
|
||||
|
||||
class ManualRemoteError(RemoteError):
|
||||
"""服务端要求人工处理的确定性冲突。"""
|
||||
@@ -0,0 +1,328 @@
|
||||
"""任务领取、续租和单张证据上传的不可变值对象。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
import hashlib
|
||||
from pathlib import Path
|
||||
|
||||
from .errors import ValidationError
|
||||
from .validation import (
|
||||
MAX_SKU_TEXT_CODE_POINTS,
|
||||
MAX_TITLE_CODE_POINTS,
|
||||
canonical_product_url,
|
||||
require_exact_fields,
|
||||
require_goods_id,
|
||||
require_lower_hex_64,
|
||||
require_money,
|
||||
require_persisted_text,
|
||||
require_positive_int,
|
||||
require_rfc3339_z,
|
||||
rfc3339_z_nanoseconds,
|
||||
require_string,
|
||||
require_uuid4,
|
||||
)
|
||||
|
||||
|
||||
EVIDENCE_KIND = "SKU_PANEL_GATE_1"
|
||||
PRIVACY_TIER = "INTERNAL_RAW"
|
||||
|
||||
|
||||
@dataclass(frozen=True, repr=False)
|
||||
class SecretToken:
|
||||
"""64 位小写 token;repr 永不暴露明文。"""
|
||||
|
||||
value: str
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
require_lower_hex_64(self.value, "invalid_token")
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return "SecretToken([已隐藏])"
|
||||
|
||||
def __str__(self) -> str:
|
||||
return "[已隐藏]"
|
||||
|
||||
|
||||
@dataclass(frozen=True, repr=False)
|
||||
class DeviceCredentials:
|
||||
device_id: str
|
||||
token: SecretToken
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
require_uuid4(self.device_id, "invalid_device_id")
|
||||
if not isinstance(self.token, SecretToken):
|
||||
raise ValidationError("invalid_device_token")
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"DeviceCredentials(device_id={self.device_id!r}, token=[已隐藏])"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ClaimRequest:
|
||||
session_id: str
|
||||
claim_request_id: str
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
require_uuid4(self.session_id, "invalid_session_id")
|
||||
require_uuid4(self.claim_request_id, "invalid_claim_request_id")
|
||||
|
||||
def to_wire(self) -> dict[str, object]:
|
||||
return {"session_id": self.session_id, "claim_request_id": self.claim_request_id}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PurchaseTask:
|
||||
id: str
|
||||
version: int
|
||||
title: str
|
||||
product_url: str
|
||||
goods_id: str
|
||||
sku_color: str
|
||||
sku_size: str
|
||||
quantity: int
|
||||
max_total_price: str
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
require_uuid4(self.id, "invalid_task_id")
|
||||
require_positive_int(self.version, "invalid_task_version")
|
||||
require_persisted_text(self.title, "invalid_task_title", maximum=MAX_TITLE_CODE_POINTS)
|
||||
require_goods_id(self.goods_id)
|
||||
if self.product_url != canonical_product_url(self.goods_id):
|
||||
raise ValidationError("invalid_product_url")
|
||||
require_persisted_text(self.sku_color, "invalid_sku_color", maximum=MAX_SKU_TEXT_CODE_POINTS)
|
||||
require_persisted_text(self.sku_size, "invalid_sku_size", maximum=MAX_SKU_TEXT_CODE_POINTS)
|
||||
require_positive_int(self.quantity, "invalid_quantity")
|
||||
require_money(self.max_total_price, "invalid_max_total_price")
|
||||
|
||||
@classmethod
|
||||
def from_wire(cls, value: object) -> "PurchaseTask":
|
||||
data = require_exact_fields(
|
||||
value,
|
||||
("id", "version", "title", "product_url", "goods_id", "sku_color", "sku_size", "quantity", "max_total_price"),
|
||||
)
|
||||
return cls(**data) # type: ignore[arg-type]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AuthorizationSnapshot:
|
||||
id: str
|
||||
task_version: int
|
||||
expires_at: str
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
require_uuid4(self.id, "invalid_authorization_id")
|
||||
require_positive_int(self.task_version, "invalid_authorization_task_version")
|
||||
require_rfc3339_z(self.expires_at, "invalid_authorization_expiry")
|
||||
|
||||
@classmethod
|
||||
def from_wire(cls, value: object) -> "AuthorizationSnapshot":
|
||||
data = require_exact_fields(value, ("id", "task_version", "expires_at"))
|
||||
return cls(**data) # type: ignore[arg-type]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AttemptSnapshot:
|
||||
id: str
|
||||
claim_token: SecretToken
|
||||
claim_generation: int
|
||||
lease_expires_at: str
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
require_uuid4(self.id, "invalid_attempt_id")
|
||||
if not isinstance(self.claim_token, SecretToken):
|
||||
object.__setattr__(self, "claim_token", SecretToken(self.claim_token))
|
||||
require_positive_int(self.claim_generation, "invalid_claim_generation")
|
||||
require_rfc3339_z(self.lease_expires_at, "invalid_lease_expiry")
|
||||
|
||||
@classmethod
|
||||
def from_wire(cls, value: object) -> "AttemptSnapshot":
|
||||
data = require_exact_fields(value, ("id", "claim_token", "claim_generation", "lease_expires_at"))
|
||||
return cls(
|
||||
id=data["id"], # type: ignore[arg-type]
|
||||
claim_token=SecretToken(data["claim_token"]), # type: ignore[arg-type]
|
||||
claim_generation=data["claim_generation"], # type: ignore[arg-type]
|
||||
lease_expires_at=data["lease_expires_at"], # type: ignore[arg-type]
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ClaimedTask:
|
||||
task: PurchaseTask
|
||||
authorization: AuthorizationSnapshot
|
||||
attempt: AttemptSnapshot = field(repr=False)
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if self.task.version != self.authorization.task_version + 1:
|
||||
raise ValidationError("task_authorization_version_mismatch")
|
||||
if rfc3339_z_nanoseconds(self.attempt.lease_expires_at) > rfc3339_z_nanoseconds(self.authorization.expires_at):
|
||||
raise ValidationError("claim_lease_exceeds_authorization")
|
||||
|
||||
@classmethod
|
||||
def from_wire(cls, value: object) -> "ClaimedTask":
|
||||
data = require_exact_fields(value, ("task", "authorization", "attempt"))
|
||||
return cls(
|
||||
task=PurchaseTask.from_wire(data["task"]),
|
||||
authorization=AuthorizationSnapshot.from_wire(data["authorization"]),
|
||||
attempt=AttemptSnapshot.from_wire(data["attempt"]),
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True, repr=False)
|
||||
class RenewRequest:
|
||||
task_id: str
|
||||
renew_request_id: str
|
||||
session_id: str
|
||||
attempt_id: str
|
||||
claim_generation: int
|
||||
claim_token: SecretToken
|
||||
expected_lease_expires_at: str
|
||||
authorization_expires_at: str
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
require_uuid4(self.task_id, "invalid_task_id")
|
||||
require_uuid4(self.renew_request_id, "invalid_renew_request_id")
|
||||
require_uuid4(self.session_id, "invalid_session_id")
|
||||
require_uuid4(self.attempt_id, "invalid_attempt_id")
|
||||
require_positive_int(self.claim_generation, "invalid_claim_generation")
|
||||
if not isinstance(self.claim_token, SecretToken):
|
||||
object.__setattr__(self, "claim_token", SecretToken(self.claim_token))
|
||||
require_rfc3339_z(self.expected_lease_expires_at, "invalid_expected_lease_expiry")
|
||||
require_rfc3339_z(self.authorization_expires_at, "invalid_authorization_expiry")
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return (
|
||||
f"RenewRequest(task_id={self.task_id!r}, renew_request_id={self.renew_request_id!r}, "
|
||||
"claim_token=[已隐藏])"
|
||||
)
|
||||
|
||||
def to_wire(self) -> dict[str, object]:
|
||||
return {
|
||||
"renew_request_id": self.renew_request_id,
|
||||
"session_id": self.session_id,
|
||||
"attempt_id": self.attempt_id,
|
||||
"claim_generation": self.claim_generation,
|
||||
"claim_token": self.claim_token.value,
|
||||
"expected_lease_expires_at": self.expected_lease_expires_at,
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RenewResult:
|
||||
task_id: str
|
||||
attempt_id: str
|
||||
claim_generation: int
|
||||
lease_expires_at: str
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
require_uuid4(self.task_id, "invalid_task_id")
|
||||
require_uuid4(self.attempt_id, "invalid_attempt_id")
|
||||
require_positive_int(self.claim_generation, "invalid_claim_generation")
|
||||
require_rfc3339_z(self.lease_expires_at, "invalid_lease_expiry")
|
||||
|
||||
@classmethod
|
||||
def from_wire(cls, value: object) -> "RenewResult":
|
||||
data = require_exact_fields(value, ("task_id", "attempt_id", "claim_generation", "lease_expires_at"))
|
||||
return cls(**data) # type: ignore[arg-type]
|
||||
|
||||
|
||||
@dataclass(frozen=True, repr=False)
|
||||
class EvidenceUpload:
|
||||
task_id: str
|
||||
upload_key: str
|
||||
attempt_id: str
|
||||
sha256: str
|
||||
captured_at: str
|
||||
content: bytes = field(repr=False)
|
||||
kind: str = EVIDENCE_KIND
|
||||
privacy_tier: str = PRIVACY_TIER
|
||||
width_px: int = field(init=False)
|
||||
height_px: int = field(init=False)
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
require_uuid4(self.task_id, "invalid_task_id")
|
||||
require_uuid4(self.upload_key, "invalid_upload_key")
|
||||
require_uuid4(self.attempt_id, "invalid_attempt_id")
|
||||
require_lower_hex_64(self.sha256, "invalid_evidence_sha256")
|
||||
require_rfc3339_z(self.captured_at, "invalid_captured_at")
|
||||
if self.kind != EVIDENCE_KIND or self.privacy_tier != PRIVACY_TIER:
|
||||
raise ValidationError("invalid_evidence_metadata")
|
||||
if not isinstance(self.content, bytes) or not self.content or len(self.content) > 10 * 1024 * 1024:
|
||||
raise ValidationError("invalid_evidence_size")
|
||||
if len(self.content) < 24 or not self.content.startswith(b"\x89PNG\r\n\x1a\n") or self.content[12:16] != b"IHDR":
|
||||
raise ValidationError("invalid_evidence_png")
|
||||
width = int.from_bytes(self.content[16:20], "big")
|
||||
height = int.from_bytes(self.content[20:24], "big")
|
||||
if width <= 0 or height <= 0 or width > 8192 or height > 8192 or width * height > 16_777_216:
|
||||
raise ValidationError("invalid_evidence_dimensions")
|
||||
object.__setattr__(self, "width_px", width)
|
||||
object.__setattr__(self, "height_px", height)
|
||||
if hashlib.sha256(self.content).hexdigest() != self.sha256:
|
||||
raise ValidationError("evidence_hash_mismatch")
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return (
|
||||
f"EvidenceUpload(task_id={self.task_id!r}, upload_key={self.upload_key!r}, "
|
||||
f"attempt_id={self.attempt_id!r}, byte_size={len(self.content)})"
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AssetReceipt:
|
||||
asset_id: str
|
||||
task_id: str
|
||||
attempt_id: str
|
||||
kind: str
|
||||
privacy_tier: str
|
||||
sha256: str
|
||||
byte_size: int
|
||||
content_type: str
|
||||
width_px: int
|
||||
height_px: int
|
||||
captured_at: str
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
require_uuid4(self.asset_id, "invalid_asset_id")
|
||||
require_uuid4(self.task_id, "invalid_task_id")
|
||||
require_uuid4(self.attempt_id, "invalid_attempt_id")
|
||||
if self.kind != EVIDENCE_KIND or self.privacy_tier != PRIVACY_TIER:
|
||||
raise ValidationError("invalid_asset_metadata")
|
||||
require_lower_hex_64(self.sha256, "invalid_asset_sha256")
|
||||
require_positive_int(self.byte_size, "invalid_asset_byte_size")
|
||||
if self.byte_size > 10 * 1024 * 1024 or self.content_type != "image/png":
|
||||
raise ValidationError("invalid_asset_content")
|
||||
width = require_positive_int(self.width_px, "invalid_asset_width")
|
||||
height = require_positive_int(self.height_px, "invalid_asset_height")
|
||||
if width > 8192 or height > 8192 or width * height > 16_777_216:
|
||||
raise ValidationError("invalid_asset_dimensions")
|
||||
require_rfc3339_z(self.captured_at, "invalid_captured_at")
|
||||
|
||||
@classmethod
|
||||
def from_wire(cls, value: object) -> "AssetReceipt":
|
||||
data = require_exact_fields(
|
||||
value,
|
||||
("asset_id", "task_id", "attempt_id", "kind", "privacy_tier", "sha256", "byte_size", "content_type", "width_px", "height_px", "captured_at"),
|
||||
)
|
||||
return cls(**data) # type: ignore[arg-type]
|
||||
|
||||
|
||||
@dataclass(frozen=True, repr=False)
|
||||
class ScreenshotAsset:
|
||||
"""调用方显式选择的唯一 PNG;路径不会进入 repr 或 HTTP。"""
|
||||
|
||||
path: Path = field(repr=False)
|
||||
task_id: str
|
||||
attempt_id: str
|
||||
captured_at: str
|
||||
kind: str = EVIDENCE_KIND
|
||||
privacy_tier: str = PRIVACY_TIER
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
require_uuid4(self.task_id, "invalid_task_id")
|
||||
require_uuid4(self.attempt_id, "invalid_attempt_id")
|
||||
require_rfc3339_z(self.captured_at, "invalid_captured_at")
|
||||
if self.kind != EVIDENCE_KIND or self.privacy_tier != PRIVACY_TIER:
|
||||
raise ValidationError("invalid_evidence_metadata")
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"ScreenshotAsset(task_id={self.task_id!r}, attempt_id={self.attempt_id!r}, path=[已隐藏])"
|
||||
@@ -0,0 +1,17 @@
|
||||
"""由 HTTP 适配器实现的窄端口。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Protocol
|
||||
|
||||
from .models import AssetReceipt, ClaimRequest, ClaimedTask, DeviceCredentials, EvidenceUpload, RenewRequest, RenewResult
|
||||
|
||||
|
||||
class TaskSource(Protocol):
|
||||
def claim_next(self, credentials: DeviceCredentials, request: ClaimRequest) -> ClaimedTask | None: ...
|
||||
|
||||
def renew(self, credentials: DeviceCredentials, request: RenewRequest) -> RenewResult: ...
|
||||
|
||||
|
||||
class EvidenceSink(Protocol):
|
||||
def upload(self, credentials: DeviceCredentials, evidence: EvidenceUpload) -> AssetReceipt: ...
|
||||
@@ -0,0 +1,197 @@
|
||||
"""客户端与服务端共享 wire 的严格值校验。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import calendar
|
||||
from datetime import datetime, timezone
|
||||
import json
|
||||
import re
|
||||
from typing import Any, Iterable, Mapping
|
||||
from urllib.parse import quote
|
||||
|
||||
from .errors import ValidationError
|
||||
|
||||
|
||||
UUID4_RE = re.compile(
|
||||
r"[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}"
|
||||
)
|
||||
LOWER_HEX_64_RE = re.compile(r"[0-9a-f]{64}")
|
||||
RFC3339_Z_RE = re.compile(
|
||||
r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,9})?Z"
|
||||
)
|
||||
MONEY_RE = re.compile(r"(?:0|[1-9][0-9]*)\.[0-9]{2}")
|
||||
GOODS_ID_RE = re.compile(r"[0-9]+")
|
||||
MAX_TITLE_CODE_POINTS = 120
|
||||
MAX_SKU_TEXT_CODE_POINTS = 80
|
||||
MAX_GOODS_ID_ASCII_CHARACTERS = 32
|
||||
MAX_MONEY_ASCII_CHARACTERS = 32
|
||||
# Go strings.TrimSpace uses Unicode White_Space plus the six ASCII space
|
||||
# characters below, but unlike Python str.strip it does not include U+001C--
|
||||
# U+001F. Keep the wire contract independent of either runtime's defaults.
|
||||
GO_UNICODE_WHITE_SPACE = "\t\n\v\f\r \u0085\u00a0\u1680\u2000\u2001\u2002\u2003\u2004\u2005\u2006\u2007\u2008\u2009\u200a\u2028\u2029\u202f\u205f\u3000"
|
||||
|
||||
|
||||
def require_string(value: object, reason: str, *, maximum: int = 4096) -> str:
|
||||
if not isinstance(value, str) or not value or len(value) > maximum:
|
||||
raise ValidationError(reason)
|
||||
if any(0xD800 <= ord(character) <= 0xDFFF for character in value):
|
||||
raise ValidationError(reason)
|
||||
return value
|
||||
|
||||
|
||||
def require_persisted_text(value: object, reason: str, *, maximum: int) -> str:
|
||||
"""Validate text stored by Go after TrimSpace, without Python trim drift."""
|
||||
|
||||
text = require_string(value, reason, maximum=maximum)
|
||||
if text.strip(GO_UNICODE_WHITE_SPACE) != text:
|
||||
raise ValidationError(reason)
|
||||
# Python str.strip treats these C0 separators as whitespace while Go does
|
||||
# not. Reject them anywhere on both ends instead of assigning them two
|
||||
# runtime-dependent meanings.
|
||||
if any(0x1C <= ord(character) <= 0x1F for character in text):
|
||||
raise ValidationError(reason)
|
||||
return text
|
||||
|
||||
|
||||
def require_uuid4(value: object, reason: str = "invalid_uuid") -> str:
|
||||
text = require_string(value, reason, maximum=36)
|
||||
if UUID4_RE.fullmatch(text) is None:
|
||||
raise ValidationError(reason)
|
||||
return text
|
||||
|
||||
|
||||
def require_lower_hex_64(value: object, reason: str = "invalid_hex") -> str:
|
||||
text = require_string(value, reason, maximum=64)
|
||||
if LOWER_HEX_64_RE.fullmatch(text) is None:
|
||||
raise ValidationError(reason)
|
||||
return text
|
||||
|
||||
|
||||
def require_rfc3339_z(value: object, reason: str = "invalid_timestamp") -> str:
|
||||
text = require_string(value, reason, maximum=40)
|
||||
if RFC3339_Z_RE.fullmatch(text) is None:
|
||||
raise ValidationError(reason)
|
||||
parsed: datetime | None = None
|
||||
try:
|
||||
parsed = datetime.fromisoformat(text[:-1] + "+00:00")
|
||||
except ValueError:
|
||||
pass
|
||||
if parsed is None:
|
||||
raise ValidationError(reason)
|
||||
if parsed.utcoffset() is None or parsed.utcoffset().total_seconds() != 0:
|
||||
raise ValidationError(reason)
|
||||
return text
|
||||
|
||||
|
||||
def rfc3339_z_nanoseconds(value: object, reason: str = "invalid_timestamp") -> int:
|
||||
"""无浮点、无微秒截断地把 UTC RFC3339Nano 转成纳秒时间轴。"""
|
||||
|
||||
text = require_rfc3339_z(value, reason)
|
||||
base: datetime | None = None
|
||||
try:
|
||||
base = datetime.strptime(text[:19], "%Y-%m-%dT%H:%M:%S").replace(tzinfo=timezone.utc)
|
||||
except ValueError:
|
||||
pass
|
||||
if base is None:
|
||||
raise ValidationError(reason)
|
||||
fraction = "" if len(text) == 20 else text[20:-1]
|
||||
nanoseconds = int(fraction.ljust(9, "0")) if fraction else 0
|
||||
return calendar.timegm(base.utctimetuple()) * 1_000_000_000 + nanoseconds
|
||||
|
||||
|
||||
def datetime_nanoseconds(value: datetime, reason: str = "invalid_timestamp") -> int:
|
||||
if not isinstance(value, datetime) or value.utcoffset() is None:
|
||||
raise ValidationError(reason)
|
||||
utc = value.astimezone(timezone.utc)
|
||||
return calendar.timegm(utc.utctimetuple()) * 1_000_000_000 + utc.microsecond * 1_000
|
||||
|
||||
|
||||
def require_positive_int(value: object, reason: str = "invalid_integer") -> int:
|
||||
# bool 是 int 的子类;wire 中必须显式拒绝 true/false。
|
||||
if type(value) is not int or value <= 0 or value > 9_223_372_036_854_775_807:
|
||||
raise ValidationError(reason)
|
||||
return value
|
||||
|
||||
|
||||
def require_money(value: object, reason: str = "invalid_money") -> str:
|
||||
text = require_string(value, reason, maximum=MAX_MONEY_ASCII_CHARACTERS)
|
||||
if MONEY_RE.fullmatch(text) is None or text == "0.00":
|
||||
raise ValidationError(reason)
|
||||
return text
|
||||
|
||||
|
||||
def require_goods_id(value: object) -> str:
|
||||
text = require_string(value, "invalid_goods_id", maximum=MAX_GOODS_ID_ASCII_CHARACTERS)
|
||||
if GOODS_ID_RE.fullmatch(text) is None:
|
||||
raise ValidationError("invalid_goods_id")
|
||||
return text
|
||||
|
||||
|
||||
def canonical_product_url(goods_id: str) -> str:
|
||||
require_goods_id(goods_id)
|
||||
return "https://mobile.yangkeduo.com/goods.html?goods_id=" + quote(goods_id, safe="")
|
||||
|
||||
|
||||
def require_exact_fields(
|
||||
value: object,
|
||||
required: Iterable[str],
|
||||
reason: str = "invalid_schema",
|
||||
) -> Mapping[str, Any]:
|
||||
if not isinstance(value, dict):
|
||||
raise ValidationError(reason)
|
||||
expected = frozenset(required)
|
||||
if frozenset(value) != expected:
|
||||
raise ValidationError(reason)
|
||||
return value
|
||||
|
||||
|
||||
def strict_json_loads(raw: bytes, *, maximum: int) -> object:
|
||||
if not isinstance(raw, bytes) or len(raw) == 0 or len(raw) > maximum:
|
||||
raise ValidationError("invalid_json_size")
|
||||
text: str | None = None
|
||||
try:
|
||||
text = raw.decode("utf-8")
|
||||
except UnicodeDecodeError:
|
||||
pass
|
||||
if text is None:
|
||||
raise ValidationError("invalid_json_utf8")
|
||||
if text.startswith("\ufeff"):
|
||||
raise ValidationError("invalid_json_bom")
|
||||
|
||||
def pairs_hook(pairs: list[tuple[str, Any]]) -> dict[str, Any]:
|
||||
result: dict[str, Any] = {}
|
||||
for key, value in pairs:
|
||||
if key in result:
|
||||
raise ValidationError("duplicate_json_key")
|
||||
result[key] = value
|
||||
return result
|
||||
|
||||
def reject_number(_: str) -> object:
|
||||
raise ValidationError("invalid_json_number")
|
||||
|
||||
def parse_integer(value: str) -> int:
|
||||
digits = value[1:] if value.startswith("-") else value
|
||||
if len(digits) > 19:
|
||||
raise ValidationError("invalid_json_integer")
|
||||
parsed = int(value)
|
||||
if parsed < -9_223_372_036_854_775_808 or parsed > 9_223_372_036_854_775_807:
|
||||
raise ValidationError("invalid_json_integer")
|
||||
return parsed
|
||||
|
||||
parsed_json: object | None = None
|
||||
failed = False
|
||||
try:
|
||||
parsed_json = json.loads(
|
||||
text,
|
||||
object_pairs_hook=pairs_hook,
|
||||
parse_int=parse_integer,
|
||||
parse_float=reject_number,
|
||||
parse_constant=reject_number,
|
||||
)
|
||||
except ValidationError:
|
||||
raise
|
||||
except (json.JSONDecodeError, UnicodeError, ValueError, RecursionError):
|
||||
failed = True
|
||||
if failed:
|
||||
raise ValidationError("invalid_json")
|
||||
return parsed_json
|
||||
@@ -0,0 +1,18 @@
|
||||
"""Windows 本地恢复、凭据保护和单实例底座。"""
|
||||
|
||||
from .models import PollingSession, ProfileSettings, RecoverySnapshot
|
||||
from .facade import DurableClientGateway
|
||||
from .protection import DpapiProtector, SecretProtector
|
||||
from .single_instance import NamedMutex
|
||||
from .store import LocalStateStore
|
||||
|
||||
__all__ = [
|
||||
"DpapiProtector",
|
||||
"DurableClientGateway",
|
||||
"LocalStateStore",
|
||||
"NamedMutex",
|
||||
"PollingSession",
|
||||
"ProfileSettings",
|
||||
"RecoverySnapshot",
|
||||
"SecretProtector",
|
||||
]
|
||||
@@ -0,0 +1,82 @@
|
||||
"""把“先持久化,再发一次 HTTP”固化成 T-304/T-306 的唯一集成入口。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from cmbuyer_client.core.errors import (
|
||||
AmbiguousRemoteError,
|
||||
CredentialRemoteError,
|
||||
ManualRemoteError,
|
||||
ProtocolRemoteError,
|
||||
)
|
||||
from cmbuyer_client.core.models import AssetReceipt, ClaimedTask, ScreenshotAsset
|
||||
from cmbuyer_client.core.ports import EvidenceSink, TaskSource
|
||||
|
||||
from .store import LocalStateStore
|
||||
|
||||
|
||||
class DurableClientGateway:
|
||||
"""不隐藏重试;每次方法调用最多发一次请求,结果不明保留原槽。"""
|
||||
|
||||
def __init__(self, store: LocalStateStore, task_source: TaskSource, evidence_sink: EvidenceSink) -> None:
|
||||
self._store = store
|
||||
self._task_source = task_source
|
||||
self._evidence_sink = evidence_sink
|
||||
|
||||
def claim_next(self, profile_id: str) -> ClaimedTask | None:
|
||||
request = self._store.prepare_claim(profile_id)
|
||||
credentials = self._store.load_profile(profile_id).credentials
|
||||
try:
|
||||
claimed = self._task_source.claim_next(credentials, request)
|
||||
except AmbiguousRemoteError:
|
||||
raise
|
||||
except CredentialRemoteError:
|
||||
raise
|
||||
except ProtocolRemoteError:
|
||||
self._store.mark_claim_terminal(profile_id, request, "PROTOCOL")
|
||||
raise
|
||||
except ManualRemoteError:
|
||||
self._store.mark_claim_terminal(profile_id, request, "MANUAL")
|
||||
raise
|
||||
if claimed is None:
|
||||
self._store.commit_claim_empty(profile_id, request)
|
||||
return None
|
||||
self._store.commit_claim_success(profile_id, request, claimed)
|
||||
return claimed
|
||||
|
||||
def renew(self, profile_id: str):
|
||||
request = self._store.prepare_renew(profile_id)
|
||||
credentials = self._store.load_profile(profile_id).credentials
|
||||
try:
|
||||
result = self._task_source.renew(credentials, request)
|
||||
except AmbiguousRemoteError:
|
||||
raise
|
||||
except CredentialRemoteError:
|
||||
raise
|
||||
except ProtocolRemoteError:
|
||||
self._store.mark_renew_terminal(profile_id, request, "PROTOCOL")
|
||||
raise
|
||||
except ManualRemoteError:
|
||||
self._store.mark_renew_terminal(profile_id, request, "MANUAL")
|
||||
raise
|
||||
self._store.commit_renew_success(profile_id, request, result)
|
||||
return result
|
||||
|
||||
def upload_evidence(self, profile_id: str, asset: ScreenshotAsset) -> AssetReceipt:
|
||||
prepared = self._store.prepare_or_resume_evidence(profile_id, asset)
|
||||
if isinstance(prepared, AssetReceipt):
|
||||
return prepared
|
||||
credentials = self._store.load_profile(profile_id).credentials
|
||||
try:
|
||||
receipt = self._evidence_sink.upload(credentials, prepared)
|
||||
except AmbiguousRemoteError:
|
||||
raise
|
||||
except CredentialRemoteError:
|
||||
raise
|
||||
except ProtocolRemoteError:
|
||||
self._store.mark_evidence_terminal(profile_id, prepared, "PROTOCOL")
|
||||
raise
|
||||
except ManualRemoteError:
|
||||
self._store.mark_evidence_terminal(profile_id, prepared, "MANUAL")
|
||||
raise
|
||||
self._store.commit_evidence_success(profile_id, prepared, receipt)
|
||||
return receipt
|
||||
@@ -0,0 +1,86 @@
|
||||
"""供 T-304 使用的稳定本地配置与恢复快照。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
import re
|
||||
|
||||
from cmbuyer_client.core.models import ClaimRequest, ClaimedTask, DeviceCredentials, RenewRequest
|
||||
from cmbuyer_client.core.validation import require_string, require_uuid4
|
||||
|
||||
|
||||
LOOPBACK_SERVICE_URL = "http://127.0.0.1:8080"
|
||||
PROFILE_ID_RE = re.compile(r"[a-z0-9][a-z0-9_-]{0,63}")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ProfileSettings:
|
||||
profile_id: str
|
||||
service_url: str
|
||||
device_id: str
|
||||
adb_path: str
|
||||
adb_serial: str
|
||||
transport: str
|
||||
poll_interval_seconds: int = 15
|
||||
failure_threshold: int = 3
|
||||
http_timeout_seconds: int = 10
|
||||
step_timeout_seconds: int = 45
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if not isinstance(self.profile_id, str) or PROFILE_ID_RE.fullmatch(self.profile_id) is None:
|
||||
raise ValueError("invalid_profile_id")
|
||||
if self.service_url != LOOPBACK_SERVICE_URL:
|
||||
raise ValueError("service_url_not_allowed")
|
||||
require_uuid4(self.device_id, "invalid_device_id")
|
||||
require_string(self.adb_path, "invalid_adb_path", maximum=1024)
|
||||
require_string(self.adb_serial, "invalid_adb_serial", maximum=200)
|
||||
if self.transport not in ("usb", "wifi"):
|
||||
raise ValueError("invalid_transport")
|
||||
_range(self.poll_interval_seconds, 5, 300, "invalid_poll_interval")
|
||||
_range(self.failure_threshold, 1, 10, "invalid_failure_threshold")
|
||||
_range(self.http_timeout_seconds, 1, 120, "invalid_http_timeout")
|
||||
_range(self.step_timeout_seconds, 5, 300, "invalid_step_timeout")
|
||||
|
||||
|
||||
@dataclass(frozen=True, repr=False)
|
||||
class LoadedProfile:
|
||||
settings: ProfileSettings
|
||||
credentials: DeviceCredentials = field(repr=False)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"LoadedProfile(settings={self.settings!r}, credentials=[已隐藏])"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PollingSession:
|
||||
profile_id: str
|
||||
session_id: str
|
||||
accept_new: bool
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
require_uuid4(self.session_id, "invalid_session_id")
|
||||
if type(self.accept_new) is not bool:
|
||||
raise ValueError("invalid_accept_new")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PendingEvidence:
|
||||
task_id: str
|
||||
attempt_id: str
|
||||
kind: str
|
||||
upload_key: str
|
||||
status: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RecoverySnapshot:
|
||||
session: PollingSession | None
|
||||
pending_claim: ClaimRequest | None
|
||||
active_claim: ClaimedTask | None = field(repr=False)
|
||||
pending_renew: RenewRequest | None = field(repr=False)
|
||||
pending_evidence: tuple[PendingEvidence, ...]
|
||||
|
||||
|
||||
def _range(value: object, minimum: int, maximum: int, reason: str) -> None:
|
||||
if type(value) is not int or not minimum <= value <= maximum:
|
||||
raise ValueError(reason)
|
||||
@@ -0,0 +1,112 @@
|
||||
"""Windows 当前用户范围 DPAPI 封装;生产环境绝不降级为明文。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import ctypes
|
||||
from ctypes import wintypes
|
||||
import os
|
||||
import re
|
||||
from typing import Protocol
|
||||
|
||||
from cmbuyer_client.core.errors import ProtectionError
|
||||
|
||||
|
||||
class SecretProtector(Protocol):
|
||||
def protect(self, plaintext: bytes, *, purpose: str) -> bytes: ...
|
||||
|
||||
def unprotect(self, ciphertext: bytes, *, purpose: str) -> bytes: ...
|
||||
|
||||
|
||||
class _DataBlob(ctypes.Structure):
|
||||
_fields_ = (("cbData", wintypes.DWORD), ("pbData", ctypes.POINTER(ctypes.c_ubyte)))
|
||||
|
||||
|
||||
def _blob(data: bytes) -> tuple[_DataBlob, object]:
|
||||
buffer = (ctypes.c_ubyte * len(data)).from_buffer_copy(data) if data else (ctypes.c_ubyte * 1)()
|
||||
return _DataBlob(len(data), ctypes.cast(buffer, ctypes.POINTER(ctypes.c_ubyte))), buffer
|
||||
|
||||
|
||||
class DpapiProtector:
|
||||
"""使用 CryptProtectData/UI_FORBIDDEN;错误只暴露固定 reason code。"""
|
||||
|
||||
_UI_FORBIDDEN = 0x1
|
||||
_ENTROPY_PREFIX = b"cmbuyer-localstate-v1:"
|
||||
_PURPOSE_RE = re.compile(
|
||||
r"(?:device-token:[a-z0-9][a-z0-9_-]{0,63}:[0-9a-f-]{36}|"
|
||||
r"claim-token:[a-z0-9][a-z0-9_-]{0,63}:[0-9a-f-]{36})",
|
||||
flags=re.ASCII,
|
||||
)
|
||||
|
||||
def __init__(self) -> None:
|
||||
if os.name != "nt":
|
||||
raise ProtectionError("dpapi_requires_windows")
|
||||
self._crypt32 = ctypes.WinDLL("crypt32", use_last_error=True)
|
||||
self._kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
|
||||
self._crypt32.CryptProtectData.argtypes = (
|
||||
ctypes.POINTER(_DataBlob),
|
||||
wintypes.LPCWSTR,
|
||||
ctypes.POINTER(_DataBlob),
|
||||
wintypes.LPVOID,
|
||||
wintypes.LPVOID,
|
||||
wintypes.DWORD,
|
||||
ctypes.POINTER(_DataBlob),
|
||||
)
|
||||
self._crypt32.CryptProtectData.restype = wintypes.BOOL
|
||||
self._crypt32.CryptUnprotectData.argtypes = (
|
||||
ctypes.POINTER(_DataBlob),
|
||||
ctypes.POINTER(wintypes.LPWSTR),
|
||||
ctypes.POINTER(_DataBlob),
|
||||
wintypes.LPVOID,
|
||||
wintypes.LPVOID,
|
||||
wintypes.DWORD,
|
||||
ctypes.POINTER(_DataBlob),
|
||||
)
|
||||
self._crypt32.CryptUnprotectData.restype = wintypes.BOOL
|
||||
self._kernel32.LocalFree.argtypes = (wintypes.HLOCAL,)
|
||||
self._kernel32.LocalFree.restype = wintypes.HLOCAL
|
||||
|
||||
def protect(self, plaintext: bytes, *, purpose: str) -> bytes:
|
||||
if not isinstance(plaintext, bytes) or not plaintext:
|
||||
raise ProtectionError("invalid_plaintext")
|
||||
entropy = self._entropy(purpose)
|
||||
source, source_buffer = _blob(plaintext)
|
||||
entropy_blob, entropy_buffer = _blob(entropy)
|
||||
output = _DataBlob()
|
||||
if not self._crypt32.CryptProtectData(
|
||||
ctypes.byref(source), None, ctypes.byref(entropy_blob), None, None, self._UI_FORBIDDEN, ctypes.byref(output)
|
||||
):
|
||||
raise ProtectionError("dpapi_protect_failed")
|
||||
# ctypes 指针不持有底层 Python buffer;局部引用必须活到系统调用返回。
|
||||
del source_buffer, entropy_buffer
|
||||
return self._take_output(output, "dpapi_protect_failed")
|
||||
|
||||
def unprotect(self, ciphertext: bytes, *, purpose: str) -> bytes:
|
||||
if not isinstance(ciphertext, bytes) or not ciphertext:
|
||||
raise ProtectionError("invalid_ciphertext")
|
||||
entropy = self._entropy(purpose)
|
||||
source, source_buffer = _blob(ciphertext)
|
||||
entropy_blob, entropy_buffer = _blob(entropy)
|
||||
output = _DataBlob()
|
||||
description = wintypes.LPWSTR()
|
||||
if not self._crypt32.CryptUnprotectData(
|
||||
ctypes.byref(source), ctypes.byref(description), ctypes.byref(entropy_blob), None, None, self._UI_FORBIDDEN, ctypes.byref(output)
|
||||
):
|
||||
raise ProtectionError("dpapi_unprotect_failed")
|
||||
del source_buffer, entropy_buffer
|
||||
if description:
|
||||
self._kernel32.LocalFree(ctypes.cast(description, wintypes.HLOCAL))
|
||||
return self._take_output(output, "dpapi_unprotect_failed")
|
||||
|
||||
def _take_output(self, output: _DataBlob, reason: str) -> bytes:
|
||||
if not output.pbData or output.cbData <= 0:
|
||||
raise ProtectionError(reason)
|
||||
try:
|
||||
return ctypes.string_at(output.pbData, output.cbData)
|
||||
finally:
|
||||
self._kernel32.LocalFree(ctypes.cast(output.pbData, wintypes.HLOCAL))
|
||||
|
||||
@classmethod
|
||||
def _entropy(cls, purpose: str) -> bytes:
|
||||
if not isinstance(purpose, str) or cls._PURPOSE_RE.fullmatch(purpose) is None:
|
||||
raise ProtectionError("invalid_protection_purpose")
|
||||
return cls._ENTROPY_PREFIX + purpose.encode("ascii")
|
||||
@@ -0,0 +1,51 @@
|
||||
"""同一本地数据库的 Windows named mutex。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import ctypes
|
||||
from ctypes import wintypes
|
||||
import hashlib
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
from cmbuyer_client.core.errors import SingleInstanceError
|
||||
|
||||
|
||||
class NamedMutex:
|
||||
_ALREADY_EXISTS = 183
|
||||
|
||||
def __init__(self, database_path: Path) -> None:
|
||||
if os.name != "nt":
|
||||
raise SingleInstanceError("named_mutex_requires_windows")
|
||||
canonical = str(database_path.expanduser().resolve()).casefold().encode("utf-8")
|
||||
# Global namespace 覆盖同一 Windows 用户的多个交互 session;默认 DACL 不向其他用户泄露句柄。
|
||||
name = "Global\\cmbuyer-" + hashlib.sha256(canonical).hexdigest()
|
||||
kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
|
||||
kernel32.CreateMutexW.argtypes = (wintypes.LPVOID, wintypes.BOOL, wintypes.LPCWSTR)
|
||||
kernel32.CreateMutexW.restype = wintypes.HANDLE
|
||||
kernel32.ReleaseMutex.argtypes = (wintypes.HANDLE,)
|
||||
kernel32.ReleaseMutex.restype = wintypes.BOOL
|
||||
kernel32.CloseHandle.argtypes = (wintypes.HANDLE,)
|
||||
kernel32.CloseHandle.restype = wintypes.BOOL
|
||||
ctypes.set_last_error(0)
|
||||
handle = kernel32.CreateMutexW(None, True, name)
|
||||
if not handle:
|
||||
raise SingleInstanceError("named_mutex_failed")
|
||||
if ctypes.get_last_error() == self._ALREADY_EXISTS:
|
||||
kernel32.CloseHandle(handle)
|
||||
raise SingleInstanceError("instance_already_running")
|
||||
self._kernel32 = kernel32
|
||||
self._handle = handle
|
||||
|
||||
def close(self) -> None:
|
||||
handle = getattr(self, "_handle", None)
|
||||
if handle:
|
||||
self._kernel32.ReleaseMutex(handle)
|
||||
self._kernel32.CloseHandle(handle)
|
||||
self._handle = None
|
||||
|
||||
def __enter__(self) -> "NamedMutex":
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type: object, exc: object, traceback: object) -> None:
|
||||
self.close()
|
||||
File diff suppressed because it is too large
Load Diff
@@ -23,6 +23,8 @@ _KEY_VALUE_PATTERN = re.compile(
|
||||
flags=re.IGNORECASE,
|
||||
)
|
||||
_PHONE_PATTERN = re.compile(r"(?<!\d)1[3-9]\d{9}(?!\d)")
|
||||
_BEARER_PATTERN = re.compile(r"(?i)\bBearer\s+[0-9a-f]{64}\b")
|
||||
_BARE_TOKEN_PATTERN = re.compile(r"(?<![0-9a-fA-F])[0-9a-fA-F]{64}(?![0-9a-fA-F])")
|
||||
|
||||
|
||||
def redact_text(message: str) -> str:
|
||||
@@ -31,7 +33,9 @@ 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)
|
||||
redacted = _BEARER_PATTERN.sub("Bearer " + REDACTED, message)
|
||||
redacted = _KEY_VALUE_PATTERN.sub(replace_key_value, redacted)
|
||||
redacted = _BARE_TOKEN_PATTERN.sub(REDACTED, redacted)
|
||||
return _PHONE_PATTERN.sub(REDACTED, redacted)
|
||||
|
||||
|
||||
@@ -47,6 +51,16 @@ class SensitiveDataFilter(logging.Filter):
|
||||
return True
|
||||
|
||||
|
||||
class RedactingFormatter(logging.Formatter):
|
||||
"""再次处理完整格式化文本,覆盖异常 traceback 中的敏感值。"""
|
||||
|
||||
def format(self, record: logging.LogRecord) -> str:
|
||||
return redact_text(super().format(record))
|
||||
|
||||
def formatException(self, exc_info: tuple[type[BaseException], BaseException, object]) -> str:
|
||||
return redact_text(super().formatException(exc_info))
|
||||
|
||||
|
||||
def configure_application_logger(paths: RuntimePaths) -> logging.Logger:
|
||||
"""配置唯一的 UTF-8 文件日志,并确保其先经过脱敏过滤。"""
|
||||
|
||||
@@ -61,6 +75,6 @@ def configure_application_logger(paths: RuntimePaths) -> logging.Logger:
|
||||
|
||||
handler = logging.FileHandler(Path(paths.logs) / "client.log", encoding="utf-8")
|
||||
handler.addFilter(SensitiveDataFilter())
|
||||
handler.setFormatter(logging.Formatter("%(asctime)s %(levelname)s %(message)s"))
|
||||
handler.setFormatter(RedactingFormatter("%(asctime)s %(levelname)s %(message)s"))
|
||||
logger.addHandler(handler)
|
||||
return logger
|
||||
|
||||
@@ -1,15 +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,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,7 @@
|
||||
"""只连接固定本机采购服务的 HTTP 适配器。"""
|
||||
|
||||
from .evidence_sink import HttpEvidenceSink
|
||||
from .http_transport import HttpTransport, LOOPBACK_SERVICE_URL
|
||||
from .task_source import HttpTaskSource
|
||||
|
||||
__all__ = ["HttpEvidenceSink", "HttpTaskSource", "HttpTransport", "LOOPBACK_SERVICE_URL"]
|
||||
@@ -0,0 +1,84 @@
|
||||
"""仅上传调用方显式提供的单个 PNG 的窄 EvidenceSink。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from cmbuyer_client.core.errors import AmbiguousRemoteError, ProtocolRemoteError, ValidationError
|
||||
from cmbuyer_client.core.models import AssetReceipt, DeviceCredentials, EvidenceUpload
|
||||
from cmbuyer_client.core.validation import rfc3339_z_nanoseconds
|
||||
|
||||
from .http_transport import HttpTransport
|
||||
from .wire import SMALL_RESPONSE_LIMIT, classify_bodyless_error, common_headers, parse_json_response
|
||||
|
||||
|
||||
class HttpEvidenceSink:
|
||||
def __init__(self, transport: HttpTransport) -> None:
|
||||
self._transport = transport
|
||||
|
||||
def upload(self, credentials: DeviceCredentials, evidence: EvidenceUpload) -> AssetReceipt:
|
||||
boundary = "cmbuyer-" + evidence.upload_key.replace("-", "")
|
||||
marker = ("--" + boundary).encode("ascii")
|
||||
if marker in evidence.content:
|
||||
raise ProtocolRemoteError("multipart_boundary_collision")
|
||||
body = _multipart_body(boundary, evidence)
|
||||
response = self._transport.request(
|
||||
"POST",
|
||||
f"/api/v1/tasks/{evidence.task_id}/evidence",
|
||||
common_headers(
|
||||
credentials.device_id,
|
||||
credentials.token.value,
|
||||
"multipart/form-data; boundary=" + boundary,
|
||||
),
|
||||
body,
|
||||
response_limit=SMALL_RESPONSE_LIMIT,
|
||||
)
|
||||
if response.status not in (200, 201):
|
||||
classify_bodyless_error(response)
|
||||
try:
|
||||
receipt = AssetReceipt.from_wire(parse_json_response(response, maximum=SMALL_RESPONSE_LIMIT))
|
||||
except ValidationError as error:
|
||||
raise AmbiguousRemoteError("invalid_evidence_success_response") from error
|
||||
if (
|
||||
receipt.task_id != evidence.task_id
|
||||
or receipt.attempt_id != evidence.attempt_id
|
||||
or receipt.kind != evidence.kind
|
||||
or receipt.privacy_tier != evidence.privacy_tier
|
||||
or receipt.sha256 != evidence.sha256
|
||||
or receipt.byte_size != len(evidence.content)
|
||||
or receipt.width_px != evidence.width_px
|
||||
or receipt.height_px != evidence.height_px
|
||||
or rfc3339_z_nanoseconds(receipt.captured_at) != rfc3339_z_nanoseconds(evidence.captured_at)
|
||||
):
|
||||
raise AmbiguousRemoteError("evidence_response_mismatch")
|
||||
return receipt
|
||||
|
||||
|
||||
def _multipart_body(boundary: str, evidence: EvidenceUpload) -> bytes:
|
||||
chunks: list[bytes] = []
|
||||
|
||||
def add_field(name: str, value: str) -> None:
|
||||
chunks.extend(
|
||||
(
|
||||
f"--{boundary}\r\n".encode("ascii"),
|
||||
f'Content-Disposition: form-data; name="{name}"\r\n\r\n'.encode("ascii"),
|
||||
value.encode("utf-8"),
|
||||
b"\r\n",
|
||||
)
|
||||
)
|
||||
|
||||
add_field("upload_key", evidence.upload_key)
|
||||
add_field("attempt_id", evidence.attempt_id)
|
||||
add_field("kind", evidence.kind)
|
||||
add_field("privacy_tier", evidence.privacy_tier)
|
||||
add_field("sha256", evidence.sha256)
|
||||
add_field("captured_at", evidence.captured_at)
|
||||
chunks.extend(
|
||||
(
|
||||
f"--{boundary}\r\n".encode("ascii"),
|
||||
b'Content-Disposition: form-data; name="file"; filename="evidence.png"\r\n',
|
||||
b"Content-Type: image/png\r\n\r\n",
|
||||
evidence.content,
|
||||
b"\r\n",
|
||||
f"--{boundary}--\r\n".encode("ascii"),
|
||||
)
|
||||
)
|
||||
return b"".join(chunks)
|
||||
@@ -0,0 +1,116 @@
|
||||
"""无代理、无重定向、无隐藏重试的 localhost HTTP transport。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
import http.client
|
||||
import re
|
||||
from typing import Callable, Iterable
|
||||
|
||||
from cmbuyer_client.core.errors import AmbiguousRemoteError, ProtocolRemoteError
|
||||
|
||||
|
||||
LOOPBACK_SERVICE_URL = "http://127.0.0.1:8080"
|
||||
_HOST = "127.0.0.1"
|
||||
_PORT = 8080
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class HttpResponse:
|
||||
status: int
|
||||
headers: tuple[tuple[str, str], ...]
|
||||
body: bytes
|
||||
|
||||
def header_values(self, name: str) -> tuple[str, ...]:
|
||||
wanted = name.lower()
|
||||
return tuple(value for key, value in self.headers if key.lower() == wanted)
|
||||
|
||||
|
||||
class HttpTransport:
|
||||
"""每次调用只创建一个直连 TCP 请求;重试只能由持久化恢复层决定。"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
service_url: str = LOOPBACK_SERVICE_URL,
|
||||
*,
|
||||
timeout_seconds: int = 10,
|
||||
connection_factory: Callable[..., http.client.HTTPConnection] = http.client.HTTPConnection,
|
||||
) -> None:
|
||||
if service_url != LOOPBACK_SERVICE_URL:
|
||||
raise ProtocolRemoteError("service_url_not_allowed")
|
||||
if type(timeout_seconds) is not int or not 1 <= timeout_seconds <= 120:
|
||||
raise ProtocolRemoteError("invalid_http_timeout")
|
||||
self._timeout_seconds = timeout_seconds
|
||||
self._connection_factory = connection_factory
|
||||
|
||||
def request(
|
||||
self,
|
||||
method: str,
|
||||
path: str,
|
||||
headers: Iterable[tuple[str, str]],
|
||||
body: bytes,
|
||||
*,
|
||||
response_limit: int,
|
||||
) -> HttpResponse:
|
||||
if method != "POST" or not path.startswith("/api/v1/") or "?" in path or "#" in path:
|
||||
raise ProtocolRemoteError("invalid_http_target")
|
||||
if not isinstance(body, bytes) or type(response_limit) is not int or response_limit <= 0:
|
||||
raise ProtocolRemoteError("invalid_http_request")
|
||||
header_items = tuple(headers)
|
||||
normalized: dict[str, str] = {}
|
||||
for key, value in header_items:
|
||||
lowered = key.lower()
|
||||
if lowered in normalized or "\r" in key or "\n" in key or "\r" in value or "\n" in value:
|
||||
raise ProtocolRemoteError("invalid_http_headers")
|
||||
normalized[lowered] = value
|
||||
|
||||
connection: http.client.HTTPConnection | None = None
|
||||
result: HttpResponse | None = None
|
||||
failure: str | None = None
|
||||
try:
|
||||
connection = self._connection_factory(_HOST, _PORT, timeout=self._timeout_seconds)
|
||||
connection.request(method, path, body=body, headers={key: value for key, value in header_items})
|
||||
response = connection.getresponse()
|
||||
response_headers = tuple(response.getheaders())
|
||||
content_lengths = tuple(value for key, value in response_headers if key.lower() == "content-length")
|
||||
transfer_encodings = tuple(value for key, value in response_headers if key.lower() == "transfer-encoding")
|
||||
if len(content_lengths) > 1:
|
||||
raise AmbiguousRemoteError("invalid_content_length")
|
||||
if content_lengths and transfer_encodings:
|
||||
raise AmbiguousRemoteError("ambiguous_response_framing")
|
||||
if len(transfer_encodings) > 1 or (
|
||||
transfer_encodings and transfer_encodings[0].lower() != "chunked"
|
||||
):
|
||||
raise AmbiguousRemoteError("invalid_transfer_encoding")
|
||||
declared = content_lengths[0] if content_lengths else None
|
||||
declared_length: int | None = None
|
||||
if declared is not None:
|
||||
if re.fullmatch(r"[0-9]+", declared, flags=re.ASCII) is None:
|
||||
raise AmbiguousRemoteError("invalid_content_length")
|
||||
if len(declared) > 10:
|
||||
raise AmbiguousRemoteError("response_too_large")
|
||||
declared_length = int(declared)
|
||||
if declared_length > response_limit:
|
||||
raise AmbiguousRemoteError("response_too_large")
|
||||
response_body = response.read(response_limit + 1)
|
||||
if len(response_body) > response_limit:
|
||||
raise AmbiguousRemoteError("response_too_large")
|
||||
if declared_length is not None and len(response_body) != declared_length:
|
||||
raise AmbiguousRemoteError("truncated_response")
|
||||
result = HttpResponse(response.status, response_headers, response_body)
|
||||
except AmbiguousRemoteError as error:
|
||||
failure = error.reason
|
||||
except (OSError, TimeoutError, http.client.HTTPException):
|
||||
failure = "http_result_unknown"
|
||||
finally:
|
||||
if connection is not None:
|
||||
try:
|
||||
connection.close()
|
||||
except OSError:
|
||||
if result is None:
|
||||
failure = "http_result_unknown"
|
||||
if failure is not None:
|
||||
raise AmbiguousRemoteError(failure)
|
||||
if result is None:
|
||||
raise AmbiguousRemoteError("http_result_unknown")
|
||||
return result
|
||||
@@ -0,0 +1,75 @@
|
||||
"""领取与续租的固定 localhost HTTP 适配器。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from cmbuyer_client.core.errors import AmbiguousRemoteError, ValidationError
|
||||
from cmbuyer_client.core.models import ClaimRequest, ClaimedTask, DeviceCredentials, RenewRequest, RenewResult
|
||||
from cmbuyer_client.core.validation import rfc3339_z_nanoseconds
|
||||
|
||||
from .http_transport import HttpTransport
|
||||
from .wire import (
|
||||
JSON_RESPONSE_LIMIT,
|
||||
SMALL_RESPONSE_LIMIT,
|
||||
classify_json_error,
|
||||
common_headers,
|
||||
encode_json,
|
||||
parse_json_response,
|
||||
)
|
||||
|
||||
|
||||
class HttpTaskSource:
|
||||
def __init__(self, transport: HttpTransport) -> None:
|
||||
self._transport = transport
|
||||
|
||||
def claim_next(self, credentials: DeviceCredentials, request: ClaimRequest) -> ClaimedTask | None:
|
||||
body = encode_json(request.to_wire())
|
||||
response = self._transport.request(
|
||||
"POST",
|
||||
"/api/v1/tasks/claim-next",
|
||||
common_headers(credentials.device_id, credentials.token.value, "application/json"),
|
||||
body,
|
||||
response_limit=JSON_RESPONSE_LIMIT,
|
||||
)
|
||||
if response.status == 204:
|
||||
if response.body or response.header_values("Content-Encoding"):
|
||||
raise AmbiguousRemoteError("invalid_empty_claim_response")
|
||||
return None
|
||||
if response.status != 200:
|
||||
classify_json_error(
|
||||
response,
|
||||
allowed_409=frozenset(("idempotency_conflict", "claim_requires_manual")),
|
||||
)
|
||||
try:
|
||||
claimed = ClaimedTask.from_wire(parse_json_response(response, maximum=JSON_RESPONSE_LIMIT))
|
||||
except ValidationError as error:
|
||||
raise AmbiguousRemoteError("invalid_claim_success_response") from error
|
||||
if rfc3339_z_nanoseconds(claimed.attempt.lease_expires_at) > rfc3339_z_nanoseconds(claimed.authorization.expires_at):
|
||||
raise AmbiguousRemoteError("invalid_claim_lease")
|
||||
return claimed
|
||||
|
||||
def renew(self, credentials: DeviceCredentials, request: RenewRequest) -> RenewResult:
|
||||
response = self._transport.request(
|
||||
"POST",
|
||||
f"/api/v1/tasks/{request.task_id}/lease/renew",
|
||||
common_headers(credentials.device_id, credentials.token.value, "application/json"),
|
||||
encode_json(request.to_wire()),
|
||||
response_limit=SMALL_RESPONSE_LIMIT,
|
||||
)
|
||||
if response.status != 200:
|
||||
classify_json_error(
|
||||
response,
|
||||
allowed_409=frozenset(("idempotency_conflict", "claim_not_current")),
|
||||
)
|
||||
try:
|
||||
result = RenewResult.from_wire(parse_json_response(response, maximum=SMALL_RESPONSE_LIMIT))
|
||||
except ValidationError as error:
|
||||
raise AmbiguousRemoteError("invalid_renew_success_response") from error
|
||||
if (
|
||||
result.task_id != request.task_id
|
||||
or result.attempt_id != request.attempt_id
|
||||
or result.claim_generation != request.claim_generation
|
||||
or rfc3339_z_nanoseconds(result.lease_expires_at) < rfc3339_z_nanoseconds(request.expected_lease_expires_at)
|
||||
or rfc3339_z_nanoseconds(result.lease_expires_at) > rfc3339_z_nanoseconds(request.authorization_expires_at)
|
||||
):
|
||||
raise AmbiguousRemoteError("renew_response_mismatch")
|
||||
return result
|
||||
@@ -0,0 +1,106 @@
|
||||
"""T-302/T-204 固定 HTTP wire 的编码、解码与错误分类。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any, Mapping
|
||||
|
||||
from cmbuyer_client.core.errors import (
|
||||
AmbiguousRemoteError,
|
||||
CredentialRemoteError,
|
||||
ManualRemoteError,
|
||||
ProtocolRemoteError,
|
||||
ValidationError,
|
||||
)
|
||||
from cmbuyer_client.core.validation import require_exact_fields, strict_json_loads
|
||||
|
||||
from .http_transport import HttpResponse
|
||||
|
||||
|
||||
JSON_REQUEST_LIMIT = 4096
|
||||
JSON_RESPONSE_LIMIT = 32 * 1024
|
||||
SMALL_RESPONSE_LIMIT = 8 * 1024
|
||||
JSON_CONTENT_TYPES = frozenset(("application/json", "application/json; charset=utf-8"))
|
||||
|
||||
|
||||
def encode_json(value: Mapping[str, object]) -> bytes:
|
||||
body = json.dumps(value, ensure_ascii=False, separators=(",", ":"), allow_nan=False).encode("utf-8")
|
||||
if len(body) > JSON_REQUEST_LIMIT:
|
||||
raise ProtocolRemoteError("request_too_large")
|
||||
return body
|
||||
|
||||
|
||||
def common_headers(device_id: str, token: str, content_type: str) -> tuple[tuple[str, str], ...]:
|
||||
return (
|
||||
("Authorization", "Bearer " + token),
|
||||
("X-CMBuyer-Device-ID", device_id),
|
||||
("Accept", "application/json"),
|
||||
("Content-Type", content_type),
|
||||
)
|
||||
|
||||
|
||||
def parse_json_response(response: HttpResponse, *, maximum: int) -> object:
|
||||
encodings = response.header_values("Content-Encoding")
|
||||
types = response.header_values("Content-Type")
|
||||
if encodings or len(types) != 1 or types[0].lower() not in JSON_CONTENT_TYPES:
|
||||
raise ValidationError("invalid_response_content_type")
|
||||
return strict_json_loads(response.body, maximum=maximum)
|
||||
|
||||
|
||||
def require_empty_response(response: HttpResponse) -> None:
|
||||
if response.body or response.header_values("Content-Encoding"):
|
||||
raise ProtocolRemoteError("unexpected_error_body")
|
||||
|
||||
|
||||
def classify_json_error(response: HttpResponse, *, allowed_409: frozenset[str]) -> None:
|
||||
"""抛出错误,不返回。调用方只在非成功状态使用。"""
|
||||
|
||||
if 200 <= response.status <= 299:
|
||||
# 服务端可能已提交幂等事实;未知 2xx 绝不能终结本地槽或换 key。
|
||||
raise AmbiguousRemoteError("unknown_success_status")
|
||||
if response.status == 401:
|
||||
require_empty_response(response)
|
||||
raise CredentialRemoteError("device_credential_rejected")
|
||||
if response.status == 503 or 500 <= response.status <= 599:
|
||||
# 5xx 无法证明服务端是否在提交响应前完成事务。
|
||||
raise AmbiguousRemoteError("server_result_unknown")
|
||||
if response.status == 409:
|
||||
try:
|
||||
data = require_exact_fields(parse_json_response(response, maximum=SMALL_RESPONSE_LIMIT), ("error",))
|
||||
code = data["error"]
|
||||
except ValidationError as error:
|
||||
raise ProtocolRemoteError("invalid_conflict_response") from error
|
||||
if not isinstance(code, str) or code not in allowed_409:
|
||||
raise ProtocolRemoteError("unknown_conflict")
|
||||
raise ManualRemoteError(code)
|
||||
expected = {400: "invalid_request", 413: "request_too_large", 415: "unsupported_media_type"}
|
||||
if response.status in expected:
|
||||
try:
|
||||
data = require_exact_fields(parse_json_response(response, maximum=SMALL_RESPONSE_LIMIT), ("error",))
|
||||
except ValidationError as error:
|
||||
raise ProtocolRemoteError("invalid_error_response") from error
|
||||
if data["error"] != expected[response.status]:
|
||||
raise ProtocolRemoteError("unexpected_error_code")
|
||||
raise ProtocolRemoteError(expected[response.status])
|
||||
if 300 <= response.status <= 399:
|
||||
raise ProtocolRemoteError("redirect_rejected")
|
||||
raise ProtocolRemoteError("unexpected_http_status")
|
||||
|
||||
|
||||
def classify_bodyless_error(response: HttpResponse) -> None:
|
||||
if 200 <= response.status <= 299:
|
||||
raise AmbiguousRemoteError("unknown_success_status")
|
||||
if response.status == 401:
|
||||
require_empty_response(response)
|
||||
raise CredentialRemoteError("device_credential_rejected")
|
||||
if response.status == 503 or 500 <= response.status <= 599:
|
||||
raise AmbiguousRemoteError("server_result_unknown")
|
||||
if response.status == 409:
|
||||
require_empty_response(response)
|
||||
raise ManualRemoteError("evidence_conflict")
|
||||
if response.status in (400, 403, 413, 415):
|
||||
require_empty_response(response)
|
||||
raise ProtocolRemoteError("evidence_request_rejected")
|
||||
if 300 <= response.status <= 399:
|
||||
raise ProtocolRemoteError("redirect_rejected")
|
||||
raise ProtocolRemoteError("unexpected_http_status")
|
||||
@@ -5,6 +5,7 @@ from __future__ import annotations
|
||||
from dataclasses import dataclass
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -17,21 +18,34 @@ class RuntimePaths:
|
||||
root: Path
|
||||
logs: Path
|
||||
artifacts: Path
|
||||
state: Path
|
||||
database: Path
|
||||
|
||||
@classmethod
|
||||
def from_root(cls, root: Path) -> "RuntimePaths":
|
||||
resolved_root = root.expanduser()
|
||||
# 路径在进程启动时一次性固化;之后 cwd 改变不能打开第二套数据库或绕过原 mutex。
|
||||
resolved_root = root.expanduser().resolve(strict=False)
|
||||
state = resolved_root / "state"
|
||||
return cls(
|
||||
root=resolved_root,
|
||||
logs=resolved_root / "logs",
|
||||
artifacts=resolved_root / "artifacts",
|
||||
state=state,
|
||||
database=state / "client-state.sqlite3",
|
||||
)
|
||||
|
||||
@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")
|
||||
local_root = Path(local_app_data).expanduser()
|
||||
if not local_root.is_absolute():
|
||||
raise RuntimeError("local_app_data_must_be_absolute")
|
||||
return cls.from_root(local_root / "cmbuyer")
|
||||
|
||||
if os.name == "nt":
|
||||
# Windows 上回退到 home 会悄悄创建第二套状态库并绕开同一 mutex,必须失败闭合。
|
||||
raise RuntimeError("local_app_data_required")
|
||||
|
||||
return cls.from_root(Path.home() / ".local" / "share" / "cmbuyer")
|
||||
|
||||
@@ -40,3 +54,49 @@ class RuntimePaths:
|
||||
|
||||
self.logs.mkdir(parents=True, exist_ok=True)
|
||||
self.artifacts.mkdir(parents=True, exist_ok=True)
|
||||
self.state.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
|
||||
@dataclass
|
||||
class LocalStateRuntime:
|
||||
"""持有 named mutex 与本地状态库,保证 mutex 总是先取得。"""
|
||||
|
||||
paths: RuntimePaths
|
||||
mutex: Any
|
||||
store: Any
|
||||
|
||||
@classmethod
|
||||
def open(
|
||||
cls,
|
||||
paths: RuntimePaths | None = None,
|
||||
*,
|
||||
mutex_factory: Callable[[Path], Any] | None = None,
|
||||
protector_factory: Callable[[], Any] | None = None,
|
||||
store_factory: Callable[[Path, Any], Any] | None = None,
|
||||
) -> "LocalStateRuntime":
|
||||
from .localstate.protection import DpapiProtector
|
||||
from .localstate.single_instance import NamedMutex
|
||||
from .localstate.store import LocalStateStore
|
||||
|
||||
selected = paths or RuntimePaths.default()
|
||||
selected.ensure_exists()
|
||||
make_mutex = mutex_factory or NamedMutex
|
||||
make_protector = protector_factory or DpapiProtector
|
||||
make_store = store_factory or LocalStateStore
|
||||
mutex = make_mutex(selected.database)
|
||||
try:
|
||||
protector = make_protector()
|
||||
store = make_store(selected.database, protector)
|
||||
except Exception:
|
||||
mutex.close()
|
||||
raise
|
||||
return cls(selected, mutex, store)
|
||||
|
||||
def close(self) -> None:
|
||||
self.mutex.close()
|
||||
|
||||
def __enter__(self) -> "LocalStateRuntime":
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type: object, exc: object, traceback: object) -> None:
|
||||
self.close()
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
"""core tests。"""
|
||||
@@ -0,0 +1,187 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import unittest
|
||||
|
||||
from cmbuyer_client.core.errors import ValidationError
|
||||
from cmbuyer_client.core.models import ClaimedTask, SecretToken
|
||||
from cmbuyer_client.core.validation import rfc3339_z_nanoseconds, strict_json_loads
|
||||
|
||||
|
||||
TASK_ID = "13c9f507-7473-4fa6-8d71-8786c34c6301"
|
||||
AUTH_ID = "73c9f507-7473-4fa6-8d71-8786c34c6301"
|
||||
ATTEMPT_ID = "53c9f507-7473-4fa6-8d71-8786c34c6301"
|
||||
TOKEN = "0123456789abcdef" * 4
|
||||
|
||||
|
||||
def claim_wire() -> dict[str, object]:
|
||||
return {
|
||||
"task": {
|
||||
"id": TASK_ID,
|
||||
"version": 3,
|
||||
"title": "纯棉短袖",
|
||||
"product_url": "https://mobile.yangkeduo.com/goods.html?goods_id=937122477375",
|
||||
"goods_id": "937122477375",
|
||||
"sku_color": "黑色CHA(纯棉)",
|
||||
"sku_size": "M(建议100-115)",
|
||||
"quantity": 2,
|
||||
"max_total_price": "30.00",
|
||||
},
|
||||
"authorization": {"id": AUTH_ID, "task_version": 2, "expires_at": "2026-08-04T10:00:00Z"},
|
||||
"attempt": {
|
||||
"id": ATTEMPT_ID,
|
||||
"claim_token": TOKEN,
|
||||
"claim_generation": 1,
|
||||
"lease_expires_at": "2026-08-04T09:05:00Z",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
class CoreModelsTests(unittest.TestCase):
|
||||
def test_claim_wire_round_trip_and_secret_repr(self) -> None:
|
||||
claimed = ClaimedTask.from_wire(claim_wire())
|
||||
self.assertEqual(claimed.task.quantity, 2)
|
||||
self.assertNotIn(TOKEN, repr(claimed))
|
||||
self.assertNotIn(TOKEN, repr(SecretToken(TOKEN)))
|
||||
|
||||
def test_rejects_bool_float_wrong_url_and_version_drift(self) -> None:
|
||||
mutations = []
|
||||
for mutate in (
|
||||
lambda value: value["task"].__setitem__("quantity", True),
|
||||
lambda value: value["task"].__setitem__("max_total_price", "30.0"),
|
||||
lambda value: value["task"].__setitem__("max_total_price", "0.00"),
|
||||
lambda value: value["task"].__setitem__("product_url", "https://example.invalid/"),
|
||||
lambda value: value["task"].__setitem__("version", 2),
|
||||
):
|
||||
value = claim_wire()
|
||||
mutate(value)
|
||||
mutations.append(value)
|
||||
for value in mutations:
|
||||
with self.subTest(value=value), self.assertRaises(ValidationError):
|
||||
ClaimedTask.from_wire(value)
|
||||
|
||||
def test_strict_json_rejects_nested_duplicates_float_nan_bom_and_utf8(self) -> None:
|
||||
bad_values = (
|
||||
b'{"task":{"id":1,"id":2}}',
|
||||
b'{"value":1.0}',
|
||||
b'{"value":NaN}',
|
||||
b'\xef\xbb\xbf{}',
|
||||
b'\xff',
|
||||
('{"value":' + "9" * 5000 + '}').encode(),
|
||||
)
|
||||
for raw in bad_values:
|
||||
with self.subTest(raw=raw), self.assertRaises(ValidationError):
|
||||
strict_json_loads(raw, maximum=1024)
|
||||
self.assertEqual(strict_json_loads(json.dumps({"value": 1}).encode(), maximum=1024), {"value": 1})
|
||||
|
||||
def test_rfc3339_nano_comparison_preserves_all_fraction_digits(self) -> None:
|
||||
equal = (
|
||||
"2026-08-04T09:01:00.1Z",
|
||||
"2026-08-04T09:01:00.100000Z",
|
||||
"2026-08-04T09:01:00.100000000Z",
|
||||
)
|
||||
self.assertEqual(len({rfc3339_z_nanoseconds(value) for value in equal}), 1)
|
||||
ordered = (
|
||||
"2026-08-04T09:01:00Z",
|
||||
"2026-08-04T09:01:00.000001Z",
|
||||
"2026-08-04T09:01:00.0000011Z",
|
||||
"2026-08-04T09:01:00.000001101Z",
|
||||
"2026-08-04T09:01:01Z",
|
||||
)
|
||||
self.assertEqual([rfc3339_z_nanoseconds(value) for value in ordered], sorted(rfc3339_z_nanoseconds(value) for value in ordered))
|
||||
|
||||
def test_money_accepts_positive_subunit_but_rejects_zero_and_noncanonical_forms(self) -> None:
|
||||
value = claim_wire()
|
||||
value["task"]["max_total_price"] = "0.01"
|
||||
self.assertEqual(ClaimedTask.from_wire(value).task.max_total_price, "0.01")
|
||||
for invalid in ("0.00", "00.01", "1.0", "1.000", "1", 1.0, "1.12", "1.٠٠", "12.00"):
|
||||
with self.subTest(invalid=invalid), self.assertRaises(ValidationError):
|
||||
changed = claim_wire()
|
||||
changed["task"]["max_total_price"] = invalid
|
||||
ClaimedTask.from_wire(changed)
|
||||
|
||||
wide_quantity = claim_wire()
|
||||
wide_quantity["task"]["quantity"] = 2_147_483_648
|
||||
self.assertEqual(ClaimedTask.from_wire(wide_quantity).task.quantity, 2_147_483_648)
|
||||
for invalid_goods in ("123", "1٢3"):
|
||||
changed = claim_wire()
|
||||
changed["task"]["goods_id"] = invalid_goods
|
||||
changed["task"]["product_url"] = "https://mobile.yangkeduo.com/goods.html?goods_id=" + invalid_goods
|
||||
with self.subTest(invalid_goods=invalid_goods), self.assertRaises(ValidationError):
|
||||
ClaimedTask.from_wire(changed)
|
||||
too_large = claim_wire()
|
||||
too_large["task"]["quantity"] = 9_223_372_036_854_775_808
|
||||
with self.assertRaises(ValidationError):
|
||||
ClaimedTask.from_wire(too_large)
|
||||
|
||||
def test_claim_fields_share_explicit_server_bounds(self) -> None:
|
||||
legal = claim_wire()
|
||||
legal_goods = "1" * 32
|
||||
legal["task"].update(
|
||||
version=9_223_372_036_854_775_807,
|
||||
title="😀" * 120,
|
||||
goods_id=legal_goods,
|
||||
product_url="https://mobile.yangkeduo.com/goods.html?goods_id=" + legal_goods,
|
||||
sku_color="色" * 80,
|
||||
sku_size="码" * 80,
|
||||
max_total_price="1" * 29 + ".00",
|
||||
)
|
||||
legal["authorization"]["task_version"] = 9_223_372_036_854_775_806
|
||||
claimed = ClaimedTask.from_wire(legal)
|
||||
self.assertEqual(len(claimed.task.title), 120)
|
||||
# Python's default ensure_ascii=True expands astral characters to surrogate
|
||||
# escape pairs, so this is a conservative parser-budget proof as well.
|
||||
self.assertLess(len(json.dumps(legal, separators=(",", ":")).encode()), 32 * 1024)
|
||||
|
||||
mutations = (
|
||||
("title", "😀" * 121),
|
||||
("title", " title"),
|
||||
("sku_color", "色" * 81),
|
||||
("sku_color", "black "),
|
||||
("sku_size", "码" * 81),
|
||||
("sku_size", " M"),
|
||||
("max_total_price", "1" * 30 + ".00"),
|
||||
)
|
||||
for field, invalid in mutations:
|
||||
changed = claim_wire()
|
||||
changed["task"][field] = invalid
|
||||
with self.subTest(field=field, length=len(invalid)), self.assertRaises(ValidationError):
|
||||
ClaimedTask.from_wire(changed)
|
||||
|
||||
overlong_goods = "1" * 33
|
||||
changed = claim_wire()
|
||||
changed["task"].update(
|
||||
goods_id=overlong_goods,
|
||||
product_url="https://mobile.yangkeduo.com/goods.html?goods_id=" + overlong_goods,
|
||||
)
|
||||
with self.assertRaises(ValidationError):
|
||||
ClaimedTask.from_wire(changed)
|
||||
|
||||
def test_wire_strings_reject_lone_surrogates_but_accept_valid_pair(self) -> None:
|
||||
for escaped in (r'"\ud800"', r'"\udc00"'):
|
||||
value = claim_wire()
|
||||
value["task"]["title"] = json.loads(escaped)
|
||||
with self.subTest(escaped=escaped), self.assertRaises(ValidationError):
|
||||
ClaimedTask.from_wire(value)
|
||||
value = claim_wire()
|
||||
value["task"]["title"] = json.loads(r'"\ud83d\ude00"')
|
||||
self.assertEqual(ClaimedTask.from_wire(value).task.title, "😀")
|
||||
|
||||
def test_title_rejects_ascii_and_unicode_whitespace_only(self) -> None:
|
||||
for title in ("", " \t\r\n", "\u3000", " \u3000\t", "\u00a0title", "title\u00a0"):
|
||||
value = claim_wire()
|
||||
value["task"]["title"] = title
|
||||
with self.subTest(title=repr(title)), self.assertRaises(ValidationError):
|
||||
ClaimedTask.from_wire(value)
|
||||
|
||||
def test_persisted_text_has_runtime_independent_c0_and_nbsp_domain(self) -> None:
|
||||
for field in ("title", "sku_color", "sku_size"):
|
||||
for invalid in ("\u001cvalue", "value\u001f", "value\u001dinside", "\u00a0value", "value\u00a0"):
|
||||
value = claim_wire()
|
||||
value["task"][field] = invalid
|
||||
with self.subTest(field=field, invalid=repr(invalid)), self.assertRaises(ValidationError):
|
||||
ClaimedTask.from_wire(value)
|
||||
|
||||
value = claim_wire()
|
||||
value["task"][field] = "left\u00a0right"
|
||||
self.assertEqual(getattr(ClaimedTask.from_wire(value).task, field), "left\u00a0right")
|
||||
@@ -0,0 +1 @@
|
||||
"""localstate tests。"""
|
||||
@@ -0,0 +1,36 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import ast
|
||||
from pathlib import Path
|
||||
import unittest
|
||||
|
||||
|
||||
SRC = Path(__file__).resolve().parents[2] / "src" / "cmbuyer_client"
|
||||
SCOPED = tuple((SRC / name) for name in ("core", "remote", "localstate"))
|
||||
|
||||
|
||||
class StaticBoundaryTests(unittest.TestCase):
|
||||
def test_scoped_modules_do_not_import_device_pdd_or_unapproved_capabilities(self) -> None:
|
||||
forbidden_modules = ("cmbuyer_client.device", "cmbuyer_client.pdd")
|
||||
forbidden_text = (
|
||||
"ResultSink",
|
||||
"/events",
|
||||
"/fail",
|
||||
"/submission-fence",
|
||||
"/result",
|
||||
"click_permitted",
|
||||
)
|
||||
for directory in SCOPED:
|
||||
for path in directory.glob("*.py"):
|
||||
text = path.read_text(encoding="utf-8")
|
||||
tree = ast.parse(text)
|
||||
imports = []
|
||||
for node in ast.walk(tree):
|
||||
if isinstance(node, ast.Import):
|
||||
imports.extend(alias.name for alias in node.names)
|
||||
elif isinstance(node, ast.ImportFrom) and node.module:
|
||||
imports.append(node.module)
|
||||
for module in forbidden_modules:
|
||||
self.assertFalse(any(name.startswith(module) for name in imports), (path, module))
|
||||
for value in forbidden_text:
|
||||
self.assertNotIn(value, text, (path, value))
|
||||
@@ -0,0 +1,321 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
from datetime import datetime, timezone
|
||||
import hashlib
|
||||
import json
|
||||
from pathlib import Path
|
||||
import sqlite3
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest import mock
|
||||
|
||||
from cmbuyer_client.core.errors import AmbiguousRemoteError, CredentialRemoteError, ManualRemoteError, StateError
|
||||
from cmbuyer_client.core.models import AssetReceipt, ClaimedTask, RenewResult, ScreenshotAsset, SecretToken
|
||||
from cmbuyer_client.localstate.facade import DurableClientGateway
|
||||
from cmbuyer_client.localstate.models import ProfileSettings
|
||||
from cmbuyer_client.localstate.store import LocalStateStore
|
||||
from cmbuyer_client.remote.evidence_sink import HttpEvidenceSink
|
||||
from cmbuyer_client.remote.http_transport import HttpResponse
|
||||
from cmbuyer_client.remote.task_source import HttpTaskSource
|
||||
from tests.core.test_models import ATTEMPT_ID, TASK_ID, claim_wire
|
||||
from tests.localstate.test_store import DEVICE_TOKEN, FakeProtector, PROFILE
|
||||
from tests.remote.test_task_source import DEVICE_ID, FakeTransport, response
|
||||
|
||||
|
||||
PNG = base64.b64decode(
|
||||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII="
|
||||
)
|
||||
|
||||
|
||||
class InspectingSource:
|
||||
def __init__(self, store: LocalStateStore, profile_id: str) -> None:
|
||||
self.store = store
|
||||
self.profile_id = profile_id
|
||||
self.calls = 0
|
||||
self.mode = "success"
|
||||
self.request_ids: list[str] = []
|
||||
|
||||
def claim_next(self, credentials, request):
|
||||
self.calls += 1
|
||||
self.request_ids.append(request.claim_request_id)
|
||||
# HTTP 适配器被调用时,幂等请求必须已经 durable。
|
||||
self.assert_pending(request.claim_request_id)
|
||||
if self.mode == "ambiguous":
|
||||
raise AmbiguousRemoteError("http_result_unknown")
|
||||
if self.mode == "manual":
|
||||
raise ManualRemoteError("claim_requires_manual")
|
||||
return ClaimedTask.from_wire(claim_wire())
|
||||
|
||||
def renew(self, credentials, request):
|
||||
raise AssertionError("not used")
|
||||
|
||||
def assert_pending(self, request_id: str) -> None:
|
||||
snapshot = LocalStateStore(self.store.database_path, FakeProtector()).recovery_snapshot(self.profile_id)
|
||||
if snapshot.pending_claim is None or snapshot.pending_claim.claim_request_id != request_id:
|
||||
raise AssertionError("HTTP happened before durable prepare")
|
||||
|
||||
|
||||
class InspectingSink:
|
||||
def __init__(self, store: LocalStateStore, profile_id: str) -> None:
|
||||
self.store = store
|
||||
self.profile_id = profile_id
|
||||
self.calls = 0
|
||||
|
||||
def upload(self, credentials, upload):
|
||||
self.calls += 1
|
||||
snapshot = LocalStateStore(self.store.database_path, FakeProtector()).recovery_snapshot(self.profile_id)
|
||||
if not snapshot.pending_evidence or snapshot.pending_evidence[0].upload_key != upload.upload_key:
|
||||
raise AssertionError("HTTP happened before durable evidence slot")
|
||||
return AssetReceipt(
|
||||
"63c9f507-7473-4fa6-8d71-8786c34c6301",
|
||||
upload.task_id,
|
||||
upload.attempt_id,
|
||||
upload.kind,
|
||||
upload.privacy_tier,
|
||||
upload.sha256,
|
||||
len(upload.content),
|
||||
"image/png",
|
||||
1,
|
||||
1,
|
||||
upload.captured_at,
|
||||
)
|
||||
|
||||
|
||||
class InspectingRenewSource:
|
||||
def __init__(self, store: LocalStateStore, profile_id: str) -> None:
|
||||
self.store = store
|
||||
self.profile_id = profile_id
|
||||
self.calls = 0
|
||||
|
||||
def claim_next(self, credentials, request):
|
||||
raise AssertionError("not used")
|
||||
|
||||
def renew(self, credentials, request):
|
||||
self.calls += 1
|
||||
snapshot = LocalStateStore(self.store.database_path, FakeProtector()).recovery_snapshot(self.profile_id)
|
||||
if snapshot.pending_renew is None or snapshot.pending_renew.renew_request_id != request.renew_request_id:
|
||||
raise AssertionError("HTTP happened before durable renew")
|
||||
return RenewResult(request.task_id, request.attempt_id, request.claim_generation, request.expected_lease_expires_at)
|
||||
|
||||
|
||||
class DurableClientGatewayTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.directory = tempfile.TemporaryDirectory()
|
||||
self.database = Path(self.directory.name) / "client-state.sqlite3"
|
||||
self.store = LocalStateStore(
|
||||
self.database,
|
||||
FakeProtector(),
|
||||
now=lambda: datetime(2026, 8, 4, 9, 0, tzinfo=timezone.utc),
|
||||
)
|
||||
profile = ProfileSettings(
|
||||
PROFILE,
|
||||
"http://127.0.0.1:8080",
|
||||
DEVICE_ID,
|
||||
"D:/Portable/adb/adb.exe",
|
||||
"192.168.0.173:5555",
|
||||
"wifi",
|
||||
)
|
||||
self.store.save_profile(profile, SecretToken(DEVICE_TOKEN))
|
||||
self.store.start_or_resume_polling(PROFILE)
|
||||
self.source = InspectingSource(self.store, PROFILE)
|
||||
self.sink = InspectingSink(self.store, PROFILE)
|
||||
self.gateway = DurableClientGateway(self.store, self.source, self.sink)
|
||||
|
||||
def tearDown(self) -> None:
|
||||
self.directory.cleanup()
|
||||
|
||||
def test_claim_unknown_replays_same_durable_key_then_commits(self) -> None:
|
||||
self.source.mode = "ambiguous"
|
||||
with self.assertRaises(AmbiguousRemoteError):
|
||||
self.gateway.claim_next(PROFILE)
|
||||
self.source.mode = "success"
|
||||
claimed = self.gateway.claim_next(PROFILE)
|
||||
self.assertEqual(claimed.task.id, TASK_ID)
|
||||
self.assertEqual(self.source.request_ids[0], self.source.request_ids[1])
|
||||
self.assertIsNotNone(self.store.active_claim(PROFILE))
|
||||
|
||||
def test_unknown_claim_2xx_keeps_pending_key_for_real_adapter_replay(self) -> None:
|
||||
transport = FakeTransport(response(201, claim_wire()))
|
||||
gateway = DurableClientGateway(self.store, HttpTaskSource(transport), self.sink)
|
||||
with self.assertRaises(AmbiguousRemoteError):
|
||||
gateway.claim_next(PROFILE)
|
||||
pending = self.store.recovery_snapshot(PROFILE).pending_claim
|
||||
self.assertIsNotNone(pending)
|
||||
transport.response = response(200, claim_wire())
|
||||
claimed = gateway.claim_next(PROFILE)
|
||||
self.assertEqual(claimed.task.id, TASK_ID)
|
||||
sent = [call[3] for call in transport.calls]
|
||||
self.assertEqual(sent[0], sent[1])
|
||||
|
||||
def test_claim_401_allows_token_repair_and_same_key_replay(self) -> None:
|
||||
transport = FakeTransport(HttpResponse(401, (), b""))
|
||||
gateway = DurableClientGateway(self.store, HttpTaskSource(transport), self.sink)
|
||||
with self.assertRaises(CredentialRemoteError):
|
||||
gateway.claim_next(PROFILE)
|
||||
request_id = self.store.recovery_snapshot(PROFILE).pending_claim.claim_request_id
|
||||
self.store.save_profile(self.store.load_profile(PROFILE).settings, SecretToken("c" * 64))
|
||||
transport.response = response(200, claim_wire())
|
||||
gateway.claim_next(PROFILE)
|
||||
self.assertEqual(transport.calls[0][3], transport.calls[1][3])
|
||||
self.assertNotEqual(dict(transport.calls[0][2])["Authorization"], dict(transport.calls[1][2])["Authorization"])
|
||||
self.assertEqual(json.loads(transport.calls[1][3])["claim_request_id"], request_id)
|
||||
|
||||
def test_profile_read_sql_failure_after_prepare_is_fixed_error_and_zero_http(self) -> None:
|
||||
original_connect = self.store._connect
|
||||
calls = 0
|
||||
|
||||
def fail_second_connection():
|
||||
nonlocal calls
|
||||
calls += 1
|
||||
connection = original_connect()
|
||||
if calls == 2:
|
||||
connection.set_authorizer(
|
||||
lambda action, table, *_: sqlite3.SQLITE_DENY
|
||||
if action == sqlite3.SQLITE_READ and table == "profiles"
|
||||
else sqlite3.SQLITE_OK
|
||||
)
|
||||
return connection
|
||||
|
||||
with mock.patch.object(self.store, "_connect", side_effect=fail_second_connection):
|
||||
with self.assertRaisesRegex(StateError, "localstate_read_failed") as captured:
|
||||
self.gateway.claim_next(PROFILE)
|
||||
self.assertEqual(self.source.calls, 0)
|
||||
self.assertNotIn(str(self.database), repr(captured.exception))
|
||||
|
||||
def test_renew_is_durable_before_http(self) -> None:
|
||||
self.gateway.claim_next(PROFILE)
|
||||
source = InspectingRenewSource(self.store, PROFILE)
|
||||
gateway = DurableClientGateway(self.store, source, self.sink)
|
||||
result = gateway.renew(PROFILE)
|
||||
self.assertEqual(result.attempt_id, ATTEMPT_ID)
|
||||
self.assertEqual(source.calls, 1)
|
||||
|
||||
def test_unknown_renew_2xx_keeps_pending_payload_for_replay(self) -> None:
|
||||
self.gateway.claim_next(PROFILE)
|
||||
payload = {
|
||||
"task_id": TASK_ID,
|
||||
"attempt_id": ATTEMPT_ID,
|
||||
"claim_generation": 1,
|
||||
"lease_expires_at": "2026-08-04T09:06:00Z",
|
||||
}
|
||||
transport = FakeTransport(response(201, payload))
|
||||
gateway = DurableClientGateway(self.store, HttpTaskSource(transport), self.sink)
|
||||
with self.assertRaises(AmbiguousRemoteError):
|
||||
gateway.renew(PROFILE)
|
||||
pending = self.store.recovery_snapshot(PROFILE).pending_renew
|
||||
self.assertIsNotNone(pending)
|
||||
transport.response = response(200, payload)
|
||||
gateway.renew(PROFILE)
|
||||
self.assertEqual(transport.calls[0][3], transport.calls[1][3])
|
||||
|
||||
def test_renew_401_allows_bearer_repair_without_changing_claim_payload(self) -> None:
|
||||
self.gateway.claim_next(PROFILE)
|
||||
payload = {
|
||||
"task_id": TASK_ID,
|
||||
"attempt_id": ATTEMPT_ID,
|
||||
"claim_generation": 1,
|
||||
"lease_expires_at": "2026-08-04T09:06:00Z",
|
||||
}
|
||||
transport = FakeTransport(HttpResponse(401, (), b""))
|
||||
gateway = DurableClientGateway(self.store, HttpTaskSource(transport), self.sink)
|
||||
with self.assertRaises(CredentialRemoteError):
|
||||
gateway.renew(PROFILE)
|
||||
self.assertIsNotNone(self.store.recovery_snapshot(PROFILE).pending_renew)
|
||||
self.store.save_profile(self.store.load_profile(PROFILE).settings, SecretToken("c" * 64))
|
||||
transport.response = response(200, payload)
|
||||
gateway.renew(PROFILE)
|
||||
self.assertEqual(transport.calls[0][3], transport.calls[1][3])
|
||||
self.assertNotEqual(dict(transport.calls[0][2])["Authorization"], dict(transport.calls[1][2])["Authorization"])
|
||||
|
||||
def test_unknown_evidence_2xx_keeps_pending_multipart_for_replay(self) -> None:
|
||||
self.gateway.claim_next(PROFILE)
|
||||
path = Path(self.directory.name) / "unknown.png"
|
||||
path.write_bytes(PNG)
|
||||
asset = ScreenshotAsset(path, TASK_ID, ATTEMPT_ID, "2026-08-04T09:01:00Z")
|
||||
transport = FakeTransport(HttpResponse(202, (), b""))
|
||||
gateway = DurableClientGateway(self.store, self.source, HttpEvidenceSink(transport))
|
||||
with self.assertRaises(AmbiguousRemoteError):
|
||||
gateway.upload_evidence(PROFILE, asset)
|
||||
pending = self.store.recovery_snapshot(PROFILE).pending_evidence
|
||||
self.assertEqual(len(pending), 1)
|
||||
digest = hashlib.sha256(PNG).hexdigest()
|
||||
receipt = {
|
||||
"asset_id": "63c9f507-7473-4fa6-8d71-8786c34c6301",
|
||||
"task_id": TASK_ID,
|
||||
"attempt_id": ATTEMPT_ID,
|
||||
"kind": "SKU_PANEL_GATE_1",
|
||||
"privacy_tier": "INTERNAL_RAW",
|
||||
"sha256": digest,
|
||||
"byte_size": len(PNG),
|
||||
"content_type": "image/png",
|
||||
"width_px": 1,
|
||||
"height_px": 1,
|
||||
"captured_at": "2026-08-04T09:01:00Z",
|
||||
}
|
||||
transport.response = HttpResponse(201, (("Content-Type", "application/json"),), json.dumps(receipt).encode())
|
||||
gateway.upload_evidence(PROFILE, asset)
|
||||
self.assertEqual(transport.calls[0][3], transport.calls[1][3])
|
||||
|
||||
def test_evidence_401_allows_bearer_repair_with_same_file_and_multipart(self) -> None:
|
||||
self.gateway.claim_next(PROFILE)
|
||||
path = Path(self.directory.name) / "credential.png"
|
||||
path.write_bytes(PNG)
|
||||
asset = ScreenshotAsset(path, TASK_ID, ATTEMPT_ID, "2026-08-04T09:01:00Z")
|
||||
transport = FakeTransport(HttpResponse(401, (), b""))
|
||||
gateway = DurableClientGateway(self.store, self.source, HttpEvidenceSink(transport))
|
||||
with self.assertRaises(CredentialRemoteError):
|
||||
gateway.upload_evidence(PROFILE, asset)
|
||||
self.assertEqual(len(self.store.recovery_snapshot(PROFILE).pending_evidence), 1)
|
||||
self.store.save_profile(self.store.load_profile(PROFILE).settings, SecretToken("c" * 64))
|
||||
digest = hashlib.sha256(PNG).hexdigest()
|
||||
receipt = {
|
||||
"asset_id": "63c9f507-7473-4fa6-8d71-8786c34c6301",
|
||||
"task_id": TASK_ID,
|
||||
"attempt_id": ATTEMPT_ID,
|
||||
"kind": "SKU_PANEL_GATE_1",
|
||||
"privacy_tier": "INTERNAL_RAW",
|
||||
"sha256": digest,
|
||||
"byte_size": len(PNG),
|
||||
"content_type": "image/png",
|
||||
"width_px": 1,
|
||||
"height_px": 1,
|
||||
"captured_at": "2026-08-04T09:01:00Z",
|
||||
}
|
||||
transport.response = HttpResponse(201, (("Content-Type", "application/json"),), json.dumps(receipt).encode())
|
||||
gateway.upload_evidence(PROFILE, asset)
|
||||
self.assertEqual(transport.calls[0][3], transport.calls[1][3])
|
||||
self.assertNotEqual(dict(transport.calls[0][2])["Authorization"], dict(transport.calls[1][2])["Authorization"])
|
||||
|
||||
def test_equivalent_captured_at_replays_exact_original_multipart_bytes(self) -> None:
|
||||
self.gateway.claim_next(PROFILE)
|
||||
path = Path(self.directory.name) / "exact-replay.png"
|
||||
path.write_bytes(PNG)
|
||||
transport = FakeTransport(HttpResponse(202, (), b""))
|
||||
gateway = DurableClientGateway(self.store, self.source, HttpEvidenceSink(transport))
|
||||
first = ScreenshotAsset(path, TASK_ID, ATTEMPT_ID, "2026-08-04T09:01:00.1Z")
|
||||
equivalent = ScreenshotAsset(path, TASK_ID, ATTEMPT_ID, "2026-08-04T09:01:00.100000Z")
|
||||
with self.assertRaises(AmbiguousRemoteError):
|
||||
gateway.upload_evidence(PROFILE, first)
|
||||
with self.assertRaises(AmbiguousRemoteError):
|
||||
gateway.upload_evidence(PROFILE, equivalent)
|
||||
self.assertEqual(transport.calls[0][3], transport.calls[1][3])
|
||||
|
||||
def test_manual_claim_is_durable_and_never_gets_new_key(self) -> None:
|
||||
self.source.mode = "manual"
|
||||
with self.assertRaises(ManualRemoteError):
|
||||
self.gateway.claim_next(PROFILE)
|
||||
with self.assertRaises(Exception):
|
||||
self.gateway.claim_next(PROFILE)
|
||||
self.assertEqual(self.source.calls, 1)
|
||||
|
||||
def test_evidence_slot_exists_before_http_and_success_never_reuploads(self) -> None:
|
||||
self.gateway.claim_next(PROFILE)
|
||||
path = Path(self.directory.name) / "one.png"
|
||||
path.write_bytes(PNG)
|
||||
asset = ScreenshotAsset(path, TASK_ID, ATTEMPT_ID, "2026-08-04T09:01:00.120000Z")
|
||||
first = self.gateway.upload_evidence(PROFILE, asset)
|
||||
path.write_bytes(PNG + b"changed")
|
||||
second = self.gateway.upload_evidence(PROFILE, asset)
|
||||
self.assertEqual(first, second)
|
||||
self.assertEqual(self.sink.calls, 1)
|
||||
@@ -0,0 +1,24 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import unittest
|
||||
|
||||
from cmbuyer_client.core.errors import ProtectionError
|
||||
from cmbuyer_client.localstate.protection import DpapiProtector
|
||||
|
||||
|
||||
@unittest.skipUnless(os.name == "nt", "DPAPI 仅在 Windows 验证")
|
||||
class DpapiProtectorTests(unittest.TestCase):
|
||||
def test_current_user_round_trip_purpose_isolation_and_corruption(self) -> None:
|
||||
protector = DpapiProtector()
|
||||
plaintext = b"a" * 64
|
||||
device_purpose = "device-token:default:33c9f507-7473-4fa6-8d71-8786c34c6301"
|
||||
claim_purpose = "claim-token:default:53c9f507-7473-4fa6-8d71-8786c34c6301"
|
||||
ciphertext = protector.protect(plaintext, purpose=device_purpose)
|
||||
self.assertNotIn(plaintext, ciphertext)
|
||||
self.assertEqual(protector.unprotect(ciphertext, purpose=device_purpose), plaintext)
|
||||
with self.assertRaises(ProtectionError):
|
||||
protector.unprotect(ciphertext, purpose=claim_purpose)
|
||||
damaged = ciphertext[:-1] + bytes((ciphertext[-1] ^ 1,))
|
||||
with self.assertRaises(ProtectionError):
|
||||
protector.unprotect(damaged, purpose=device_purpose)
|
||||
@@ -0,0 +1,38 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
from cmbuyer_client.core.errors import SingleInstanceError
|
||||
from cmbuyer_client.localstate.single_instance import NamedMutex
|
||||
|
||||
|
||||
@unittest.skipUnless(os.name == "nt", "named mutex 仅在 Windows 验证")
|
||||
class NamedMutexTests(unittest.TestCase):
|
||||
def test_second_process_for_same_database_is_rejected(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
database = Path(directory) / "state.sqlite3"
|
||||
first = NamedMutex(database)
|
||||
try:
|
||||
with self.assertRaises(SingleInstanceError):
|
||||
NamedMutex(database)
|
||||
code = (
|
||||
"from pathlib import Path; "
|
||||
"from cmbuyer_client.localstate.single_instance import NamedMutex; "
|
||||
"from cmbuyer_client.core.errors import SingleInstanceError; "
|
||||
f"p=Path({str(database)!r}); "
|
||||
"\ntry:\n NamedMutex(p)\nexcept SingleInstanceError:\n raise SystemExit(17)\nraise SystemExit(0)"
|
||||
)
|
||||
environment = dict(os.environ)
|
||||
environment["PYTHONPATH"] = str(Path(__file__).resolve().parents[2] / "src")
|
||||
result = subprocess.run([sys.executable, "-c", code], env=environment, check=False)
|
||||
self.assertEqual(result.returncode, 17)
|
||||
finally:
|
||||
first.close()
|
||||
|
||||
with NamedMutex(database):
|
||||
pass
|
||||
@@ -0,0 +1,786 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
from dataclasses import replace
|
||||
from datetime import datetime, timezone
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import sqlite3
|
||||
import tempfile
|
||||
import threading
|
||||
from types import SimpleNamespace
|
||||
import unittest
|
||||
from unittest import mock
|
||||
|
||||
from cmbuyer_client.core.errors import ProtectionError, StateError
|
||||
from cmbuyer_client.core.models import AssetReceipt, ClaimedTask, RenewResult, ScreenshotAsset, SecretToken
|
||||
from cmbuyer_client.localstate.models import ProfileSettings
|
||||
from cmbuyer_client.localstate.store import LocalStateStore, _read_stable_png
|
||||
from tests.core.test_models import ATTEMPT_ID, TASK_ID, TOKEN, claim_wire
|
||||
from tests.remote.test_task_source import DEVICE_ID
|
||||
|
||||
|
||||
DEVICE_TOKEN = "b" * 64
|
||||
PROFILE = "default"
|
||||
PNG = base64.b64decode(
|
||||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII="
|
||||
)
|
||||
|
||||
|
||||
class FakeProtector:
|
||||
def protect(self, plaintext: bytes, *, purpose: str) -> bytes:
|
||||
key = hashlib.sha256(("test:" + purpose).encode()).digest()
|
||||
encrypted = bytes(value ^ key[index % len(key)] for index, value in enumerate(plaintext))
|
||||
return hmac.digest(key, plaintext, "sha256") + encrypted
|
||||
|
||||
def unprotect(self, ciphertext: bytes, *, purpose: str) -> bytes:
|
||||
if len(ciphertext) < 33:
|
||||
raise ProtectionError("fake_unprotect_failed")
|
||||
key = hashlib.sha256(("test:" + purpose).encode()).digest()
|
||||
plaintext = bytes(value ^ key[index % len(key)] for index, value in enumerate(ciphertext[32:]))
|
||||
if not hmac.compare_digest(ciphertext[:32], hmac.digest(key, plaintext, "sha256")):
|
||||
raise ProtectionError("fake_unprotect_failed")
|
||||
return plaintext
|
||||
|
||||
|
||||
def settings(device_id: str = DEVICE_ID) -> ProfileSettings:
|
||||
return ProfileSettings(
|
||||
PROFILE,
|
||||
"http://127.0.0.1:8080",
|
||||
device_id,
|
||||
"D:/Portable/adb/adb.exe",
|
||||
"192.168.0.173:5555",
|
||||
"wifi",
|
||||
)
|
||||
|
||||
|
||||
class LocalStateStoreTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.directory = tempfile.TemporaryDirectory()
|
||||
self.database = Path(self.directory.name) / "state" / "client-state.sqlite3"
|
||||
self.clock = [datetime(2026, 8, 4, 9, 0, tzinfo=timezone.utc)]
|
||||
self.store = self.new_store()
|
||||
self.store.save_profile(settings(), SecretToken(DEVICE_TOKEN))
|
||||
|
||||
def tearDown(self) -> None:
|
||||
self.directory.cleanup()
|
||||
|
||||
def new_store(self) -> LocalStateStore:
|
||||
return LocalStateStore(self.database, FakeProtector(), now=lambda: self.clock[0])
|
||||
|
||||
def test_claim_unknown_restart_stop_and_atomic_success(self) -> None:
|
||||
session = self.store.start_or_resume_polling(PROFILE)
|
||||
request = self.store.prepare_claim(PROFILE)
|
||||
self.assertEqual(self.store.prepare_claim(PROFILE), request)
|
||||
|
||||
# 模拟 HTTP 已成功但进程在落库前退出;重启只能恢复原 key。
|
||||
restarted = self.new_store()
|
||||
self.assertEqual(restarted.recovery_snapshot(PROFILE).pending_claim, request)
|
||||
self.assertEqual(restarted.prepare_claim(PROFILE), request)
|
||||
|
||||
# stop 不能丢弃飞行中的 claim,返回结果仍必须落 active。
|
||||
restarted.request_stop(PROFILE)
|
||||
claimed = ClaimedTask.from_wire(claim_wire())
|
||||
restarted.commit_claim_success(PROFILE, request, claimed)
|
||||
snapshot = self.new_store().recovery_snapshot(PROFILE)
|
||||
self.assertFalse(snapshot.session.accept_new)
|
||||
self.assertIsNone(snapshot.pending_claim)
|
||||
self.assertEqual(snapshot.active_claim.task.id, TASK_ID)
|
||||
self.assertEqual(snapshot.active_claim.attempt.claim_token.value, TOKEN)
|
||||
with self.assertRaises(StateError):
|
||||
restarted.prepare_claim(PROFILE)
|
||||
|
||||
def test_relative_database_path_is_frozen_across_cwd_changes(self) -> None:
|
||||
original_cwd = Path.cwd()
|
||||
first = Path(self.directory.name) / "first-cwd"
|
||||
second = Path(self.directory.name) / "second-cwd"
|
||||
first.mkdir()
|
||||
second.mkdir()
|
||||
try:
|
||||
os.chdir(first)
|
||||
relative_store = LocalStateStore(Path("relative/state.sqlite3"), FakeProtector(), now=lambda: self.clock[0])
|
||||
relative_store.save_profile(settings(), SecretToken(DEVICE_TOKEN))
|
||||
frozen_path = relative_store.database_path
|
||||
os.chdir(second)
|
||||
self.assertEqual(relative_store.load_profile(PROFILE).settings, settings())
|
||||
self.assertEqual(relative_store.database_path, frozen_path)
|
||||
self.assertTrue(frozen_path.is_absolute())
|
||||
self.assertFalse((second / "relative" / "state.sqlite3").exists())
|
||||
finally:
|
||||
os.chdir(original_cwd)
|
||||
|
||||
def test_empty_allows_new_key_but_terminal_does_not(self) -> None:
|
||||
self.store.start_or_resume_polling(PROFILE)
|
||||
first = self.store.prepare_claim(PROFILE)
|
||||
self.store.commit_claim_empty(PROFILE, first)
|
||||
second = self.store.prepare_claim(PROFILE)
|
||||
self.assertNotEqual(first.claim_request_id, second.claim_request_id)
|
||||
self.store.mark_claim_terminal(PROFILE, second, "MANUAL")
|
||||
with self.assertRaises(StateError):
|
||||
self.store.prepare_claim(PROFILE)
|
||||
|
||||
def test_idle_profile_identity_change_creates_new_session_and_key(self) -> None:
|
||||
first_session = self.store.start_or_resume_polling(PROFILE)
|
||||
first = self.store.prepare_claim(PROFILE)
|
||||
self.store.commit_claim_empty(PROFILE, first)
|
||||
other_id = "f3c9f507-7473-4fa6-8d71-8786c34c6301"
|
||||
self.store.save_profile(settings(other_id), SecretToken("c" * 64))
|
||||
self.assertIsNone(self.store.recovery_snapshot(PROFILE).session)
|
||||
second_session = self.store.start_or_resume_polling(PROFILE)
|
||||
second = self.store.prepare_claim(PROFILE)
|
||||
self.assertNotEqual(first_session.session_id, second_session.session_id)
|
||||
self.assertNotEqual(first.claim_request_id, second.claim_request_id)
|
||||
self.assertEqual(second.session_id, second_session.session_id)
|
||||
|
||||
def test_device_change_requires_new_token_because_cipher_is_identity_bound(self) -> None:
|
||||
other_id = "f3c9f507-7473-4fa6-8d71-8786c34c6301"
|
||||
with self.assertRaisesRegex(StateError, "device_token_required_for_device_change"):
|
||||
self.store.save_profile(settings(other_id), None)
|
||||
self.store.save_profile(settings(other_id), SecretToken("c" * 64))
|
||||
self.assertEqual(self.store.load_profile(PROFILE).credentials.token.value, "c" * 64)
|
||||
|
||||
def test_tampered_profile_session_mismatch_is_not_auto_repaired(self) -> None:
|
||||
self.store.start_or_resume_polling(PROFILE)
|
||||
connection = sqlite3.connect(self.database)
|
||||
try:
|
||||
connection.execute(
|
||||
"UPDATE profiles SET device_id=? WHERE profile_id=?",
|
||||
("f3c9f507-7473-4fa6-8d71-8786c34c6301", PROFILE),
|
||||
)
|
||||
connection.commit()
|
||||
finally:
|
||||
connection.close()
|
||||
with self.assertRaisesRegex(StateError, "polling_identity_mismatch"):
|
||||
self.store.start_or_resume_polling(PROFILE)
|
||||
|
||||
def test_pending_and_active_freeze_service_device_and_token_identity(self) -> None:
|
||||
self.store.start_or_resume_polling(PROFILE)
|
||||
request = self.store.prepare_claim(PROFILE)
|
||||
self.store.save_profile(settings(), SecretToken("c" * 64))
|
||||
self.assertEqual(self.store.load_profile(PROFILE).credentials.token.value, "c" * 64)
|
||||
other_id = "f3c9f507-7473-4fa6-8d71-8786c34c6301"
|
||||
with self.assertRaises(StateError):
|
||||
self.store.save_profile(settings(other_id), None)
|
||||
base = settings()
|
||||
changed_profiles = (
|
||||
replace(base, adb_path="D:/other/adb.exe"),
|
||||
replace(base, adb_serial="usb-other"),
|
||||
replace(base, transport="usb"),
|
||||
replace(base, poll_interval_seconds=16),
|
||||
replace(base, failure_threshold=4),
|
||||
replace(base, http_timeout_seconds=11),
|
||||
replace(base, step_timeout_seconds=46),
|
||||
)
|
||||
for changed in changed_profiles:
|
||||
with self.subTest(changed=changed), self.assertRaises(StateError):
|
||||
self.store.save_profile(changed, None)
|
||||
self.store.commit_claim_success(PROFILE, request, ClaimedTask.from_wire(claim_wire()))
|
||||
self.store.save_profile(settings(), SecretToken("d" * 64))
|
||||
self.assertEqual(self.store.load_profile(PROFILE).credentials.token.value, "d" * 64)
|
||||
|
||||
def test_renew_reuses_exact_payload_across_restart_and_cas_updates_only_lease(self) -> None:
|
||||
self._claim_active()
|
||||
request = self.store.prepare_renew(PROFILE)
|
||||
self.clock[0] = datetime(2026, 8, 4, 9, 7, tzinfo=timezone.utc)
|
||||
recovered = self.new_store().prepare_renew(PROFILE)
|
||||
self.assertEqual(recovered.renew_request_id, request.renew_request_id)
|
||||
self.assertEqual(recovered.to_wire(), request.to_wire())
|
||||
result = RenewResult(TASK_ID, ATTEMPT_ID, 1, "2026-08-04T09:06:00Z")
|
||||
self.store.commit_renew_success(PROFILE, request, result)
|
||||
active = self.new_store().active_claim(PROFILE)
|
||||
self.assertEqual(active.attempt.claim_generation, 1)
|
||||
self.assertEqual(active.attempt.claim_token.value, TOKEN)
|
||||
self.assertEqual(active.attempt.lease_expires_at, "2026-08-04T09:06:00Z")
|
||||
|
||||
with self.assertRaises(StateError):
|
||||
self.store.prepare_renew(PROFILE)
|
||||
|
||||
def test_evidence_slot_is_persisted_before_send_and_rejects_file_change(self) -> None:
|
||||
self._claim_active()
|
||||
image = Path(self.directory.name) / "explicit.png"
|
||||
image.write_bytes(PNG)
|
||||
asset = ScreenshotAsset(image, TASK_ID, ATTEMPT_ID, "2026-08-04T09:01:00Z")
|
||||
upload = self.store.prepare_or_resume_evidence(PROFILE, asset)
|
||||
recovered = self.new_store().prepare_or_resume_evidence(PROFILE, asset)
|
||||
self.assertEqual(recovered.upload_key, upload.upload_key)
|
||||
self.assertEqual(recovered.content, upload.content)
|
||||
|
||||
image.write_bytes(PNG + b"changed")
|
||||
with self.assertRaises(StateError):
|
||||
self.store.prepare_or_resume_evidence(PROFILE, asset)
|
||||
image.write_bytes(PNG)
|
||||
|
||||
receipt = AssetReceipt(
|
||||
"63c9f507-7473-4fa6-8d71-8786c34c6301",
|
||||
TASK_ID,
|
||||
ATTEMPT_ID,
|
||||
"SKU_PANEL_GATE_1",
|
||||
"INTERNAL_RAW",
|
||||
upload.sha256,
|
||||
len(PNG),
|
||||
"image/png",
|
||||
1,
|
||||
1,
|
||||
"2026-08-04T09:01:00Z",
|
||||
)
|
||||
self.store.commit_evidence_success(PROFILE, upload, receipt)
|
||||
image.write_bytes(PNG + b"different-after-success")
|
||||
self.assertEqual(self.new_store().prepare_or_resume_evidence(PROFILE, asset), receipt)
|
||||
image.unlink()
|
||||
self.assertEqual(self.new_store().prepare_or_resume_evidence(PROFILE, asset), receipt)
|
||||
|
||||
def test_evidence_terminal_outcome_retains_slot_and_blocks_resend(self) -> None:
|
||||
self._claim_active()
|
||||
image = Path(self.directory.name) / "manual.png"
|
||||
image.write_bytes(PNG)
|
||||
asset = ScreenshotAsset(image, TASK_ID, ATTEMPT_ID, "2026-08-04T09:01:00Z")
|
||||
upload = self.store.prepare_or_resume_evidence(PROFILE, asset)
|
||||
self.store.mark_evidence_terminal(PROFILE, upload, "MANUAL")
|
||||
snapshot = self.store.recovery_snapshot(PROFILE)
|
||||
self.assertEqual(snapshot.pending_evidence[0].status, "MANUAL")
|
||||
with self.assertRaises(StateError):
|
||||
self.store.prepare_or_resume_evidence(PROFILE, asset)
|
||||
|
||||
def test_receipt_dimensions_must_match_local_png_and_pending_slot_survives(self) -> None:
|
||||
self._claim_active()
|
||||
image = Path(self.directory.name) / "dimension.png"
|
||||
image.write_bytes(PNG)
|
||||
asset = ScreenshotAsset(image, TASK_ID, ATTEMPT_ID, "2026-08-04T09:01:00Z")
|
||||
upload = self.store.prepare_or_resume_evidence(PROFILE, asset)
|
||||
wrong = AssetReceipt(
|
||||
"63c9f507-7473-4fa6-8d71-8786c34c6301", TASK_ID, ATTEMPT_ID, upload.kind,
|
||||
upload.privacy_tier, upload.sha256, len(upload.content), "image/png", 2, 2, upload.captured_at,
|
||||
)
|
||||
with self.assertRaisesRegex(StateError, "evidence_response_mismatch"):
|
||||
self.store.commit_evidence_success(PROFILE, upload, wrong)
|
||||
pending = self.new_store().recovery_snapshot(PROFILE).pending_evidence
|
||||
self.assertEqual(len(pending), 1)
|
||||
self.assertEqual(pending[0].upload_key, upload.upload_key)
|
||||
|
||||
def test_evidence_slot_is_profile_owned_and_missing_half_fails_closed(self) -> None:
|
||||
self._claim_active()
|
||||
image = Path(self.directory.name) / "owned.png"
|
||||
image.write_bytes(PNG)
|
||||
asset = ScreenshotAsset(image, TASK_ID, ATTEMPT_ID, "2026-08-04T09:01:00Z")
|
||||
self.store.prepare_or_resume_evidence(PROFILE, asset)
|
||||
|
||||
other = replace(settings(), profile_id="other")
|
||||
self.store.save_profile(other, SecretToken("c" * 64))
|
||||
with self.assertRaisesRegex(StateError, "evidence_slot_not_owned"):
|
||||
self.store.prepare_or_resume_evidence("other", asset)
|
||||
|
||||
connection = sqlite3.connect(self.database)
|
||||
connection.execute("DROP TRIGGER evidence_slots_no_delete")
|
||||
connection.execute("DELETE FROM evidence_slots WHERE attempt_id=?", (ATTEMPT_ID,))
|
||||
connection.commit()
|
||||
connection.close()
|
||||
with self.assertRaisesRegex(StateError, "evidence_marker_mismatch"):
|
||||
self.store.prepare_or_resume_evidence(PROFILE, asset)
|
||||
|
||||
def test_evidence_reader_rejects_reparse_and_path_identity_change(self) -> None:
|
||||
image = Path(self.directory.name) / "stable.png"
|
||||
image.write_bytes(PNG)
|
||||
real = os.lstat(image)
|
||||
reparse = SimpleNamespace(
|
||||
st_mode=real.st_mode,
|
||||
st_file_attributes=0x400,
|
||||
st_dev=real.st_dev,
|
||||
st_ino=real.st_ino,
|
||||
st_size=real.st_size,
|
||||
st_mtime_ns=real.st_mtime_ns,
|
||||
)
|
||||
with mock.patch("cmbuyer_client.localstate.store.os.lstat", return_value=reparse):
|
||||
with self.assertRaisesRegex(StateError, "evidence_file_not_regular"):
|
||||
_read_stable_png(image)
|
||||
|
||||
replaced = SimpleNamespace(
|
||||
st_mode=real.st_mode,
|
||||
st_file_attributes=0,
|
||||
st_dev=real.st_dev,
|
||||
st_ino=real.st_ino + 1,
|
||||
st_size=real.st_size,
|
||||
st_mtime_ns=real.st_mtime_ns,
|
||||
)
|
||||
with mock.patch("cmbuyer_client.localstate.store.os.lstat", side_effect=(real, replaced)):
|
||||
with self.assertRaisesRegex(StateError, "evidence_changed_during_read"):
|
||||
_read_stable_png(image)
|
||||
|
||||
def test_corrupt_database_and_ciphertext_fail_closed(self) -> None:
|
||||
self._claim_active()
|
||||
connection = sqlite3.connect(self.database)
|
||||
try:
|
||||
connection.execute("DROP TRIGGER active_claims_identity_immutable")
|
||||
connection.execute("UPDATE active_claims SET claim_token_cipher=?", (sqlite3.Binary(b"corrupt"),))
|
||||
connection.commit()
|
||||
finally:
|
||||
connection.close()
|
||||
with self.assertRaises(ProtectionError):
|
||||
self.new_store().active_claim(PROFILE)
|
||||
|
||||
corrupt = Path(self.directory.name) / "corrupt.sqlite3"
|
||||
corrupt.write_bytes(b"not-a-sqlite-database")
|
||||
with self.assertRaises(StateError):
|
||||
LocalStateStore(corrupt, FakeProtector())
|
||||
|
||||
def test_claim_token_cipher_cannot_be_swapped_between_attempt_histories(self) -> None:
|
||||
self._claim_active()
|
||||
connection = sqlite3.connect(self.database)
|
||||
connection.execute("UPDATE active_claims SET closed_at='2026-08-04T09:02:00Z' WHERE attempt_id=?", (ATTEMPT_ID,))
|
||||
connection.commit()
|
||||
connection.close()
|
||||
request = self.store.prepare_claim(PROFILE)
|
||||
wire = claim_wire()
|
||||
wire["task"]["id"] = "83c9f507-7473-4fa6-8d71-8786c34c6301"
|
||||
wire["authorization"]["id"] = "93c9f507-7473-4fa6-8d71-8786c34c6301"
|
||||
wire["attempt"]["id"] = "a3c9f507-7473-4fa6-8d71-8786c34c6301"
|
||||
wire["attempt"]["claim_token"] = "f" * 64
|
||||
self.store.commit_claim_success(PROFILE, request, ClaimedTask.from_wire(wire))
|
||||
|
||||
connection = sqlite3.connect(self.database)
|
||||
try:
|
||||
connection.execute("DROP TRIGGER active_claims_identity_immutable")
|
||||
rows = connection.execute("SELECT attempt_id,claim_token_cipher FROM active_claims ORDER BY created_at,attempt_id").fetchall()
|
||||
connection.execute("UPDATE active_claims SET claim_token_cipher=? WHERE attempt_id=?", (rows[1][1], rows[0][0]))
|
||||
connection.execute("UPDATE active_claims SET claim_token_cipher=? WHERE attempt_id=?", (rows[0][1], rows[1][0]))
|
||||
connection.commit()
|
||||
finally:
|
||||
connection.close()
|
||||
with self.assertRaises(ProtectionError):
|
||||
self.new_store().active_claim(PROFILE)
|
||||
|
||||
def test_device_token_cipher_cannot_be_swapped_between_profiles(self) -> None:
|
||||
other = replace(settings(), profile_id="other", device_id="f3c9f507-7473-4fa6-8d71-8786c34c6301")
|
||||
self.store.save_profile(other, SecretToken("c" * 64))
|
||||
connection = sqlite3.connect(self.database)
|
||||
try:
|
||||
rows = connection.execute(
|
||||
"SELECT profile_id,device_token_cipher FROM profiles WHERE profile_id IN (?,?) ORDER BY profile_id",
|
||||
(PROFILE, "other"),
|
||||
).fetchall()
|
||||
connection.execute("UPDATE profiles SET device_token_cipher=? WHERE profile_id=?", (rows[1][1], rows[0][0]))
|
||||
connection.execute("UPDATE profiles SET device_token_cipher=? WHERE profile_id=?", (rows[0][1], rows[1][0]))
|
||||
connection.commit()
|
||||
finally:
|
||||
connection.close()
|
||||
with self.assertRaises(ProtectionError):
|
||||
self.new_store().load_profile(PROFILE)
|
||||
|
||||
def test_state_graph_rejects_missing_active_pending_overlap_and_snapshot_drift(self) -> None:
|
||||
self._claim_active()
|
||||
connection = sqlite3.connect(self.database)
|
||||
try:
|
||||
connection.execute(
|
||||
"INSERT INTO claim_requests VALUES(?,?,?,'PENDING',?,?)",
|
||||
(
|
||||
"63c9f507-7473-4fa6-8d71-8786c34c6301",
|
||||
PROFILE,
|
||||
self.store.recovery_snapshot(PROFILE).session.session_id,
|
||||
"2026-08-04T09:00:00Z",
|
||||
"2026-08-04T09:00:00Z",
|
||||
),
|
||||
)
|
||||
connection.commit()
|
||||
finally:
|
||||
connection.close()
|
||||
with self.assertRaisesRegex(StateError, "claim_state_conflict"):
|
||||
self.store.prepare_claim(PROFILE)
|
||||
|
||||
connection = sqlite3.connect(self.database)
|
||||
try:
|
||||
connection.execute("DROP TRIGGER claim_requests_no_delete")
|
||||
connection.execute("DELETE FROM claim_requests WHERE status='PENDING'")
|
||||
connection.execute("DROP TRIGGER active_claims_identity_immutable")
|
||||
connection.execute("UPDATE active_claims SET task_id=?", ("83c9f507-7473-4fa6-8d71-8786c34c6301",))
|
||||
connection.commit()
|
||||
finally:
|
||||
connection.close()
|
||||
with self.assertRaisesRegex(StateError, "active_claim_snapshot_mismatch"):
|
||||
self.store.active_claim(PROFILE)
|
||||
|
||||
def test_succeeded_claim_tombstone_detects_missing_history(self) -> None:
|
||||
self._claim_active()
|
||||
connection = sqlite3.connect(self.database)
|
||||
try:
|
||||
connection.execute("DROP TRIGGER active_claims_no_delete")
|
||||
connection.execute("DELETE FROM active_claims")
|
||||
connection.commit()
|
||||
finally:
|
||||
connection.close()
|
||||
with self.assertRaisesRegex(StateError, "active_claim_request_mismatch"):
|
||||
self.store.prepare_claim(PROFILE)
|
||||
|
||||
def test_closed_history_is_retained_but_does_not_block_next_claim(self) -> None:
|
||||
self._claim_active()
|
||||
connection = sqlite3.connect(self.database)
|
||||
try:
|
||||
connection.execute("UPDATE active_claims SET closed_at='2026-08-04T09:02:00Z'")
|
||||
connection.commit()
|
||||
finally:
|
||||
connection.close()
|
||||
self.assertIsNone(self.store.active_claim(PROFILE))
|
||||
changed = replace(settings(), poll_interval_seconds=16)
|
||||
self.store.save_profile(changed, None)
|
||||
self.store.start_or_resume_polling(PROFILE)
|
||||
request = self.store.prepare_claim(PROFILE)
|
||||
self.assertIsNotNone(request.claim_request_id)
|
||||
|
||||
def test_pending_renew_and_evidence_revalidate_active_graph(self) -> None:
|
||||
self._claim_active()
|
||||
renew = self.store.prepare_renew(PROFILE)
|
||||
connection = sqlite3.connect(self.database)
|
||||
try:
|
||||
connection.execute("DROP TRIGGER renew_requests_identity_immutable")
|
||||
connection.execute("UPDATE renew_requests SET task_id=?", ("83c9f507-7473-4fa6-8d71-8786c34c6301",))
|
||||
connection.commit()
|
||||
finally:
|
||||
connection.close()
|
||||
with self.assertRaisesRegex(StateError, "renew_active_mismatch"):
|
||||
self.store.prepare_renew(PROFILE)
|
||||
|
||||
def test_success_receipt_must_match_immutable_slot(self) -> None:
|
||||
self._claim_active()
|
||||
image = Path(self.directory.name) / "receipt.png"
|
||||
image.write_bytes(PNG)
|
||||
asset = ScreenshotAsset(image, TASK_ID, ATTEMPT_ID, "2026-08-04T09:01:00Z")
|
||||
upload = self.store.prepare_or_resume_evidence(PROFILE, asset)
|
||||
receipt = AssetReceipt(
|
||||
"63c9f507-7473-4fa6-8d71-8786c34c6301",
|
||||
TASK_ID,
|
||||
ATTEMPT_ID,
|
||||
upload.kind,
|
||||
upload.privacy_tier,
|
||||
upload.sha256,
|
||||
len(upload.content),
|
||||
"image/png",
|
||||
1,
|
||||
1,
|
||||
upload.captured_at,
|
||||
)
|
||||
self.store.commit_evidence_success(PROFILE, upload, receipt)
|
||||
connection = sqlite3.connect(self.database)
|
||||
try:
|
||||
wrong = dict(receipt.__dict__)
|
||||
wrong["asset_id"] = "83c9f507-7473-4fa6-8d71-8786c34c6301"
|
||||
with self.assertRaises(sqlite3.IntegrityError):
|
||||
connection.execute("UPDATE evidence_slots SET receipt_json=?", (json.dumps(wrong),))
|
||||
connection.rollback()
|
||||
connection.execute("DROP TRIGGER evidence_slots_receipt_immutable")
|
||||
connection.execute("UPDATE evidence_slots SET receipt_json=?", (json.dumps(wrong),))
|
||||
connection.commit()
|
||||
finally:
|
||||
connection.close()
|
||||
with self.assertRaisesRegex(StateError, "evidence_receipt_mismatch"):
|
||||
self.store.prepare_or_resume_evidence(PROFILE, asset)
|
||||
|
||||
def test_marker_and_slot_append_only_triggers_prevent_erasing_history(self) -> None:
|
||||
self._claim_active()
|
||||
image = Path(self.directory.name) / "append-only.png"
|
||||
image.write_bytes(PNG)
|
||||
asset = ScreenshotAsset(image, TASK_ID, ATTEMPT_ID, "2026-08-04T09:01:00Z")
|
||||
self.store.prepare_or_resume_evidence(PROFILE, asset)
|
||||
connection = sqlite3.connect(self.database)
|
||||
try:
|
||||
with self.assertRaises(sqlite3.IntegrityError):
|
||||
connection.execute("DELETE FROM evidence_slot_markers")
|
||||
with self.assertRaises(sqlite3.IntegrityError):
|
||||
connection.execute("UPDATE evidence_slot_markers SET upload_key=?", ("83c9f507-7473-4fa6-8d71-8786c34c6301",))
|
||||
with self.assertRaises(sqlite3.IntegrityError):
|
||||
connection.execute("DELETE FROM evidence_slots")
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
def test_capped_equal_renew_is_success_but_authorization_overrun_is_rejected(self) -> None:
|
||||
self._claim_active()
|
||||
request = self.store.prepare_renew(PROFILE)
|
||||
equal = RenewResult(TASK_ID, ATTEMPT_ID, 1, request.expected_lease_expires_at)
|
||||
self.store.commit_renew_success(PROFILE, request, equal)
|
||||
self.assertEqual(self.store.active_claim(PROFILE).attempt.lease_expires_at, request.expected_lease_expires_at)
|
||||
next_request = self.store.prepare_renew(PROFILE)
|
||||
beyond = RenewResult(TASK_ID, ATTEMPT_ID, 1, "2026-08-04T10:00:00.000000001Z")
|
||||
with self.assertRaisesRegex(StateError, "renew_response_mismatch"):
|
||||
self.store.commit_renew_success(PROFILE, next_request, beyond)
|
||||
|
||||
def test_sqlite_database_wal_and_shm_never_contain_plaintext_tokens(self) -> None:
|
||||
self._claim_active()
|
||||
# 强制 checkpoint 后同时扫描主文件和可能存在的 WAL/SHM。
|
||||
connection = sqlite3.connect(self.database)
|
||||
connection.execute("PRAGMA wal_checkpoint(FULL)")
|
||||
connection.close()
|
||||
for path in (self.database, Path(str(self.database) + "-wal"), Path(str(self.database) + "-shm")):
|
||||
if path.exists():
|
||||
raw = path.read_bytes()
|
||||
self.assertNotIn(DEVICE_TOKEN.encode(), raw)
|
||||
self.assertNotIn(bytes.fromhex(DEVICE_TOKEN), raw)
|
||||
self.assertNotIn(TOKEN.encode(), raw)
|
||||
self.assertNotIn(bytes.fromhex(TOKEN), raw)
|
||||
|
||||
def test_concurrent_stop_and_claim_commit_never_loses_result(self) -> None:
|
||||
self.store.start_or_resume_polling(PROFILE)
|
||||
request = self.store.prepare_claim(PROFILE)
|
||||
barrier = threading.Barrier(2)
|
||||
failures: list[BaseException] = []
|
||||
|
||||
def stop() -> None:
|
||||
try:
|
||||
barrier.wait()
|
||||
self.store.request_stop(PROFILE)
|
||||
except BaseException as error:
|
||||
failures.append(error)
|
||||
|
||||
thread = threading.Thread(target=stop)
|
||||
thread.start()
|
||||
barrier.wait()
|
||||
self.store.commit_claim_success(PROFILE, request, ClaimedTask.from_wire(claim_wire()))
|
||||
thread.join()
|
||||
self.assertEqual(failures, [])
|
||||
snapshot = self.store.recovery_snapshot(PROFILE)
|
||||
self.assertFalse(snapshot.session.accept_new)
|
||||
self.assertIsNotNone(snapshot.active_claim)
|
||||
|
||||
def test_recovery_snapshot_uses_one_sqlite_read_snapshot(self) -> None:
|
||||
self.store.start_or_resume_polling(PROFILE)
|
||||
writer = self.new_store()
|
||||
failures: list[BaseException] = []
|
||||
|
||||
class PausingStore(LocalStateStore):
|
||||
armed = False
|
||||
|
||||
def _connect(inner_self):
|
||||
connection = super(PausingStore, inner_self)._connect()
|
||||
if inner_self.armed:
|
||||
def trace(statement: str) -> None:
|
||||
if inner_self.armed and "FROM claim_requests" in statement:
|
||||
inner_self.armed = False
|
||||
thread = threading.Thread(target=do_stop)
|
||||
thread.start()
|
||||
thread.join()
|
||||
|
||||
connection.set_trace_callback(trace)
|
||||
return connection
|
||||
|
||||
def do_stop() -> None:
|
||||
try:
|
||||
writer.request_stop(PROFILE)
|
||||
except BaseException as error:
|
||||
failures.append(error)
|
||||
|
||||
reader = PausingStore(self.database, FakeProtector(), now=lambda: self.clock[0])
|
||||
reader.armed = True
|
||||
snapshot = reader.recovery_snapshot(PROFILE)
|
||||
self.assertEqual(failures, [])
|
||||
self.assertTrue(snapshot.session.accept_new)
|
||||
self.assertFalse(writer.recovery_snapshot(PROFILE).session.accept_new)
|
||||
|
||||
def test_business_snapshot_is_immutable_hashed_and_renew_never_rewrites_it(self) -> None:
|
||||
self._claim_active()
|
||||
connection = sqlite3.connect(self.database)
|
||||
try:
|
||||
raw, digest, initial_lease, current_lease = connection.execute(
|
||||
"""SELECT snapshot_json,snapshot_digest,initial_lease_expires_at,lease_expires_at
|
||||
FROM active_claims WHERE closed_at IS NULL"""
|
||||
).fetchone()
|
||||
with self.assertRaises(sqlite3.IntegrityError):
|
||||
connection.execute("UPDATE active_claims SET snapshot_json='{}' WHERE closed_at IS NULL")
|
||||
connection.rollback()
|
||||
connection.execute("DROP TRIGGER active_claims_identity_immutable")
|
||||
for field, changed in (
|
||||
("sku_color", "白色"),
|
||||
("sku_size", "XL"),
|
||||
("quantity", 99),
|
||||
("max_total_price", "999.00"),
|
||||
):
|
||||
payload = json.loads(raw)
|
||||
payload["task"][field] = changed
|
||||
tampered = json.dumps(payload, ensure_ascii=False, separators=(",", ":"), sort_keys=True)
|
||||
connection.execute("UPDATE active_claims SET snapshot_json=? WHERE closed_at IS NULL", (tampered,))
|
||||
connection.commit()
|
||||
with self.subTest(field=field), self.assertRaisesRegex(StateError, "active_claim_snapshot_mismatch"):
|
||||
self.store.active_claim(PROFILE)
|
||||
connection.execute(
|
||||
"UPDATE active_claims SET snapshot_json=?,snapshot_digest=? WHERE closed_at IS NULL", (raw, digest)
|
||||
)
|
||||
connection.commit()
|
||||
connection.execute(
|
||||
"""UPDATE active_claims SET initial_lease_expires_at='2026-08-04T09:59:00Z',
|
||||
lease_expires_at='2026-08-04T09:59:00Z' WHERE closed_at IS NULL"""
|
||||
)
|
||||
connection.commit()
|
||||
with self.assertRaisesRegex(StateError, "invalid_stored_claim"):
|
||||
self.store.active_claim(PROFILE)
|
||||
connection.execute(
|
||||
"""UPDATE active_claims SET initial_lease_expires_at=?,lease_expires_at=?
|
||||
WHERE closed_at IS NULL""",
|
||||
(initial_lease, current_lease),
|
||||
)
|
||||
connection.commit()
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
renew = self.store.prepare_renew(PROFILE)
|
||||
self.store.commit_renew_success(PROFILE, renew, RenewResult(TASK_ID, ATTEMPT_ID, 1, "2026-08-04T09:06:00Z"))
|
||||
connection = sqlite3.connect(self.database)
|
||||
try:
|
||||
self.assertEqual(
|
||||
connection.execute("SELECT snapshot_json,snapshot_digest FROM active_claims WHERE closed_at IS NULL").fetchone(),
|
||||
(raw, digest),
|
||||
)
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
def test_open_session_detects_tampering_of_every_non_token_profile_setting(self) -> None:
|
||||
self._claim_active()
|
||||
changed_values = {
|
||||
"service_url": "http://127.0.0.1:9999",
|
||||
"device_id": "f3c9f507-7473-4fa6-8d71-8786c34c6301",
|
||||
"adb_path": "D:/other/adb.exe",
|
||||
"adb_serial": "usb-other",
|
||||
"transport": "usb",
|
||||
"poll_interval_seconds": 16,
|
||||
"failure_threshold": 4,
|
||||
"http_timeout_seconds": 11,
|
||||
"step_timeout_seconds": 46,
|
||||
}
|
||||
connection = sqlite3.connect(self.database)
|
||||
try:
|
||||
for field, changed in changed_values.items():
|
||||
original = connection.execute(f"SELECT {field} FROM profiles WHERE profile_id=?", (PROFILE,)).fetchone()[0]
|
||||
connection.execute(f"UPDATE profiles SET {field}=? WHERE profile_id=?", (changed, PROFILE))
|
||||
connection.commit()
|
||||
with self.subTest(field=field), self.assertRaisesRegex(StateError, "polling_identity_mismatch"):
|
||||
self.store.active_claim(PROFILE)
|
||||
connection.execute(f"UPDATE profiles SET {field}=? WHERE profile_id=?", (original, PROFILE))
|
||||
connection.commit()
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
def test_closed_attempt_evidence_is_history_not_current_recovery_work(self) -> None:
|
||||
self._claim_active()
|
||||
image = Path(self.directory.name) / "old-manual.png"
|
||||
image.write_bytes(PNG)
|
||||
asset = ScreenshotAsset(image, TASK_ID, ATTEMPT_ID, "2026-08-04T09:01:00Z")
|
||||
upload = self.store.prepare_or_resume_evidence(PROFILE, asset)
|
||||
self.store.mark_evidence_terminal(PROFILE, upload, "MANUAL")
|
||||
connection = sqlite3.connect(self.database)
|
||||
connection.execute("UPDATE active_claims SET closed_at='2026-08-04T09:02:00Z' WHERE attempt_id=?", (ATTEMPT_ID,))
|
||||
connection.commit()
|
||||
connection.close()
|
||||
|
||||
next_request = self.store.prepare_claim(PROFILE)
|
||||
self.assertIsNotNone(next_request.claim_request_id)
|
||||
self.assertEqual(self.store.recovery_snapshot(PROFILE).pending_evidence, ())
|
||||
|
||||
def test_succeeded_evidence_history_does_not_block_next_claim(self) -> None:
|
||||
self._claim_active()
|
||||
image = Path(self.directory.name) / "old-success.png"
|
||||
image.write_bytes(PNG)
|
||||
asset = ScreenshotAsset(image, TASK_ID, ATTEMPT_ID, "2026-08-04T09:01:00Z")
|
||||
upload = self.store.prepare_or_resume_evidence(PROFILE, asset)
|
||||
receipt = AssetReceipt(
|
||||
"63c9f507-7473-4fa6-8d71-8786c34c6301", TASK_ID, ATTEMPT_ID, upload.kind,
|
||||
upload.privacy_tier, upload.sha256, len(upload.content), "image/png", 1, 1, upload.captured_at,
|
||||
)
|
||||
self.store.commit_evidence_success(PROFILE, upload, receipt)
|
||||
connection = sqlite3.connect(self.database)
|
||||
connection.execute("UPDATE active_claims SET closed_at='2026-08-04T09:02:00Z' WHERE attempt_id=?", (ATTEMPT_ID,))
|
||||
connection.commit()
|
||||
connection.close()
|
||||
self.assertIsNotNone(self.store.prepare_claim(PROFILE))
|
||||
|
||||
def test_evidence_slot_without_corresponding_claim_history_fails_closed(self) -> None:
|
||||
self._claim_active()
|
||||
image = Path(self.directory.name) / "orphan.png"
|
||||
image.write_bytes(PNG)
|
||||
asset = ScreenshotAsset(image, TASK_ID, ATTEMPT_ID, "2026-08-04T09:01:00Z")
|
||||
upload = self.store.prepare_or_resume_evidence(PROFILE, asset)
|
||||
receipt = AssetReceipt(
|
||||
"63c9f507-7473-4fa6-8d71-8786c34c6301", TASK_ID, ATTEMPT_ID, upload.kind,
|
||||
upload.privacy_tier, upload.sha256, len(upload.content), "image/png", 1, 1, upload.captured_at,
|
||||
)
|
||||
self.store.commit_evidence_success(PROFILE, upload, receipt)
|
||||
orphan = "a3c9f507-7473-4fa6-8d71-8786c34c6301"
|
||||
connection = sqlite3.connect(self.database)
|
||||
try:
|
||||
connection.execute("DROP TRIGGER evidence_slot_markers_immutable")
|
||||
connection.execute("DROP TRIGGER evidence_slots_identity_immutable")
|
||||
connection.execute("UPDATE evidence_slot_markers SET attempt_id=?", (orphan,))
|
||||
connection.execute("UPDATE evidence_slots SET attempt_id=?", (orphan,))
|
||||
connection.commit()
|
||||
finally:
|
||||
connection.close()
|
||||
with self.assertRaisesRegex(StateError, "evidence_claim_history_mismatch"):
|
||||
self.store.recovery_snapshot(PROFILE)
|
||||
|
||||
def test_terminal_renew_is_scoped_to_old_closed_attempt(self) -> None:
|
||||
self._claim_active()
|
||||
old_renew = self.store.prepare_renew(PROFILE)
|
||||
self.store.mark_renew_terminal(PROFILE, old_renew, "MANUAL")
|
||||
connection = sqlite3.connect(self.database)
|
||||
connection.execute("UPDATE active_claims SET closed_at='2026-08-04T09:02:00Z' WHERE attempt_id=?", (ATTEMPT_ID,))
|
||||
connection.commit()
|
||||
connection.close()
|
||||
|
||||
request = self.store.prepare_claim(PROFILE)
|
||||
wire = claim_wire()
|
||||
wire["task"]["id"] = "83c9f507-7473-4fa6-8d71-8786c34c6301"
|
||||
wire["authorization"]["id"] = "93c9f507-7473-4fa6-8d71-8786c34c6301"
|
||||
wire["attempt"]["id"] = "a3c9f507-7473-4fa6-8d71-8786c34c6301"
|
||||
self.store.commit_claim_success(PROFILE, request, ClaimedTask.from_wire(wire))
|
||||
next_renew = self.store.prepare_renew(PROFILE)
|
||||
self.assertNotEqual(next_renew.renew_request_id, old_renew.renew_request_id)
|
||||
self.assertEqual(self.store.recovery_snapshot(PROFILE).pending_renew, next_renew)
|
||||
|
||||
def test_successful_renew_response_is_write_once_and_digest_checked(self) -> None:
|
||||
self._claim_active()
|
||||
request = self.store.prepare_renew(PROFILE)
|
||||
self.store.commit_renew_success(PROFILE, request, RenewResult(TASK_ID, ATTEMPT_ID, 1, "2026-08-04T09:06:00Z"))
|
||||
connection = sqlite3.connect(self.database)
|
||||
try:
|
||||
with self.assertRaises(sqlite3.IntegrityError):
|
||||
connection.execute("UPDATE renew_requests SET response_json='{}' WHERE renew_request_id=?", (request.renew_request_id,))
|
||||
connection.rollback()
|
||||
connection.execute("DROP TRIGGER renew_requests_response_immutable")
|
||||
connection.execute("UPDATE renew_requests SET response_json='{}' WHERE renew_request_id=?", (request.renew_request_id,))
|
||||
connection.commit()
|
||||
finally:
|
||||
connection.close()
|
||||
with self.assertRaisesRegex(StateError, "renew_response_mismatch"):
|
||||
self.store.active_claim(PROFILE)
|
||||
|
||||
def test_invalid_or_reversed_session_and_claim_timestamps_fail_closed(self) -> None:
|
||||
self._claim_active()
|
||||
connection = sqlite3.connect(self.database)
|
||||
try:
|
||||
connection.execute("UPDATE active_claims SET closed_at='2026-08-04T08:59:00Z' WHERE attempt_id=?", (ATTEMPT_ID,))
|
||||
connection.commit()
|
||||
finally:
|
||||
connection.close()
|
||||
with self.assertRaisesRegex(StateError, "invalid_claim_timeline"):
|
||||
self.store.recovery_snapshot(PROFILE)
|
||||
|
||||
def test_invalid_session_closed_at_fails_closed(self) -> None:
|
||||
self.store.start_or_resume_polling(PROFILE)
|
||||
connection = sqlite3.connect(self.database)
|
||||
try:
|
||||
connection.execute("UPDATE polling_sessions SET closed_at='not-a-time' WHERE profile_id=?", (PROFILE,))
|
||||
connection.commit()
|
||||
finally:
|
||||
connection.close()
|
||||
with self.assertRaises(StateError):
|
||||
self.store.recovery_snapshot(PROFILE)
|
||||
|
||||
def test_store_rejects_forged_claim_whose_lease_exceeds_authorization(self) -> None:
|
||||
self.store.start_or_resume_polling(PROFILE)
|
||||
request = self.store.prepare_claim(PROFILE)
|
||||
claimed = ClaimedTask.from_wire(claim_wire())
|
||||
object.__setattr__(claimed.attempt, "lease_expires_at", "2026-08-04T10:00:00.000000001Z")
|
||||
with self.assertRaisesRegex(StateError, "claim_lease_exceeds_authorization"):
|
||||
self.store.commit_claim_success(PROFILE, request, claimed)
|
||||
self.assertEqual(self.store.recovery_snapshot(PROFILE).pending_claim, request)
|
||||
|
||||
def _claim_active(self) -> None:
|
||||
self.store.start_or_resume_polling(PROFILE)
|
||||
request = self.store.prepare_claim(PROFILE)
|
||||
self.store.commit_claim_success(PROFILE, request, ClaimedTask.from_wire(claim_wire()))
|
||||
@@ -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,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()
|
||||
@@ -0,0 +1 @@
|
||||
"""remote tests。"""
|
||||
@@ -0,0 +1,167 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import base64
|
||||
import re
|
||||
import unittest
|
||||
|
||||
from cmbuyer_client.core.models import DeviceCredentials, EvidenceUpload, SecretToken
|
||||
from cmbuyer_client.core.errors import AmbiguousRemoteError, ValidationError
|
||||
from cmbuyer_client.remote.evidence_sink import HttpEvidenceSink
|
||||
from cmbuyer_client.remote.http_transport import HttpResponse
|
||||
from tests.core.test_models import ATTEMPT_ID, TASK_ID, TOKEN
|
||||
from tests.remote.test_task_source import DEVICE_ID, FakeTransport
|
||||
|
||||
|
||||
UPLOAD_ID = "43c9f507-7473-4fa6-8d71-8786c34c6301"
|
||||
ASSET_ID = "63c9f507-7473-4fa6-8d71-8786c34c6301"
|
||||
PNG = base64.b64decode(
|
||||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII="
|
||||
)
|
||||
|
||||
|
||||
class EvidenceSinkTests(unittest.TestCase):
|
||||
def test_upload_has_fixed_fields_and_never_contains_local_path(self) -> None:
|
||||
digest = hashlib.sha256(PNG).hexdigest()
|
||||
upload = EvidenceUpload(TASK_ID, UPLOAD_ID, ATTEMPT_ID, digest, "2026-08-04T09:01:00Z", PNG)
|
||||
payload = {
|
||||
"asset_id": ASSET_ID,
|
||||
"task_id": TASK_ID,
|
||||
"attempt_id": ATTEMPT_ID,
|
||||
"kind": "SKU_PANEL_GATE_1",
|
||||
"privacy_tier": "INTERNAL_RAW",
|
||||
"sha256": digest,
|
||||
"byte_size": len(PNG),
|
||||
"content_type": "image/png",
|
||||
"width_px": 1,
|
||||
"height_px": 1,
|
||||
"captured_at": "2026-08-04T09:01:00Z",
|
||||
}
|
||||
raw = json.dumps(payload, separators=(",", ":")).encode()
|
||||
transport = FakeTransport(HttpResponse(201, (("Content-Type", "application/json"),), raw))
|
||||
receipt = HttpEvidenceSink(transport).upload(DeviceCredentials(DEVICE_ID, SecretToken(TOKEN)), upload)
|
||||
self.assertEqual(receipt.asset_id, ASSET_ID)
|
||||
self.assertEqual(len(transport.calls), 1)
|
||||
body = transport.calls[0][3]
|
||||
self.assertIn(b'filename="evidence.png"', body)
|
||||
self.assertNotIn(b"C:\\", body)
|
||||
self.assertNotIn(b"manifest", body)
|
||||
self.assertNotIn(b".xml", body)
|
||||
names = re.findall(br'Content-Disposition: form-data; name="([^"]+)"', body)
|
||||
self.assertEqual(
|
||||
names,
|
||||
[b"upload_key", b"attempt_id", b"kind", b"privacy_tier", b"sha256", b"captured_at", b"file"],
|
||||
)
|
||||
self.assertEqual(body.count(b'filename="evidence.png"'), 1)
|
||||
self.assertNotIn(b"claim_token", body)
|
||||
self.assertNotIn(b"session_id", body)
|
||||
boundary = dict(transport.calls[0][2])["Content-Type"].split("boundary=", 1)[1]
|
||||
self.assertEqual(boundary, "cmbuyer-" + UPLOAD_ID.replace("-", ""))
|
||||
|
||||
def test_captured_at_equivalent_trailing_zeros_are_accepted(self) -> None:
|
||||
digest = hashlib.sha256(PNG).hexdigest()
|
||||
upload = EvidenceUpload(TASK_ID, UPLOAD_ID, ATTEMPT_ID, digest, "2026-08-04T09:01:00.120000Z", PNG)
|
||||
payload = {
|
||||
"asset_id": ASSET_ID,
|
||||
"task_id": TASK_ID,
|
||||
"attempt_id": ATTEMPT_ID,
|
||||
"kind": "SKU_PANEL_GATE_1",
|
||||
"privacy_tier": "INTERNAL_RAW",
|
||||
"sha256": digest,
|
||||
"byte_size": len(PNG),
|
||||
"content_type": "image/png",
|
||||
"width_px": 1,
|
||||
"height_px": 1,
|
||||
"captured_at": "2026-08-04T09:01:00.12Z",
|
||||
}
|
||||
raw = json.dumps(payload, separators=(",", ":")).encode()
|
||||
for status in (200, 201):
|
||||
with self.subTest(status=status):
|
||||
transport = FakeTransport(HttpResponse(status, (("Content-Type", "application/json"),), raw))
|
||||
receipt = HttpEvidenceSink(transport).upload(DeviceCredentials(DEVICE_ID, SecretToken(TOKEN)), upload)
|
||||
self.assertEqual(receipt.captured_at, "2026-08-04T09:01:00.12Z")
|
||||
|
||||
def test_wrong_content_hash_fails_before_http_object_can_be_built(self) -> None:
|
||||
transport = FakeTransport(HttpResponse(500, (), b""))
|
||||
with self.assertRaises(ValidationError):
|
||||
upload = EvidenceUpload(TASK_ID, UPLOAD_ID, ATTEMPT_ID, "0" * 64, "2026-08-04T09:01:00Z", PNG)
|
||||
HttpEvidenceSink(transport).upload(DeviceCredentials(DEVICE_ID, SecretToken(TOKEN)), upload)
|
||||
self.assertEqual(transport.calls, [])
|
||||
|
||||
def test_unknown_2xx_is_ambiguous(self) -> None:
|
||||
digest = hashlib.sha256(PNG).hexdigest()
|
||||
upload = EvidenceUpload(TASK_ID, UPLOAD_ID, ATTEMPT_ID, digest, "2026-08-04T09:01:00Z", PNG)
|
||||
for status in (202, 204, 206):
|
||||
with self.subTest(status=status):
|
||||
transport = FakeTransport(HttpResponse(status, (), b""))
|
||||
with self.assertRaises(AmbiguousRemoteError):
|
||||
HttpEvidenceSink(transport).upload(DeviceCredentials(DEVICE_ID, SecretToken(TOKEN)), upload)
|
||||
self.assertEqual(len(transport.calls), 1)
|
||||
|
||||
def test_boundary_collision_and_receipt_mismatch_fail_closed(self) -> None:
|
||||
marker = ("--cmbuyer-" + UPLOAD_ID.replace("-", "")).encode()
|
||||
collision_content = PNG + marker
|
||||
collision = EvidenceUpload(
|
||||
TASK_ID,
|
||||
UPLOAD_ID,
|
||||
ATTEMPT_ID,
|
||||
hashlib.sha256(collision_content).hexdigest(),
|
||||
"2026-08-04T09:01:00Z",
|
||||
collision_content,
|
||||
)
|
||||
transport = FakeTransport(HttpResponse(500, (), b""))
|
||||
from cmbuyer_client.core.errors import ProtocolRemoteError
|
||||
with self.assertRaises(ProtocolRemoteError):
|
||||
HttpEvidenceSink(transport).upload(DeviceCredentials(DEVICE_ID, SecretToken(TOKEN)), collision)
|
||||
self.assertEqual(transport.calls, [])
|
||||
|
||||
digest = hashlib.sha256(PNG).hexdigest()
|
||||
upload = EvidenceUpload(TASK_ID, UPLOAD_ID, ATTEMPT_ID, digest, "2026-08-04T09:01:00Z", PNG)
|
||||
mismatch = {
|
||||
"asset_id": ASSET_ID,
|
||||
"task_id": TASK_ID,
|
||||
"attempt_id": ATTEMPT_ID,
|
||||
"kind": "SKU_PANEL_GATE_1",
|
||||
"privacy_tier": "INTERNAL_RAW",
|
||||
"sha256": "f" * 64,
|
||||
"byte_size": len(PNG),
|
||||
"content_type": "image/png",
|
||||
"width_px": 1,
|
||||
"height_px": 1,
|
||||
"captured_at": "2026-08-04T09:01:00Z",
|
||||
}
|
||||
raw = json.dumps(mismatch, separators=(",", ":")).encode()
|
||||
with self.assertRaises(AmbiguousRemoteError):
|
||||
HttpEvidenceSink(FakeTransport(HttpResponse(201, (("Content-Type", "application/json"),), raw))).upload(
|
||||
DeviceCredentials(DEVICE_ID, SecretToken(TOKEN)), upload
|
||||
)
|
||||
|
||||
dimension_mismatch = dict(mismatch)
|
||||
dimension_mismatch["sha256"] = digest
|
||||
dimension_mismatch["width_px"] = 2
|
||||
raw = json.dumps(dimension_mismatch, separators=(",", ":")).encode()
|
||||
with self.assertRaises(AmbiguousRemoteError):
|
||||
HttpEvidenceSink(FakeTransport(HttpResponse(201, (("Content-Type", "application/json"),), raw))).upload(
|
||||
DeviceCredentials(DEVICE_ID, SecretToken(TOKEN)), upload
|
||||
)
|
||||
|
||||
def test_evidence_error_status_matrix(self) -> None:
|
||||
digest = hashlib.sha256(PNG).hexdigest()
|
||||
upload = EvidenceUpload(TASK_ID, UPLOAD_ID, ATTEMPT_ID, digest, "2026-08-04T09:01:00Z", PNG)
|
||||
credentials = DeviceCredentials(DEVICE_ID, SecretToken(TOKEN))
|
||||
from cmbuyer_client.core.errors import CredentialRemoteError, ManualRemoteError, ProtocolRemoteError
|
||||
|
||||
cases = (
|
||||
(401, CredentialRemoteError),
|
||||
(400, ProtocolRemoteError),
|
||||
(403, ProtocolRemoteError),
|
||||
(409, ManualRemoteError),
|
||||
(413, ProtocolRemoteError),
|
||||
(415, ProtocolRemoteError),
|
||||
(500, AmbiguousRemoteError),
|
||||
(503, AmbiguousRemoteError),
|
||||
)
|
||||
for status, expected in cases:
|
||||
with self.subTest(status=status), self.assertRaises(expected):
|
||||
HttpEvidenceSink(FakeTransport(HttpResponse(status, (), b""))).upload(credentials, upload)
|
||||
@@ -0,0 +1,184 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import http.client
|
||||
import unittest
|
||||
from unittest import mock
|
||||
|
||||
from cmbuyer_client.core.errors import AmbiguousRemoteError, ProtocolRemoteError
|
||||
from cmbuyer_client.remote.http_transport import HttpTransport
|
||||
|
||||
|
||||
class FakeResponse:
|
||||
status = 200
|
||||
|
||||
def __init__(self, body: bytes = b"{}", headers: list[tuple[str, str]] | None = None) -> None:
|
||||
self.body = body
|
||||
self.headers = headers or [("Content-Type", "application/json")]
|
||||
|
||||
def getheader(self, name: str) -> str | None:
|
||||
return str(len(self.body)) if name == "Content-Length" else None
|
||||
|
||||
def getheaders(self) -> list[tuple[str, str]]:
|
||||
return self.headers
|
||||
|
||||
def read(self, maximum: int) -> bytes:
|
||||
return self.body[:maximum]
|
||||
|
||||
|
||||
class FakeConnection:
|
||||
def __init__(self, host: str, port: int, timeout: int) -> None:
|
||||
self.created = (host, port, timeout)
|
||||
self.calls = 0
|
||||
self.closed = False
|
||||
self.sent_headers: dict[str, str] = {}
|
||||
self.response = FakeResponse()
|
||||
|
||||
def request(self, method: str, path: str, body: bytes, headers: dict[str, str]) -> None:
|
||||
self.calls += 1
|
||||
self.sent_headers = headers
|
||||
|
||||
def getresponse(self) -> FakeResponse:
|
||||
return self.response
|
||||
|
||||
def close(self) -> None:
|
||||
self.closed = True
|
||||
|
||||
|
||||
class HttpTransportTests(unittest.TestCase):
|
||||
def test_exact_loopback_and_proxy_environment_is_irrelevant(self) -> None:
|
||||
made: list[FakeConnection] = []
|
||||
|
||||
def factory(*args: object, **kwargs: object) -> FakeConnection:
|
||||
connection = FakeConnection(*args, **kwargs)
|
||||
made.append(connection)
|
||||
return connection
|
||||
|
||||
with mock.patch.dict(os.environ, {"HTTP_PROXY": "http://example.invalid:9999"}):
|
||||
result = HttpTransport(connection_factory=factory).request(
|
||||
"POST", "/api/v1/tasks/claim-next", (("Content-Type", "application/json"),), b"{}", response_limit=10
|
||||
)
|
||||
self.assertEqual(result.status, 200)
|
||||
self.assertEqual(made[0].created, ("127.0.0.1", 8080, 10))
|
||||
self.assertEqual(made[0].calls, 1)
|
||||
self.assertTrue(made[0].closed)
|
||||
|
||||
for url in ("http://localhost:8080", "http://127.0.0.1:8081", "http://127.0.0.1:8080/", "https://127.0.0.1:8080"):
|
||||
with self.subTest(url=url), self.assertRaises(ProtocolRemoteError):
|
||||
HttpTransport(url)
|
||||
|
||||
def test_network_failure_is_ambiguous_without_retry(self) -> None:
|
||||
class Broken(FakeConnection):
|
||||
def getresponse(self) -> FakeResponse:
|
||||
raise OSError("offline")
|
||||
|
||||
made: list[Broken] = []
|
||||
|
||||
def factory(*args: object, **kwargs: object) -> Broken:
|
||||
connection = Broken(*args, **kwargs)
|
||||
made.append(connection)
|
||||
return connection
|
||||
|
||||
with self.assertRaises(AmbiguousRemoteError):
|
||||
HttpTransport(connection_factory=factory).request(
|
||||
"POST", "/api/v1/tasks/claim-next", (), b"{}", response_limit=10
|
||||
)
|
||||
self.assertEqual(made[0].calls, 1)
|
||||
|
||||
def test_generator_headers_and_content_length_framing(self) -> None:
|
||||
made: list[FakeConnection] = []
|
||||
|
||||
def factory(*args: object, **kwargs: object) -> FakeConnection:
|
||||
connection = FakeConnection(*args, **kwargs)
|
||||
made.append(connection)
|
||||
return connection
|
||||
|
||||
headers = ((name, value) for name, value in (("Content-Type", "application/json"), ("Accept", "application/json")))
|
||||
HttpTransport(connection_factory=factory).request(
|
||||
"POST", "/api/v1/tasks/claim-next", headers, b"{}", response_limit=8
|
||||
)
|
||||
self.assertEqual(made[0].sent_headers["Content-Type"], "application/json")
|
||||
self.assertEqual(made[0].sent_headers["Accept"], "application/json")
|
||||
|
||||
legal = FakeConnection("127.0.0.1", 8080, 10)
|
||||
legal.response = FakeResponse(b"{}", [("Content-Length", "2")])
|
||||
accepted = HttpTransport(connection_factory=lambda *args, **kwargs: legal).request(
|
||||
"POST", "/api/v1/tasks/claim-next", (), b"{}", response_limit=8
|
||||
)
|
||||
self.assertEqual(accepted.body, b"{}")
|
||||
chunked = FakeConnection("127.0.0.1", 8080, 10)
|
||||
chunked.response = FakeResponse(b"{}", [("Transfer-Encoding", "Chunked")])
|
||||
accepted_chunked = HttpTransport(connection_factory=lambda *args, **kwargs: chunked).request(
|
||||
"POST", "/api/v1/tasks/claim-next", (), b"{}", response_limit=8
|
||||
)
|
||||
self.assertEqual(accepted_chunked.body, b"{}")
|
||||
|
||||
cases = (
|
||||
([('Transfer-Encoding', 'chunked'), ('Content-Length', '2')], b'{}'),
|
||||
([('Transfer-Encoding', 'gzip')], b'{}'),
|
||||
([('Transfer-Encoding', ' chunked ')], b'{}'),
|
||||
([('Transfer-Encoding', 'chunked,gzip')], b'{}'),
|
||||
([('Transfer-Encoding', 'chunked'), ('Transfer-Encoding', 'chunked')], b'{}'),
|
||||
([("Content-Length", "2"), ("Content-Length", "2")], b"{}"),
|
||||
([("Content-Length", "+2")], b"{}"),
|
||||
([("Content-Length", "-0")], b""),
|
||||
([("Content-Length", "2x")], b"{}"),
|
||||
([("Content-Length", "3")], b"{}"),
|
||||
([("Content-Length", "1")], b"{}"),
|
||||
([("Content-Length", "999")], b"{}"),
|
||||
([], b"0123456789"),
|
||||
)
|
||||
for response_headers, body in cases:
|
||||
with self.subTest(headers=response_headers, body=body):
|
||||
connection = FakeConnection("127.0.0.1", 8080, 10)
|
||||
connection.response = FakeResponse(body, response_headers)
|
||||
with self.assertRaises(AmbiguousRemoteError):
|
||||
HttpTransport(connection_factory=lambda *args, value=connection, **kwargs: value).request(
|
||||
"POST", "/api/v1/tasks/claim-next", (), b"{}", response_limit=8
|
||||
)
|
||||
|
||||
def test_timeout_incomplete_read_and_close_do_not_expose_partial_body(self) -> None:
|
||||
token = ("a" * 64).encode()
|
||||
|
||||
class Incomplete(FakeResponse):
|
||||
def read(self, maximum: int) -> bytes:
|
||||
raise http.client.IncompleteRead(token, 1)
|
||||
|
||||
class Connection(FakeConnection):
|
||||
def getresponse(self) -> FakeResponse:
|
||||
return Incomplete()
|
||||
|
||||
def close(self) -> None:
|
||||
self.closed = True
|
||||
raise OSError("close failed")
|
||||
|
||||
with self.assertRaises(AmbiguousRemoteError) as captured:
|
||||
HttpTransport(connection_factory=Connection).request(
|
||||
"POST", "/api/v1/tasks/claim-next", (), b"{}", response_limit=128
|
||||
)
|
||||
self.assertNotIn(token.decode(), _exception_graph(captured.exception))
|
||||
|
||||
class Timeout(FakeConnection):
|
||||
def getresponse(self) -> FakeResponse:
|
||||
raise TimeoutError("timed out")
|
||||
|
||||
with self.assertRaises(AmbiguousRemoteError):
|
||||
HttpTransport(connection_factory=Timeout).request(
|
||||
"POST", "/api/v1/tasks/claim-next", (), b"{}", response_limit=8
|
||||
)
|
||||
|
||||
|
||||
def _exception_graph(error: BaseException) -> str:
|
||||
seen: set[int] = set()
|
||||
values: list[str] = []
|
||||
pending: list[object] = [error]
|
||||
while pending:
|
||||
value = pending.pop()
|
||||
if id(value) in seen:
|
||||
continue
|
||||
seen.add(id(value))
|
||||
values.append(repr(value))
|
||||
if isinstance(value, BaseException):
|
||||
pending.extend(item for item in (value.__cause__, value.__context__) if item is not None)
|
||||
pending.extend(value.__dict__.values())
|
||||
return "\n".join(values)
|
||||
@@ -0,0 +1,162 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import unittest
|
||||
|
||||
from cmbuyer_client.core.errors import AmbiguousRemoteError, CredentialRemoteError, ManualRemoteError, ProtocolRemoteError
|
||||
from cmbuyer_client.core.models import ClaimRequest, DeviceCredentials, RenewRequest, SecretToken
|
||||
from cmbuyer_client.remote.http_transport import HttpResponse
|
||||
from cmbuyer_client.remote.task_source import HttpTaskSource
|
||||
from tests.core.test_models import ATTEMPT_ID, TASK_ID, TOKEN, claim_wire
|
||||
from tests.remote.test_http_transport import _exception_graph
|
||||
|
||||
|
||||
DEVICE_ID = "e3c9f507-7473-4fa6-8d71-8786c34c6301"
|
||||
SESSION_ID = "23c9f507-7473-4fa6-8d71-8786c34c6301"
|
||||
REQUEST_ID = "33c9f507-7473-4fa6-8d71-8786c34c6301"
|
||||
RENEW_ID = "43c9f507-7473-4fa6-8d71-8786c34c6301"
|
||||
|
||||
|
||||
class FakeTransport:
|
||||
def __init__(self, response: HttpResponse) -> None:
|
||||
self.response = response
|
||||
self.calls: list[tuple[object, ...]] = []
|
||||
|
||||
def request(self, *args: object, **kwargs: object) -> HttpResponse:
|
||||
self.calls.append(args + (kwargs,))
|
||||
return self.response
|
||||
|
||||
|
||||
def response(status: int, value: object | None = None) -> HttpResponse:
|
||||
body = b"" if value is None else json.dumps(value, ensure_ascii=False, separators=(",", ":")).encode()
|
||||
headers = () if value is None else (("Content-Type", "application/json; charset=utf-8"),)
|
||||
return HttpResponse(status, headers, body)
|
||||
|
||||
|
||||
class TaskSourceTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.credentials = DeviceCredentials(DEVICE_ID, SecretToken(TOKEN))
|
||||
|
||||
def test_claim_success_and_empty_each_send_once_with_exact_headers(self) -> None:
|
||||
transport = FakeTransport(response(200, claim_wire()))
|
||||
claimed = HttpTaskSource(transport).claim_next(self.credentials, ClaimRequest(SESSION_ID, REQUEST_ID))
|
||||
self.assertEqual(claimed.task.id, TASK_ID)
|
||||
self.assertEqual(len(transport.calls), 1)
|
||||
args = transport.calls[0]
|
||||
self.assertEqual(args[1], "/api/v1/tasks/claim-next")
|
||||
headers = dict(args[2])
|
||||
self.assertEqual(headers["Authorization"], "Bearer " + TOKEN)
|
||||
self.assertEqual(headers["X-CMBuyer-Device-ID"], DEVICE_ID)
|
||||
|
||||
empty = FakeTransport(response(204))
|
||||
self.assertIsNone(HttpTaskSource(empty).claim_next(self.credentials, ClaimRequest(SESSION_ID, REQUEST_ID)))
|
||||
self.assertEqual(len(empty.calls), 1)
|
||||
|
||||
def test_invalid_2xx_is_unknown_and_redirect_is_not_followed(self) -> None:
|
||||
malformed = FakeTransport(HttpResponse(200, (("Content-Type", "application/json"),), b'{"task":'))
|
||||
with self.assertRaises(AmbiguousRemoteError):
|
||||
HttpTaskSource(malformed).claim_next(self.credentials, ClaimRequest(SESSION_ID, REQUEST_ID))
|
||||
self.assertEqual(len(malformed.calls), 1)
|
||||
|
||||
secret_body = b'{"claim_token":"' + TOKEN.encode()
|
||||
leaking = FakeTransport(HttpResponse(200, (("Content-Type", "application/json"),), secret_body))
|
||||
with self.assertRaises(AmbiguousRemoteError) as captured:
|
||||
HttpTaskSource(leaking).claim_next(self.credentials, ClaimRequest(SESSION_ID, REQUEST_ID))
|
||||
self.assertNotIn(TOKEN, _exception_graph(captured.exception))
|
||||
|
||||
for status in (201, 202, 206):
|
||||
with self.subTest(status=status), self.assertRaises(AmbiguousRemoteError):
|
||||
HttpTaskSource(FakeTransport(response(status, claim_wire()))).claim_next(
|
||||
self.credentials, ClaimRequest(SESSION_ID, REQUEST_ID)
|
||||
)
|
||||
|
||||
redirect = FakeTransport(HttpResponse(302, (("Location", "http://example.invalid"),), b""))
|
||||
with self.assertRaises(ProtocolRemoteError):
|
||||
HttpTaskSource(redirect).claim_next(self.credentials, ClaimRequest(SESSION_ID, REQUEST_ID))
|
||||
self.assertEqual(len(redirect.calls), 1)
|
||||
|
||||
def test_claim_rejects_service_field_bound_drift_as_ambiguous(self) -> None:
|
||||
mutations = (
|
||||
("title", "😀" * 121),
|
||||
("sku_color", "色" * 81),
|
||||
("sku_size", "码" * 81),
|
||||
("max_total_price", "1" * 30 + ".00"),
|
||||
)
|
||||
for field, invalid in mutations:
|
||||
value = claim_wire()
|
||||
value["task"][field] = invalid
|
||||
transport = FakeTransport(response(200, value))
|
||||
with self.subTest(field=field), self.assertRaises(AmbiguousRemoteError):
|
||||
HttpTaskSource(transport).claim_next(self.credentials, ClaimRequest(SESSION_ID, REQUEST_ID))
|
||||
self.assertEqual(len(transport.calls), 1)
|
||||
|
||||
goods_id = "1" * 33
|
||||
value = claim_wire()
|
||||
value["task"].update(
|
||||
goods_id=goods_id,
|
||||
product_url="https://mobile.yangkeduo.com/goods.html?goods_id=" + goods_id,
|
||||
)
|
||||
with self.assertRaises(AmbiguousRemoteError):
|
||||
HttpTaskSource(FakeTransport(response(200, value))).claim_next(
|
||||
self.credentials, ClaimRequest(SESSION_ID, REQUEST_ID)
|
||||
)
|
||||
|
||||
def test_fixed_conflict_and_renew_cas(self) -> None:
|
||||
conflict = FakeTransport(response(409, {"error": "claim_requires_manual"}))
|
||||
with self.assertRaises(ManualRemoteError):
|
||||
HttpTaskSource(conflict).claim_next(self.credentials, ClaimRequest(SESSION_ID, REQUEST_ID))
|
||||
|
||||
request = RenewRequest(TASK_ID, RENEW_ID, SESSION_ID, ATTEMPT_ID, 1, SecretToken(TOKEN), "2026-08-04T09:05:00Z", "2026-08-04T10:00:00Z")
|
||||
renewed = response(200, {"task_id": TASK_ID, "attempt_id": ATTEMPT_ID, "claim_generation": 1, "lease_expires_at": "2026-08-04T09:06:00Z"})
|
||||
result = HttpTaskSource(FakeTransport(renewed)).renew(self.credentials, request)
|
||||
self.assertEqual(result.claim_generation, 1)
|
||||
|
||||
capped = RenewRequest(TASK_ID, RENEW_ID, SESSION_ID, ATTEMPT_ID, 1, SecretToken(TOKEN), "2026-08-04T10:00:00.000000000Z", "2026-08-04T10:00:00Z")
|
||||
capped_result = response(200, {"task_id": TASK_ID, "attempt_id": ATTEMPT_ID, "claim_generation": 1, "lease_expires_at": "2026-08-04T10:00:00Z"})
|
||||
self.assertEqual(HttpTaskSource(FakeTransport(capped_result)).renew(self.credentials, capped).lease_expires_at, "2026-08-04T10:00:00Z")
|
||||
|
||||
beyond_cap = response(200, {"task_id": TASK_ID, "attempt_id": ATTEMPT_ID, "claim_generation": 1, "lease_expires_at": "2026-08-04T10:00:00.000000001Z"})
|
||||
with self.assertRaises(AmbiguousRemoteError):
|
||||
HttpTaskSource(FakeTransport(beyond_cap)).renew(self.credentials, request)
|
||||
|
||||
stale = response(200, {"task_id": TASK_ID, "attempt_id": ATTEMPT_ID, "claim_generation": 1, "lease_expires_at": "2026-08-04T09:04:00Z"})
|
||||
with self.assertRaises(AmbiguousRemoteError):
|
||||
HttpTaskSource(FakeTransport(stale)).renew(self.credentials, request)
|
||||
for status in (201, 204):
|
||||
with self.subTest(status=status), self.assertRaises(AmbiguousRemoteError):
|
||||
HttpTaskSource(FakeTransport(response(status, None if status == 204 else {
|
||||
"task_id": TASK_ID,
|
||||
"attempt_id": ATTEMPT_ID,
|
||||
"claim_generation": 1,
|
||||
"lease_expires_at": "2026-08-04T09:06:00Z",
|
||||
}))).renew(self.credentials, request)
|
||||
|
||||
def test_claim_and_renew_error_status_matrix(self) -> None:
|
||||
claim_request = ClaimRequest(SESSION_ID, REQUEST_ID)
|
||||
claim_cases = (
|
||||
(HttpResponse(401, (), b""), CredentialRemoteError),
|
||||
(response(400, {"error": "invalid_request"}), ProtocolRemoteError),
|
||||
(HttpResponse(403, (), b""), ProtocolRemoteError),
|
||||
(response(413, {"error": "request_too_large"}), ProtocolRemoteError),
|
||||
(response(415, {"error": "unsupported_media_type"}), ProtocolRemoteError),
|
||||
(HttpResponse(500, (), b""), AmbiguousRemoteError),
|
||||
(HttpResponse(503, (), b""), AmbiguousRemoteError),
|
||||
(HttpResponse(418, (), b""), ProtocolRemoteError),
|
||||
)
|
||||
for wire_response, expected in claim_cases:
|
||||
with self.subTest(status=wire_response.status), self.assertRaises(expected):
|
||||
HttpTaskSource(FakeTransport(wire_response)).claim_next(self.credentials, claim_request)
|
||||
|
||||
renew_request = RenewRequest(
|
||||
TASK_ID,
|
||||
RENEW_ID,
|
||||
SESSION_ID,
|
||||
ATTEMPT_ID,
|
||||
1,
|
||||
SecretToken(TOKEN),
|
||||
"2026-08-04T09:05:00Z",
|
||||
"2026-08-04T10:00:00Z",
|
||||
)
|
||||
for code in ("idempotency_conflict", "claim_not_current"):
|
||||
with self.subTest(code=code), self.assertRaises(ManualRemoteError):
|
||||
HttpTaskSource(FakeTransport(response(409, {"error": code}))).renew(self.credentials, renew_request)
|
||||
@@ -47,3 +47,24 @@ class LoggingPolicyTests(unittest.TestCase):
|
||||
self.assertNotIn("not-for-log", content)
|
||||
self.assertNotIn("13900139000", content)
|
||||
self.assertIn("[已隐藏]", content)
|
||||
|
||||
def test_bearer_bare_token_and_traceback_are_redacted(self) -> None:
|
||||
token = "a" * 64
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
paths = RuntimePaths.from_root(Path(directory))
|
||||
logger = configure_application_logger(paths)
|
||||
try:
|
||||
try:
|
||||
raise RuntimeError("credential=" + token)
|
||||
except RuntimeError:
|
||||
logger.exception("Authorization: Bearer %s bare=%s", token, token)
|
||||
for handler in logger.handlers:
|
||||
handler.flush()
|
||||
content = (paths.logs / "client.log").read_text(encoding="utf-8")
|
||||
finally:
|
||||
for handler in tuple(logger.handlers):
|
||||
logger.removeHandler(handler)
|
||||
handler.close()
|
||||
|
||||
self.assertNotIn(token, content)
|
||||
self.assertIn("[已隐藏]", content)
|
||||
|
||||
@@ -2,16 +2,18 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest import mock
|
||||
|
||||
|
||||
CLIENT_ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(CLIENT_ROOT / "src"))
|
||||
|
||||
from cmbuyer_client.runtime import RuntimePaths
|
||||
from cmbuyer_client.runtime import LocalStateRuntime, RuntimePaths
|
||||
|
||||
|
||||
class RuntimePathsTests(unittest.TestCase):
|
||||
@@ -23,3 +25,58 @@ class RuntimePathsTests(unittest.TestCase):
|
||||
|
||||
self.assertTrue(paths.logs.is_dir())
|
||||
self.assertTrue(paths.artifacts.is_dir())
|
||||
self.assertTrue(paths.state.is_dir())
|
||||
self.assertEqual(paths.database, paths.state / "client-state.sqlite3")
|
||||
|
||||
def test_localstate_runtime_acquires_mutex_before_protector_and_store(self) -> None:
|
||||
events: list[str] = []
|
||||
|
||||
class Mutex:
|
||||
def __init__(self, path: Path) -> None:
|
||||
events.append("mutex")
|
||||
|
||||
def close(self) -> None:
|
||||
events.append("close")
|
||||
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
runtime = LocalStateRuntime.open(
|
||||
RuntimePaths.from_root(Path(directory)),
|
||||
mutex_factory=Mutex,
|
||||
protector_factory=lambda: events.append("protector") or object(),
|
||||
store_factory=lambda path, protector: events.append("store") or object(),
|
||||
)
|
||||
runtime.close()
|
||||
self.assertEqual(events, ["mutex", "protector", "store", "close"])
|
||||
|
||||
def test_localstate_runtime_releases_mutex_if_open_fails(self) -> None:
|
||||
events: list[str] = []
|
||||
|
||||
class Mutex:
|
||||
def __init__(self, path: Path) -> None:
|
||||
events.append("mutex")
|
||||
|
||||
def close(self) -> None:
|
||||
events.append("close")
|
||||
|
||||
def fail() -> object:
|
||||
raise RuntimeError("failed")
|
||||
|
||||
with tempfile.TemporaryDirectory() as directory, self.assertRaises(RuntimeError):
|
||||
LocalStateRuntime.open(
|
||||
RuntimePaths.from_root(Path(directory)),
|
||||
mutex_factory=Mutex,
|
||||
protector_factory=fail,
|
||||
)
|
||||
self.assertEqual(events, ["mutex", "close"])
|
||||
|
||||
def test_windows_without_localappdata_fails_instead_of_creating_second_database(self) -> None:
|
||||
with mock.patch("cmbuyer_client.runtime.os.name", "nt"), mock.patch.dict(os.environ, {}, clear=True):
|
||||
with self.assertRaisesRegex(RuntimeError, "local_app_data_required"):
|
||||
RuntimePaths.default()
|
||||
|
||||
def test_runtime_root_is_frozen_absolute_and_relative_localappdata_is_rejected(self) -> None:
|
||||
paths = RuntimePaths.from_root(Path("relative-runtime"))
|
||||
self.assertTrue(paths.root.is_absolute())
|
||||
with mock.patch.dict(os.environ, {"LOCALAPPDATA": "relative-local-app-data"}, clear=True):
|
||||
with self.assertRaisesRegex(RuntimeError, "local_app_data_must_be_absolute"):
|
||||
RuntimePaths.default()
|
||||
|
||||
@@ -87,13 +87,15 @@ T-103 的 `SkuSelectionFlow` 只允许证据绑定的受控入口、精确选择
|
||||
|
||||
执行按任务依赖驱动,**不按 Phase 整段串行等待**。当前优先路径:
|
||||
|
||||
1. T-111 先冻结“开始采购授权”的单趟契约;同时主审并合入只创建 `DRAFT` 的 T-202。
|
||||
2. T-111 完成后恢复 **T-103 真机取证**;按依赖推进 T-104 → T-107 的分段安全判据。
|
||||
3. admin agent 先做 T-209 把旧两趟 schema / 状态机迁到单趟,再推进 T-203 服务端批量开始采购 /
|
||||
一次性授权;两者不依赖页面选择器。T-205 起的 attempt 与证据字段等待 T-103。
|
||||
4. Phase 3:双端打通与单趟围栏前 dry-run。
|
||||
5. Phase 4:单趟真实提交、待付款、调和与收尾。
|
||||
6. V2 及以后:图搜、Excel、ERP、订单自动核对、AI 辅助。
|
||||
1. 完成 **T-103 人工真机验收**,再按依赖推进 T-104 → T-107 的分段安全判据。
|
||||
2. admin agent 在 T-107 后推进 T-210 → T-205 → T-206 → T-207 → T-208,冻结三闸门证据、
|
||||
attempt 事件、围栏、一次性结果与人工调和。
|
||||
3. client agent 先做当前可领取的 T-303,再推进 T-304 / T-306 / T-307 / T-308;与 admin 写路径
|
||||
不重叠的任务继续并行。
|
||||
4. T-305 完成围栏前真机 dry-run;T-400 只用 fixture/fake port 建立离线一次性提交安全闭包。
|
||||
5. T-403 → T-402 先准备围栏后调和与人工付款事实记录;T-401 才在新真实单趟中首次点击一次,
|
||||
点击后固定报 `UNKNOWN` 并由人调和,绝不付款。
|
||||
6. T-405 生成可核验 Windows 包,T-404 对同一候选 commit 与产物做最终只读验收;V2 能力继续后置。
|
||||
|
||||
> **M2 是本项目的生死线**:真机能按链接打开商品、精确勾选颜色分类和尺码、
|
||||
> **读到该 SKU 的单价**(T-103)。前序项目正是卡在选规格和读价。
|
||||
|
||||
@@ -31,7 +31,10 @@
|
||||
| 传输 | ADB(USB 或 WiFi) | 已定 | `uiautomator2` 3.x 走 adb 通道,`ip:port` 与 USB serial 同等对待 |
|
||||
| 桌面 GUI | `PySide6` | 已定 | 前序项目已验证;执行员需要看设备状态和批次进度 |
|
||||
| 截图处理 | `Pillow` | 已定 | 判断页面是否渲染完成,避免保存白屏壳层 |
|
||||
| HTTP 客户端 | 标准库 `urllib` 或 `httpx` | **待定** | 先用标准库;确有重试/连接池需求再评估 |
|
||||
| HTTP 客户端 | 标准库 `http.client` | 已定 | 只直连 `127.0.0.1:8080`;不读代理、不跟随重定向、不做隐藏重试或连接池 |
|
||||
| 可恢复状态 | 标准库 `sqlite3`(WAL / FULL) | 已定 | request/slot 先落库再 HTTP;每个操作独立连接,恢复读取使用单一事务快照 |
|
||||
| 秘密保护 | Windows Current User DPAPI | 已定 | token 原始 32 字节只以绑定 profile+device/attempt context 的密文 BLOB 入库;非 Windows 不降级 |
|
||||
| 单实例 | Windows `Global\` named mutex | 已定 | 以规范数据库路径 hash 命名,先于 DPAPI/SQLite 取得,覆盖同用户跨 session |
|
||||
| Excel | 不引入 | 已定 | Excel 解析移到采购服务;采购工具不再直接读表 |
|
||||
| 测试 | `unittest`(标准库) | 已定 | 前序项目 171 项测试均用标准库,无需 pytest |
|
||||
| 打包 | `pyinstaller` | 已定 | 交付给运营电脑;开发期依赖 |
|
||||
@@ -55,6 +58,10 @@
|
||||
Go 侧重写表头校验和行级报错,不能直接复用前序项目的 Python 实现。
|
||||
- **采购工具不持有业务权威。** 金额上限、授权有效性、任务状态流转的判定权在采购服务;
|
||||
采购工具本地校验只作为第二道防线,两边不一致时一律转人工。
|
||||
- **HTTP 不做自动重试。** claim/renew/evidence 的重放权属于持有 durable 幂等槽的恢复门面;底层每次
|
||||
方法最多一个请求。401 修复 Bearer 后、结果不明或重启恢复都必须复用原 key 与原 body/file。
|
||||
- **客户端 SQLite 只保存恢复事实。** append-only session/claim/renew/evidence history 不是服务端任务
|
||||
权威;它的作用是阻止崩溃、并发或状态损坏导致第二次领取、换图或换 key。
|
||||
- **不引入 pytest / 不引入 ORM。** 同一职责不并存两套方案。
|
||||
|
||||
## 五、构建与运行命令
|
||||
|
||||
+127
-4
@@ -16,8 +16,8 @@
|
||||
│ · 提交围栏、结果调和、内部证据与审计 │
|
||||
│ · 服务端渲染管理页面 │
|
||||
└───────────────────┬─────────────────────┘
|
||||
│ HTTPS / JSON
|
||||
│ Bearer + 设备绑定
|
||||
│ 本机回环 HTTP / JSON(MVP)
|
||||
│ Bearer + 设备绑定;非回环前必须先上 TLS
|
||||
v
|
||||
┌─────────────────────────────────────────┐
|
||||
│ 采购工具(client/,Python + PySide6) │
|
||||
@@ -141,6 +141,8 @@ T-103 真机证据表明:拼多多 `8.17.0`、goods_id `937122477375` 通过
|
||||
| 外部支付页 | 检测到外部支付交接立即停止,不读取、保存或输入凭据 | 凭据泄露 |
|
||||
| 安全校验 | 验证码、风控、人脸、短信出现即停止,不绕过 | 封号 / 违规 |
|
||||
| 内部截图 | 可上传页面已显示的地址/手机号;不解析成字段或日志,完整 XML 不上传 | 非必要扩散 |
|
||||
| 身份隔离 | 管理 session+CSRF 与设备 Bearer 分属不同路由域,混合凭据不叠加权限 | 设备越权 / 会话冒充 |
|
||||
| Bearer 传输 | MVP 仅绑定 IPv4 回环 `127.0.0.1:8080`;非回环访问先建立 HTTPS/TLS 终止 | 明文局域网泄露 token |
|
||||
| 授权一次性 | 一条任务版本只有一份有效授权;幂等重放不生成第二份 | 重复采购 |
|
||||
| 服务端提交围栏 | 点击前原子创建唯一提交记录;失败或响应不明不得点击 | 并发 / 断网重复下单 |
|
||||
| App 版本绑定 | 运行版本不同于证据版本时停止并重新取证 | 旧判据误点 |
|
||||
@@ -227,9 +229,42 @@ CREATE TABLE purchase_attempts (
|
||||
failure_code TEXT,
|
||||
started_at TEXT NOT NULL,
|
||||
finished_at TEXT,
|
||||
UNIQUE (task_id, claim_generation)
|
||||
UNIQUE (task_id, claim_generation),
|
||||
UNIQUE (task_id, authorization_id, id, claim_generation)
|
||||
);
|
||||
|
||||
-- attempt 的设备/session 所有权与可恢复租约;token 明文永不入库
|
||||
CREATE TABLE purchase_attempt_claims (
|
||||
attempt_id TEXT PRIMARY KEY,
|
||||
task_id TEXT NOT NULL,
|
||||
authorization_id TEXT NOT NULL UNIQUE,
|
||||
claimed_by_device_id TEXT NOT NULL REFERENCES device_credentials(device_id),
|
||||
session_id TEXT NOT NULL,
|
||||
claim_generation INTEGER NOT NULL,
|
||||
task_version INTEGER NOT NULL,
|
||||
task_title TEXT NOT NULL,
|
||||
authorization_task_version INTEGER NOT NULL,
|
||||
goods_id TEXT NOT NULL,
|
||||
sku_color TEXT NOT NULL,
|
||||
sku_size TEXT NOT NULL,
|
||||
quantity INTEGER NOT NULL,
|
||||
total_price_cap TEXT NOT NULL,
|
||||
authorization_expires_at TEXT NOT NULL,
|
||||
claim_nonce BLOB NOT NULL, -- 32 字节随机 nonce
|
||||
claim_token_sha256 BLOB NOT NULL, -- 32 字节 hash,不是 token 明文
|
||||
lease_expires_at TEXT NOT NULL,
|
||||
claimed_at TEXT NOT NULL,
|
||||
closed_at TEXT,
|
||||
FOREIGN KEY (task_id, authorization_id, attempt_id, claim_generation)
|
||||
REFERENCES purchase_attempts(task_id, authorization_id, id, claim_generation)
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX purchase_attempt_claims_one_open_per_device_idx
|
||||
ON purchase_attempt_claims (claimed_by_device_id) WHERE closed_at IS NULL;
|
||||
|
||||
-- claim-next 的 CLAIMED / EMPTY / BLOCKED 与 renew CAS 都持久化,保证跨重启幂等。
|
||||
-- 复合外键同时绑定 attempt、设备、session、generation 与 token hash,应用 bug 不能跨归属写事实。
|
||||
|
||||
-- 真机真实点击前建立;一份授权最多一条
|
||||
CREATE TABLE order_submissions (
|
||||
id TEXT PRIMARY KEY,
|
||||
@@ -246,12 +281,51 @@ CREATE TABLE order_submissions (
|
||||
UNIQUE (authorization_id),
|
||||
UNIQUE (attempt_id)
|
||||
);
|
||||
|
||||
-- 设备 token 明文只在签发完成后显示一次;数据库仅保存原始 32 字节 token 的 SHA-256
|
||||
CREATE TABLE device_credentials (
|
||||
device_id TEXT PRIMARY KEY, -- 规范小写 UUIDv4
|
||||
display_name TEXT NOT NULL, -- 非秘密运维名称
|
||||
token_sha256 BLOB NOT NULL UNIQUE, -- 恰好 32 字节
|
||||
status TEXT NOT NULL, -- ACTIVE | REVOKED
|
||||
created_at TEXT NOT NULL,
|
||||
revoked_at TEXT,
|
||||
CHECK ((status = 'ACTIVE' AND revoked_at IS NULL)
|
||||
OR (status = 'REVOKED' AND revoked_at IS NOT NULL AND revoked_at >= created_at))
|
||||
);
|
||||
|
||||
-- INTERNAL_RAW 原始截图;原文件名和客户端路径不进入数据库
|
||||
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, -- T-204 仅 SKU_PANEL_GATE_1
|
||||
privacy_tier TEXT NOT NULL, -- 仅 INTERNAL_RAW
|
||||
sha256 TEXT NOT NULL, -- 64 位小写十六进制
|
||||
byte_size INTEGER NOT NULL,
|
||||
content_type TEXT NOT NULL, -- 仅 image/png
|
||||
width_px INTEGER NOT NULL,
|
||||
height_px INTEGER NOT NULL,
|
||||
storage_key TEXT NOT NULL, -- 由 SHA-256 唯一派生
|
||||
uploaded_by_device_id TEXT NOT NULL,
|
||||
captured_at TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
UNIQUE (uploaded_by_device_id, upload_key),
|
||||
FOREIGN KEY (task_id, attempt_id) REFERENCES purchase_attempts(task_id, id)
|
||||
);
|
||||
```
|
||||
|
||||
MVP 不再用 `spec_trials` 作为审批记录,也不存在 `authorized_unit_price`。实际读价属于
|
||||
`purchase_attempts` / `order_submissions` 的执行与审计事实;管理员授权的资金边界始终是
|
||||
`total_price_cap`。
|
||||
|
||||
设备凭据由本机管理 CLI 签发、列出和撤销。token 是 32 字节加密随机值,以 64 位小写十六进制
|
||||
只显示一次;服务端把 token 解码回原始字节后计算 SHA-256,并与按设备 id 查出的 32 字节 BLOB
|
||||
恒定时间比较。未知 id 也执行固定宽度 dummy compare。认证逐请求查库,因此撤销事务提交后才开始的
|
||||
请求全部拒绝;提交前已经完成认证的在途请求不追溯取消。格式/未知/错配/撤销统一空 401,存储故障
|
||||
空 503,两类都在读取业务请求体前失败闭合。
|
||||
|
||||
### 5.2 状态机
|
||||
|
||||
```text
|
||||
@@ -286,6 +360,20 @@ DRAFT / PENDING / NEEDS_MANUAL ─管理员取消(围栏前)→ CANCELED
|
||||
|
||||
### 5.3 授权、租约与恢复
|
||||
|
||||
- claim/renew 使用独立 32 字节 HMAC secret;配置为 64 位小写十六进制,不得等于 session secret 或
|
||||
任一设备 token。每条 claim 以版本域分隔 HMAC 绑定 device/task/authorization/attempt/generation/
|
||||
32 字节随机 nonce,SQLite 只保存 nonce 和 token SHA-256。启动会重建并恒定时核对所有 open/closed
|
||||
claim;secret 错误时失败闭合,不轮换 token。
|
||||
- claim 与 renew 的 SQLite 事务必须先通过条件 no-op UPDATE 取得写入线性化位置并复核设备 ACTIVE,
|
||||
才能读取或重放 request。撤销先提交则请求失败;claim/renew 先取得写位置则该事务可完成,随后撤销
|
||||
不会自动释放已建立的 claim。
|
||||
- 同一授权最多一个 attempt,同一设备最多一个未关闭 claim。EMPTY、人工恢复冲突和续租响应都持久化;
|
||||
同幂等键只能重放原结果,不能因候选变化、续租或服务重启递增 generation、轮换 token 或延长第二次。
|
||||
- claim 行同时冻结成功响应所需的 task 标题/版本以及 authorization 版本、规格、数量、总价上限和到期
|
||||
时间。旧 request 跨重启只从该快照重建;源 task/authorization 后续漂移不能改变历史响应,并会让新
|
||||
恢复或续租失败闭合。进入 `ORDERING` 的同 attempt 仅接受 task version 恰好比 claim 快照加一。
|
||||
- 租约 TTL 显式配置为正且严格短于授权 TTL;新到期时间不得超过授权到期。租约与授权边界相等即过期,
|
||||
没有宽限;过期、撤销、停止轮询和进程退出均不关闭 claim、不释放授权、不允许另一设备接管。
|
||||
- 授权带 `expires_at`,只有围栏前可转 `EXPIRED` / `ABANDONED`;任务回到 `DRAFT`,必须重新点击
|
||||
开始采购。旧授权永不复活。
|
||||
- 设备租约丢失不等于授权可安全重用。只有服务端确认该 attempt 未建立围栏,才能关闭 attempt 并
|
||||
@@ -293,6 +381,24 @@ DRAFT / PENDING / NEEDS_MANUAL ─管理员取消(围栏前)→ CANCELED
|
||||
- 围栏建立后即使租约过期也只恢复同一 `order_submission` 的调和,不能回到可领取队列。
|
||||
- 每种非终态都必须给出安全下一步,不能出现隐藏表单导致任务永久锁死。
|
||||
|
||||
#### 采购工具本地恢复状态
|
||||
|
||||
- 生产入口先取得基于规范数据库路径的 Windows `Global\` named mutex,再构造 DPAPI 和 SQLite;不能
|
||||
以服务端“单设备最多一个 claim”替代本机单实例。
|
||||
- profile、polling session、claim/renew request、open/historical claim、evidence marker/slot 使用 WAL、
|
||||
`synchronous=FULL` 与 append-only/单调关闭约束。当前 session/claim 由 `closed_at IS NULL` partial
|
||||
unique 保证唯一,历史关闭后不阻塞下一条,但不得删除或复活。
|
||||
- `DurableClientGateway` 是轮询和截图接入的唯一顺序入口:先 durable prepare,再一次 HTTP,最后原子
|
||||
commit。停止只把当前 session 的 `accept_new` 设为 false;飞行中响应仍提交,pending/open 不清除。
|
||||
- 每次发送前校验 profile/session/request/active immutable business snapshot、renew history、evidence marker/slot/
|
||||
receipt 的完整状态图;snapshot、renew response 与 evidence receipt 还保存不可变摘要。任一冗余事实不一致、
|
||||
DPAPI context 不匹配、数据库缺行或文件 identity 变化时零 HTTP 停止,不能自行“修复”。
|
||||
- 设备 Bearer 的 401 发生在服务端读取 body/写幂等事实前,因此槽保持 `PENDING`;只允许同 device id
|
||||
更新 Bearer,并在用户再次开始后用原 key/body/file 尝试。claim token 和其他 frozen 配置不变。
|
||||
- 原始 token 以 DPAPI current-user context 密文保存:device token 绑定 profile+device,claim/renew token
|
||||
绑定 profile+attempt,密文不能跨行复用;日志 formatter 对 Bearer、裸 64 位 token 和
|
||||
traceback 做最终脱敏。异常对象也只保留固定 reason,不挂接含响应 body/partial/path 的异常上下文。
|
||||
|
||||
### 5.4 证据分层
|
||||
|
||||
| 数据 | 位置 | 边界 |
|
||||
@@ -306,6 +412,22 @@ DRAFT / PENDING / NEEDS_MANUAL ─管理员取消(围栏前)→ CANCELED
|
||||
截图上传器只能接收调用方显式指定的截图,不能枚举证据目录或顺带上传 XML/manifest。证据响应
|
||||
使用 `Cache-Control: no-store`,不能暴露为免登录静态目录。
|
||||
|
||||
内部截图存储采用以下固定边界:
|
||||
|
||||
- 单个 PNG 最大 10 MiB、单边最大 8192 px、总像素最大 16,777,216;同时验证 multipart MIME、
|
||||
PNG 魔数、完整解码、字节数、尺寸和调用方声明的 SHA-256。
|
||||
- 上传 handler 必须先通过逐请求 SQLite 设备认证并再次校验规范 principal,再解析 Content-Type 或
|
||||
读取 body。空库、无效或已撤销凭据拒绝,认证存储故障返回 503;不把管理员 session 当设备身份。
|
||||
- 首次 evidence INSERT 必须由 store 校验和 SQLite trigger 双重证明 `(task, attempt, authenticated device)`
|
||||
对应未关闭 claim。历史同设备/upload key 重放先于该检查,因此人工关闭 claim 不会破坏已提交资产的
|
||||
幂等读取;关闭后禁止新 upload key,且不为此增加 token/session 字段或放宽截图 kind。
|
||||
- 文件写入显式配置的私有证据根目录:同目录随机临时文件 → 流式 hash → 校验 → `fsync` → 原子
|
||||
rename 到 SHA-256 内容地址 → 最后事务写数据库。数据库永远不指向半文件或缺失文件。
|
||||
- SQLite 与文件系统不能组成跨资源事务;极端故障最多留下不可达孤儿文件。不得为清理孤儿而删除
|
||||
可能被其他资产记录并发复用的内容文件,自动保留/删除策略留给部署任务。
|
||||
- SHA-256 只用于物理内容寻址,不是业务资产唯一键;不同合法证据可以引用相同内容。同设备主体与
|
||||
`upload_key` 同载荷重放原资产,任一规范字段变化即冲突。
|
||||
|
||||
## 六、关键技术难点
|
||||
|
||||
| 难点 | 风险 | 应对 |
|
||||
@@ -354,7 +476,8 @@ cmbuyer/
|
||||
└── scripts/
|
||||
```
|
||||
|
||||
执行器依赖 `TaskSource` / `ResultSink`,不直接读取 Excel 或拼接 HTTP。来源变化不得改变安全执行器。
|
||||
执行器依赖核心端口,不直接读取 Excel 或拼接 HTTP。T-303 只提供 `TaskSource` / `EvidenceSink`;完整
|
||||
`ResultSink` 在服务端 events/fail/fence/result 契约落地后分阶段组合,来源变化不得改变安全执行器。
|
||||
|
||||
## 九、架构纪律
|
||||
|
||||
|
||||
+25
-20
@@ -19,16 +19,17 @@ Phase 是里程碑分组,不是整段串行栅栏。推荐波次:
|
||||
| --- | --- | --- | --- |
|
||||
| 1 | T-001 → T-004 → T-201 → T-202 | T-002 → T-101 → T-102 | T-003、原型与契约 |
|
||||
| 2 | T-209 → T-203 → T-204 | T-103 → T-104 → T-105 → T-107 | T-111 单趟重构;逐任务主审 |
|
||||
| 3 | T-205 → T-207 → T-208 | T-301 → T-304 → T-306 | 双端契约完整门禁 |
|
||||
| 4 | - | T-305 | 围栏前端到端 dry-run 主审 |
|
||||
| 5 | T-402 / T-403 | T-401 | 真机人工授权、提交与调和验收 |
|
||||
| 6 | - | T-405 | T-404 完整验收 |
|
||||
| 3 | T-210 → T-205 → T-206 → T-207 → T-208 | T-301 → T-302 → T-303,再并行 T-304 / T-306 / T-307 | 双端契约完整门禁 |
|
||||
| 4 | T-403 → T-402 | T-305;T-208 / T-303 / T-307 后做 T-308;再汇合到 T-400 | 围栏前 dry-run、客户端围栏恢复与离线单次提交主审 |
|
||||
| 5 | - | T-401 | 新真实单趟、首次一次性提交与人工调和验收 |
|
||||
| 6 | - | T-405 | T-404 完整只读验收 |
|
||||
|
||||
并行门禁:
|
||||
|
||||
- T-202 只创建/展示 `DRAFT`,可与 T-103 并行;T-203 只实现管理员授权与任务状态,不依赖页面
|
||||
选择器,也可在 T-103 完成前推进。
|
||||
- T-205 起涉及实际 attempt/证据字段,必须等待 T-103 确认规格和价格最小事实。
|
||||
- T-210 必须等待 T-107 真机确认三闸门证据类型;T-205 再基于三类真实 asset 冻结事件与摘要,
|
||||
不允许用 mock 或缺证据事件绕过生产 schema。
|
||||
- T-103、T-105、T-106、T-107 的 capability 相互隔离;未经取证的后续动作不可提前暴露。
|
||||
- T-401 前任何任务都不得真实点击“提交订单”;支付能力永远不实现。
|
||||
- 主 agent 独立审阅每个子 agent 的 diff 并重跑验证,不以其自报完成作为证据。
|
||||
@@ -59,9 +60,9 @@ Phase 是里程碑分组,不是整段串行栅栏。推荐波次:
|
||||
| T-102 | 验证按链接打开商品详情页 | T-101 | canonical goods 链接进入对应商品;前台包与 goods_id 有证据 |
|
||||
| T-103 | 验证受控规格面板、精确选择与 SKU 单价 | T-102, T-110, T-111 | 精确唯一入口、维度内等值选择/读回、防前缀碰撞、当前价唯一读取;隔离 capability 不含数量/确认/提交 |
|
||||
| T-104 | 验证规格选择能力安全退出 | T-103 | 完成选择/读价/截图后可关闭面板或退出;不进入确认页、不提交 |
|
||||
| T-105 | 验证数量设置、读回与闸门二 | T-103 | 设置数量并精确读回;再次读规格/单价等于闸门一;不一致即停 |
|
||||
| T-106 | dry-run:确认页与闸门三取证 | T-105 | 进入确认页读规格、数量、应付总额并验证提交控件唯一;绝不点击 |
|
||||
| T-107 | 固化确认页与提交控件安全判据 | T-106 | 只使用本项目证据;dry-run 静态不可达提交;版本失配 fail closed |
|
||||
| T-105 | 验证数量设置、读回与闸门二 | T-104 | 先只读取证并由人确认,再设置数量、精确读回并复核规格/单价;不一致即停 |
|
||||
| T-106 | dry-run:确认页与闸门三取证 | T-105 | 只读采集导航前、确认页、最终控件及一次人工 Back 状态;agent 不执行页面动作 |
|
||||
| T-107 | 固化确认页与提交控件安全判据 | T-106 | 只点已取证导航控件一次;最终控件只读观察;一次 Back;静态不可达提交 |
|
||||
| T-110 | 批准证据绑定的受控规格入口 | T-103 no-go 决策 | 仅批准拼多多 8.17.0 已取证的精确唯一“快要抢光”,不泛化 |
|
||||
| T-111 | 调整为开始采购授权的单趟流程 | T-004, T-010, T-110 | 状态/API/原型/任务依赖一致;删除试选后确认但不放宽三闸门和围栏 |
|
||||
|
||||
@@ -77,10 +78,11 @@ T-106 / T-107 是发布前只读验证,不是业务任务的第一趟。首次
|
||||
| T-209 | 把核心 schema / 状态机迁移为单趟模型 | T-004, T-111 | 新 migration 安全替换旧 `spec_trials` / 授权锁价结构;新增 `purchase_attempts`;删除旧 MVP 状态;无业务数据事实写入迁移前置,遇未知旧数据 fail closed;领域/迁移测试同步 |
|
||||
| T-203 | 表格查询与批量开始采购(F-004、F-008、F-018) | T-202, T-209 | 只选 DRAFT;按钮明确“不付款”;事务内创建锁定任务字段的一次性授权并全批 `DRAFT → PENDING`;重放/冲突测试 |
|
||||
| T-204 | 路由化任务详情与内部截图资产(F-007、F-011) | T-203 | 抽屉/完整页共享 URL;状态详情;受保护 `INTERNAL_RAW` screenshot 上传/读取、SHA-256 与 no-store;不接收 XML |
|
||||
| T-205 | purchase attempt、事件与执行摘要 API | T-204, T-103 | 记录实际规格、两次单价、数量、确认页金额、固定 reason;不再创建 `spec_trials` 审批记录 |
|
||||
| T-210 | 扩展闸门二与闸门三证据类型 | T-107, T-204, T-302 | 先精确扩展 Gate2/Gate3 kind;保留 Gate1、归属/FK/trigger/幂等;有新事实时拒绝降级 |
|
||||
| T-205 | purchase attempt、事件与执行摘要 API | T-210 | 三条成功事件分别绑定正确 Gate1/2/3 asset,冻结摘要与围栏前 reason;不再创建 `spec_trials` 审批记录 |
|
||||
| T-206 | 单趟状态视图与安全主动作 | T-205 | PENDING/CLAIMED/ORDERING/NEEDS_MANUAL/WAITING_PAYMENT/RECONCILIATION_REQUIRED 显示和允许动作一致;无中途确认 |
|
||||
| T-207 | 授权有效期与围栏前重置(F-010) | T-206 | 围栏前过期/关闭旧授权后回 DRAFT;围栏后拒绝释放、取消、重新授权 |
|
||||
| T-208 | 提交围栏与结果调和 API(F-017) | T-207, T-107 | 原子复核授权/attempt/三闸门/唯一控件;首次许可一次;重放不许可点击;结果不明永久围栏且只调和 |
|
||||
| T-208 | 提交围栏与结果调和 API(F-017) | T-207, T-210 | 原子复核授权/attempt/三闸门/唯一控件;首次许可一次;重放不许可点击;结果不明永久围栏且只调和 |
|
||||
|
||||
### Phase 3 · 双端打通(单趟围栏前)
|
||||
|
||||
@@ -88,20 +90,23 @@ T-106 / T-107 是发布前只读验证,不是业务任务的第一趟。首次
|
||||
| --- | --- | --- | --- |
|
||||
| T-301 | 设备凭据与身份隔离(F-013) | T-201 | Bearer 不能建单/授权;管理会话不能领任务;凭据可撤销 |
|
||||
| T-302 | 已授权任务原子领取与租约(F-005) | T-301, T-203 | 只领 PENDING+有效授权;并发唯一;重放同一 attempt;claim token/generation 有效 |
|
||||
| T-303 | `HttpTaskSource` / `HttpResultSink` | T-302, T-002 | 执行器依赖抽象;幂等重放;线协议严格校验;不扩大 Excel 范围 |
|
||||
| T-303 | HTTP 任务源、证据 sink 与可恢复本地状态 | T-002, T-204, T-302 | `http.client` 单次回环请求、DPAPI/SQLite append-only 状态图、Global 单实例和 durable gateway;不伪造完整 ResultSink |
|
||||
| T-304 | 定时轮询与会话边界 | T-303, T-006 | 人启动后轮询;停止只阻止下次领取;连续失败停;当前任务/记录详情双视图 |
|
||||
| T-306 | 单趟截图与证据上传 | T-304, T-204 | 显式上传原始 screenshot;可含页面地址/手机号;完整 XML、本机路径、外部支付页不上传 |
|
||||
| T-305 | **单趟围栏前端到端 dry-run** | T-306, T-107, T-205 | 从领取连续跑到闸门三与提交控件验证,随后安全停止;全程事件/证据齐全,不申请真实许可、不点击提交 |
|
||||
| T-306 | 规格面板原始截图与可靠证据上传 | T-104, T-303 | 只发布 Gate1 的显式原始 PNG;唯一恢复槽;可含页面地址/手机号;不上传 XML/路径或扩 kind |
|
||||
| T-307 | 客户端 attempt 事件与失败 sink | T-205, T-303 | 窄 events/fail HTTP 与同键恢复;不实现 ResultSink、围栏、结果、PDD 或 UI |
|
||||
| T-308 | 客户端提交围栏与结果 sink | T-208, T-303, T-307 | 复用同一 transport/localstate;首次明确且 durable 的 CREATE_ONCE 才产生当前进程 opaque permit;重启不可重建 |
|
||||
| T-305 | **单趟围栏前端到端 dry-run** | T-107, T-304, T-306, T-307, T-210 | 连续跑到三闸门及最终控件只读观察,随后一次安全 Back 并以固定 reason 停在围栏前;不申请许可、不点击提交 |
|
||||
|
||||
### Phase 4 · 真实提交与收尾
|
||||
|
||||
| ID | 任务 | 依赖 | 验收要点 |
|
||||
| --- | --- | --- | --- |
|
||||
| T-401 | **单趟采购与提交围栏端到端** | T-305, T-208 | 同一设备会话通过三闸门、首次明确围栏许可后点击一次;结果不明只调和;明确创建转待付款;绝不付款 |
|
||||
| T-402 | 待付款收口与手工完成 | T-401 | 详情展示订单证据;人付款后手工标记;待付款不等于成功 |
|
||||
| T-403 | 失败分类与证据归档 | T-401 | 覆盖需求 reason;终态原子回写;围栏后无重试 |
|
||||
| T-404 | 完整 MVP 验收 | T-402, T-403 | P0 验收、双端门禁、真机记录和 current-state 全部通过 |
|
||||
| T-405 | 采购工具打包与运行文档 | T-404 | 运营电脑可安装运行,记录产物 SHA-256 |
|
||||
| T-400 | 离线单次提交安全编排 | T-305, T-308 | 只用 fixture/fake port 证明四条件、点击意图先落盘、调用恰好一次和崩溃后只调和;生产接线仍不可达 |
|
||||
| T-403 | 失败分类、证据归档与管理员调和视图 | T-208 | 只展示已有事实并复用同一 submission 调和事务;围栏后无重试、释放或重新授权 |
|
||||
| T-402 | 待付款人工收口与付款事实记录 | T-403 | 只记录人已在外部付款的事实;三组确认、完整 lineage 与追加式审计;系统不付款 |
|
||||
| T-401 | **首次真实提交与生产接线** | T-400, T-402 | 新真实单趟重新通过三闸门;首次明确许可后点击一次;首次运行固定 UNKNOWN 并人工调和;绝不付款 |
|
||||
| T-405 | 采购工具 Windows 打包与运行文档 | T-401 | PyInstaller onedir ZIP;拒绝秘密/状态/证据/tests/XML;记录 commit 与 SHA-256 |
|
||||
| T-404 | 完整 MVP 只读验收 | T-401, T-402, T-403, T-405 | 冻结同一候选 commit/产物做 P0、双端门禁和真机记录复核;缺陷退回原任务,不在验收任务修复 |
|
||||
|
||||
### V2 及以后
|
||||
|
||||
@@ -123,8 +128,8 @@ T-106 / T-107 是发布前只读验证,不是业务任务的第一趟。首次
|
||||
- **M2**:真机按链接打开商品、精确选择颜色/尺码并读 SKU 单价。(T-103)
|
||||
- **M3**:真机设置数量、闸门二、确认页闸门三和唯一提交控件 dry-run 通过,不点击。(T-107)
|
||||
- **M4**:管理员建单并通过批量开始采购创建一次性授权;设备可安全领取。(T-203、T-302)
|
||||
- **M5**:单趟从领取到围栏前 dry-run 跑通,事件与证据齐全。(T-305)
|
||||
- **M6**:单趟真实提交只创建待付款订单,围栏/调和/人工收口闭环。(T-401~T-404)
|
||||
- **M5**:围栏前 dry-run 跑通,客户端围栏恢复和离线一次性提交安全闭包成立。(T-305、T-308、T-400)
|
||||
- **M6**:新真实单趟只创建可能的待付款订单,围栏/调和/人工收口、Windows 交付和只读验收闭环。(T-401~T-405)
|
||||
|
||||
**M2 仍是生死线。** M2 前可以并行管理会话、DRAFT 建单和开始采购的服务端授权事务,但不能编写
|
||||
依赖未取证页面字段的生产执行逻辑。提速来自两端并行、窄任务和主审复用门禁,不来自删除安全闸门。
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
| IX-004 | US-002、US-004 | web 列表行 / `/tasks/{id}` | 双击行、浏览器前进后退 | 路由化详情抽屉或完整页,显示执行证据 | P0 | 已定 |
|
||||
| IX-005 | US-005 | web 批量操作条 | 点击开始采购 | 创建一次性授权并进入 `PENDING` | P0 | 已定 |
|
||||
| IX-006 | US-006 | web 待付款详情 | 查看后手工标记 | 只记录人工付款完成,不执行支付 | P0 | 已定 |
|
||||
| IX-007 | US-003 | desk 配置 tab | 保存并检查连接 | 服务、ADB、App 版本就绪 | P0 | 已定 |
|
||||
| IX-007 | US-003 | desk 配置 tab | 保存并校验配置 | 本地字段/路径有效;服务身份与真机能力在真实流程中验证 | P0 | 已定 |
|
||||
| IX-008 | US-003、US-008 | desk 采购执行 tab | 开始 / 停止轮询、选择记录、Esc | 单趟执行和安全人工接管 | P0 | 已定 |
|
||||
| IX-009 | US-008 | web / desk 通用错误反馈 | 请求或流程失败 | 显示阶段、原因和安全下一步 | P0 | 已定 |
|
||||
| IX-010 | US-005 | web 围栏前人工处理 | 重置 / 取消 | 关闭旧授权后回 `DRAFT` 或取消 | P0 | 已定 |
|
||||
@@ -57,7 +57,8 @@
|
||||
|
||||
### IX-006 待付款核对与标记完成
|
||||
|
||||
- 页面显示商品、目标与实际规格、数量、授权上限、两次单价、确认页总额和订单截图。
|
||||
- 页面显示商品、目标与实际规格、数量、授权上限、两次单价、确认页总额和三闸门内部证据;MVP
|
||||
没有提交后结果截图类型,不把确认页图片冒充订单结果图。
|
||||
- 首屏警示:“订单已创建,系统尚未付款。请在拼多多人工核对并付款。”
|
||||
- “标记为已付款并完成”只写系统状态,不打开支付页面、不触发手机操作。
|
||||
- 提交前用简短确认对话框说明这是人工事实记录;成功转 `SUCCEEDED`,重复提交幂等。
|
||||
@@ -105,7 +106,8 @@
|
||||
1. 当前任务在同一趟内完成闸门二和闸门三,并确认提交控件精确唯一。
|
||||
2. 客户端显示“正在申请提交围栏”,提交当前验证摘要。
|
||||
3. 只有服务端首次明确返回 `click_permitted=true`,客户端才先持久化唯一点击意图,再精确点击一次。
|
||||
4. 明确创建转待付款;外部支付、安全校验、超时、断连或未知页面转待调和。
|
||||
4. MVP 首次真实点击后因没有已取证的提交后页面判据,无条件上报 `UNKNOWN` 并转待调和;只有管理员
|
||||
对同一 submission 明确调和为已创建,才转待付款。
|
||||
5. 围栏后的 web 详情只显示同一 `submission_id` 的证据、调和记录与人工结论。
|
||||
|
||||
| 状态 | 允许动作 | 禁止动作 |
|
||||
|
||||
+188
-46
@@ -16,11 +16,29 @@
|
||||
|
||||
| 身份 | 凭据 | 能力 |
|
||||
| --- | --- | --- |
|
||||
| 管理员 | `HttpOnly; Secure; SameSite=Lax` 会话 cookie + CSRF | 建单、开始采购、查看内部证据、人工调和 |
|
||||
| 设备 | `Authorization: Bearer <device-token>` + 设备 id | 心跳、领取、事件、截图、围栏与结果 |
|
||||
| 管理员 | `HttpOnly; SameSite=Lax` 会话 cookie + CSRF;HTTPS 部署设 `Secure=true` | 建单、开始采购、查看内部证据、人工调和 |
|
||||
| 设备 | `Authorization: Bearer <device-token>` + `X-CMBuyer-Device-ID` | 心跳、领取、事件、截图、围栏与结果 |
|
||||
| ERP(V2) | 独立凭据 | 只读来源同步,不访问采购结果 |
|
||||
|
||||
设备凭据不能建单或开始采购;管理会话不能调用设备接口。未认证统一返回 `401`,无权返回 `403`。
|
||||
设备凭据不能建单或开始采购;管理会话不能调用设备接口。凭据缺失或无效返回 `401`,已认证但无权
|
||||
或管理写请求缺少有效 CSRF 返回 `403`;认证存储故障按下述规则返回 `503`。
|
||||
|
||||
设备请求的认证头采用以下固定格式:
|
||||
|
||||
- `Authorization` 和 `X-CMBuyer-Device-ID` 必须各出现且只出现一次;代理合并出的逗号列表也拒绝。
|
||||
- Authorization scheme 按 HTTP 规则大小写不敏感,但 scheme 后只允许一个 ASCII 空格;token 必须是
|
||||
加密随机生成的 32 字节值对应的 64 位小写十六进制文本。
|
||||
- 设备 id 必须是规范小写 UUIDv4。token 与设备 id 同时绑定,未知、错配、格式错误和已撤销均返回
|
||||
空 `401`,可带 `WWW-Authenticate: Bearer`,不区分具体原因。
|
||||
- 认证器逐请求读取 SQLite,不缓存 ACTIVE 结论。SQLite 查询或连接故障返回空 `503`;401 与 503
|
||||
都必须发生在 Content-Type 解析和 body 读取之前。
|
||||
- 管理 cookie 不替代设备凭据;Bearer 也不替代管理 session/CSRF。两类凭据同时出现时,各路由仍只
|
||||
采用自己的身份域,不把权限相加。
|
||||
|
||||
设备 token 由本机管理 CLI 签发,只在签发事务提交后向操作者显示一次;SQLite 仅保存 token 原始
|
||||
32 字节的 SHA-256(32 字节 BLOB),list/revoke、日志、错误和 HTTP 响应均不显示 token 或 hash。
|
||||
撤销幂等且不会恢复旧 token。MVP 服务只绑定 IPv4 回环 `127.0.0.1:8080`,设备 Bearer 只经过本机回环 HTTP;
|
||||
未来非回环访问必须先建立 HTTPS/TLS 终止与代理信任边界。
|
||||
|
||||
### 错误响应
|
||||
|
||||
@@ -30,7 +48,7 @@
|
||||
"code": "version_conflict",
|
||||
"message": "任务已变化,请刷新后重选",
|
||||
"retryable": false,
|
||||
"request_id": "018f..."
|
||||
"request_id": "c3c9f507-7473-4fa6-8d71-8786c34c6301"
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -53,13 +71,21 @@
|
||||
| `POST` | `/tasks/{id}/mark-paid` | 人工确认已付款并完成核对 |
|
||||
| `GET` | `/evidence/{asset_id}` | 登录后读取内部截图;`Cache-Control: no-store` |
|
||||
|
||||
`GET /tasks/{id}` 的完整页与列表抽屉共享同一服务端数据模型和详情模板。列表只可用同源请求携带
|
||||
`X-CMBuyer-View: drawer` 获取 HTML fragment;其他非空 view、跨站 fragment 请求或不接受
|
||||
`text/html` 的 fragment 请求均拒绝。直接导航同一 URL 始终返回完整页。
|
||||
|
||||
`GET /evidence/{asset_id}` 不经静态目录:未登录先返回 `401`,不查询和泄露资产是否存在;登录后
|
||||
缺失或畸形 id 返回空 `404`。成功只返回存储的 PNG,包含 `Content-Length`、固定安全文件名、
|
||||
`Cache-Control: no-store` 与 `X-Content-Type-Options: nosniff`,不返回原文件名或服务端路径。
|
||||
|
||||
### `POST /tasks`
|
||||
|
||||
核心字段:
|
||||
|
||||
```json
|
||||
{
|
||||
"create_key": "018f...",
|
||||
"create_key": "d3c9f507-7473-4fa6-8d71-8786c34c6301",
|
||||
"title": "纯棉短袖",
|
||||
"product_url": "https://mobile.yangkeduo.com/goods.html?goods_id=937122477375",
|
||||
"sku_color": "黑色CHA(纯棉)",
|
||||
@@ -70,6 +96,11 @@
|
||||
```
|
||||
|
||||
- 服务端解析并保存 canonical URL 与 `goods_id`;URL 非拼多多商品页、`goods_id` 缺失或含歧义则拒绝。
|
||||
- `title` 以 Go `strings.TrimSpace`(Unicode White_Space)后的持久化值计,最多 120 个 Unicode code
|
||||
point;`sku_color`、`sku_size` 同样按持久化值计,各最多 80 个 Unicode code point。非法 UTF-8
|
||||
必须先拒绝,不能把替换字符当作合法 code point;为消除 Go 与 Python 默认 trim 差异,U+001C--U+001F
|
||||
四个 C0 分隔符无论位置一律拒绝;超限不得截断。
|
||||
- `goods_id` 只允许 1--32 位 ASCII 数字;规范金额只允许 1--32 个 ASCII 字符。
|
||||
- `max_total_price` 是本任务允许创建待付款订单的总额上限,不是参考单价。
|
||||
- 成功只产生 `DRAFT`;不得创建授权、开放设备领取或触发真机。
|
||||
|
||||
@@ -77,10 +108,10 @@
|
||||
|
||||
```json
|
||||
{
|
||||
"start_key": "018f...",
|
||||
"start_key": "63c9f507-7473-4fa6-8d71-8786c34c6301",
|
||||
"tasks": [
|
||||
{"task_id": "018f-task-1", "expected_task_version": 1},
|
||||
{"task_id": "018f-task-2", "expected_task_version": 1}
|
||||
{"task_id": "83c9f507-7473-4fa6-8d71-8786c34c6301", "expected_task_version": 1},
|
||||
{"task_id": "93c9f507-7473-4fa6-8d71-8786c34c6301", "expected_task_version": 1}
|
||||
]
|
||||
}
|
||||
```
|
||||
@@ -99,11 +130,11 @@
|
||||
|
||||
```json
|
||||
{
|
||||
"start_key": "018f...",
|
||||
"start_key": "63c9f507-7473-4fa6-8d71-8786c34c6301",
|
||||
"authorized_count": 2,
|
||||
"tasks": [
|
||||
{"task_id": "018f-task-1", "task_version": 2, "authorization_id": "018f-auth-1"},
|
||||
{"task_id": "018f-task-2", "task_version": 2, "authorization_id": "018f-auth-2"}
|
||||
{"task_id": "83c9f507-7473-4fa6-8d71-8786c34c6301", "task_version": 2, "authorization_id": "a3c9f507-7473-4fa6-8d71-8786c34c6301"},
|
||||
{"task_id": "93c9f507-7473-4fa6-8d71-8786c34c6301", "task_version": 2, "authorization_id": "b3c9f507-7473-4fa6-8d71-8786c34c6301"}
|
||||
],
|
||||
"payment_automated": false
|
||||
}
|
||||
@@ -134,7 +165,7 @@
|
||||
|
||||
```json
|
||||
{
|
||||
"device_id": "desk-01",
|
||||
"device_id": "e3c9f507-7473-4fa6-8d71-8786c34c6301",
|
||||
"client_version": "0.1.0",
|
||||
"adb_serial": "192.168.0.173:5555",
|
||||
"android_release": "16",
|
||||
@@ -147,12 +178,22 @@
|
||||
|
||||
### `POST /api/v1/tasks/claim-next`
|
||||
|
||||
请求携带 `device_id`、`session_id`、`claim_request_id`。领取与授权绑定且具租约:
|
||||
设备 id 只来自已经认证的 `X-CMBuyer-Device-ID`,不得放进 JSON。请求体上限 4096 字节,只接受
|
||||
以下两个字段;二者都必须是规范小写 UUIDv4,未知字段、重复字段和额外 JSON 均拒绝:
|
||||
|
||||
```json
|
||||
{
|
||||
"session_id": "23c9f507-7473-4fa6-8d71-8786c34c6301",
|
||||
"claim_request_id": "33c9f507-7473-4fa6-8d71-8786c34c6301"
|
||||
}
|
||||
```
|
||||
|
||||
成功领取或同一会话恢复返回 `200`:
|
||||
|
||||
```json
|
||||
{
|
||||
"task": {
|
||||
"id": "018f-task",
|
||||
"id": "13c9f507-7473-4fa6-8d71-8786c34c6301",
|
||||
"version": 3,
|
||||
"title": "纯棉短袖",
|
||||
"product_url": "https://mobile.yangkeduo.com/goods.html?goods_id=937122477375",
|
||||
@@ -163,24 +204,78 @@
|
||||
"max_total_price": "30.00"
|
||||
},
|
||||
"authorization": {
|
||||
"id": "018f-auth",
|
||||
"id": "73c9f507-7473-4fa6-8d71-8786c34c6301",
|
||||
"task_version": 2,
|
||||
"expires_at": "2026-08-04T10:00:00Z"
|
||||
},
|
||||
"attempt": {
|
||||
"id": "018f-attempt",
|
||||
"claim_token": "opaque-single-claim-token",
|
||||
"id": "53c9f507-7473-4fa6-8d71-8786c34c6301",
|
||||
"claim_token": "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
|
||||
"claim_generation": 1,
|
||||
"lease_expires_at": "2026-08-04T09:05:00Z"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- 只返回有 `ACTIVE` 授权的 `PENDING`;服务端在一个事务中转为 `CLAIMED` 并创建 attempt。
|
||||
- 同一 `claim_request_id` 同载荷重放同一结果;并发设备只有一个成功。
|
||||
- 一个设备有未结束领取时优先重放该领取,不能悄悄领第二条。
|
||||
- 设备认证发生在 Content-Type 解析和 body 读取前。事务的第一条数据库业务语句取得 SQLite 写入
|
||||
位置并再次条件确认设备仍为 `ACTIVE`;之后才允许查幂等记录、候选或返回 EMPTY/冲突。
|
||||
- 只返回 `PENDING + ACTIVE + 严格未过期` 且 task/version/规格/数量/总价快照完全一致的最早授权;
|
||||
服务端在同一事务中创建唯一 attempt/claim/request,并转为 `CLAIMED`。
|
||||
- 同一 `claim_request_id` 同设备、同 session 稳定重放原结果;同键异载荷返回
|
||||
`409 {"error":"idempotency_conflict"}`。没有候选返回空 `204`,且 EMPTY 也持久化稳定重放。
|
||||
- claim 持久化完整成功响应快照;领取后的 task/authorization 源行变化不得让旧 request 的标题、规格、
|
||||
数量、金额、版本或到期时间漂移。每次首次构造和旧 request 重放都重新校验持久化响应快照的字段
|
||||
上限;源快照不一致时,新恢复/续租失败闭合。
|
||||
- 响应内 `title` 最多 120 个 Unicode code point,`sku_color` / `sku_size` 各最多 80 个;`goods_id`
|
||||
为 1--32 位 ASCII 数字,`max_total_price` 为最多 32 个 ASCII 字符的规范金额。创建、授权快照、
|
||||
candidate、持久化 claim snapshot 和 HTTP 输出共用同一合法域;既有畸形行只失败闭合,不迁移、
|
||||
截断或改写。candidate 查询必须先读取 task 与 authorization 两侧字段并分别验证;任一侧畸形必须
|
||||
回滚且不得持久化 `EMPTY`,只有两侧均合法但快照不一致时才跳过。最坏合法字段组合编码后必须明确
|
||||
小于既有 32 KiB claim 响应上限。
|
||||
- 一个设备最多有一个未关闭 claim。同 session 且租约有效时重放原 attempt;同一 attempt 已按服务端
|
||||
首事件原子进入 `ORDERING` 时也只在 task version 恰好为 claim 版本 +1 时恢复。不同 session、租约
|
||||
过期或业务状态异常固定返回 `409 {"error":"claim_requires_manual"}`,不释放、不转领、不新建 attempt。
|
||||
- `claim_token` 是 32 字节 HMAC 的 64 位小写十六进制表示,只证明一个 attempt 的归属,不是提交许可。
|
||||
SQLite 仅保存随机 nonce 与 token SHA-256;同一 secret 重启后重建相同 token,错误 secret 拒绝启动。
|
||||
- 响应不得包含自由动作脚本、CSS/XPath、通用坐标或支付能力。
|
||||
|
||||
### `POST /api/v1/tasks/{id}/lease/renew`
|
||||
|
||||
请求体同样限 4096 字节并执行严格 JSON 校验:
|
||||
|
||||
```json
|
||||
{
|
||||
"renew_request_id": "43c9f507-7473-4fa6-8d71-8786c34c6301",
|
||||
"session_id": "23c9f507-7473-4fa6-8d71-8786c34c6301",
|
||||
"attempt_id": "53c9f507-7473-4fa6-8d71-8786c34c6301",
|
||||
"claim_generation": 1,
|
||||
"claim_token": "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
|
||||
"expected_lease_expires_at": "2026-08-04T09:05:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
成功返回 `200`;响应不回显 token:
|
||||
|
||||
```json
|
||||
{
|
||||
"task_id": "13c9f507-7473-4fa6-8d71-8786c34c6301",
|
||||
"attempt_id": "53c9f507-7473-4fa6-8d71-8786c34c6301",
|
||||
"claim_generation": 1,
|
||||
"lease_expires_at": "2026-08-04T09:06:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
- 原设备/session/attempt/generation/token 必须同时匹配,当前租约与授权都必须严格晚于服务端 UTC
|
||||
当前时间,`expected_lease_expires_at` 必须逐字等于数据库当前值;边界相等即过期且不能复活。
|
||||
- 新到期时间是 `min(server_now + CMBUYER_CLAIM_LEASE_TTL, authorization.expires_at)`。续租不改变
|
||||
token、generation、任务版本或业务状态。
|
||||
- 成功续租先持久化 request 与响应;同一 `renew_request_id` 同载荷只重放旧响应,不再次 CAS 或延长。
|
||||
同键异载荷返回 `idempotency_conflict`;非当前 claim 固定返回 `claim_not_current`,两者均为 `409`。
|
||||
|
||||
claim/renew 的格式错误固定为 `400 {"error":"invalid_request"}`,超限为
|
||||
`413 {"error":"request_too_large"}`,Content-Type 错误为
|
||||
`415 {"error":"unsupported_media_type"}`;设备认证/事务内撤销为无诊断 `401`,存储故障为无诊断 `503`。
|
||||
|
||||
### 事件与证据
|
||||
|
||||
事件只包含固定 `step` / `outcome` / `reason_code` 和非敏感摘要。禁止把完整 XML、地址、手机号、
|
||||
@@ -190,17 +285,32 @@
|
||||
|
||||
```json
|
||||
{
|
||||
"attempt_id": "018f-attempt",
|
||||
"upload_key": "43c9f507-7473-4fa6-8d71-8786c34c6301",
|
||||
"attempt_id": "33c9f507-7473-4fa6-8d71-8786c34c6301",
|
||||
"kind": "SKU_PANEL_GATE_1",
|
||||
"privacy_tier": "INTERNAL_RAW",
|
||||
"sha256": "64-lowercase-hex",
|
||||
"sha256": "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
|
||||
"captured_at": "2026-08-04T09:01:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
- 允许规格面板和确认页截图保留页面已显示的地址/手机号;不要求遮罩或裁剪。
|
||||
- 不接受 XML、目录、manifest、本机绝对路径、外部支付页截图或支付凭据。
|
||||
- MIME、尺寸、字节数和 SHA-256 必须校验;资产只经管理员鉴权端点读取。
|
||||
- T-204 只开放 `kind=SKU_PANEL_GATE_1`;后续 kind 必须由对应真机证据任务收紧扩展。
|
||||
- `privacy_tier` 只能是 `INTERNAL_RAW`;时间必须是以 `Z` 结尾的 UTC RFC 3339。
|
||||
- URL 中的 task id、`upload_key` 与 `attempt_id` 都必须是规范的小写 UUIDv4;`sha256` 必须是
|
||||
恰好 64 位小写十六进制字符。
|
||||
- 恰好一个带 `Content-Type: image/png` 的显式文件;除上述六个元数据字段外,未知或重复字段均拒绝。
|
||||
- 单文件最多 10 MiB、单边最多 8192 px、总像素最多 16,777,216;服务端校验 PNG 魔数、完整解码、
|
||||
字节数、尺寸与调用方声明的 64 位小写 SHA-256。
|
||||
- `attempt_id` 必须由数据库复合外键证明属于 URL 中的 task,且首次写入必须存在由认证设备持有的
|
||||
未关闭 claim;设备 A 不能向设备 B 的 attempt 上传。认证必须先于 Content-Type 解析和请求体读取。
|
||||
- 同一设备主体和 `upload_key` 的同载荷重放返回原资产;即使 claim 后续由人工关闭,已成功资产仍先
|
||||
重放历史结果。关闭后不得用新 upload key 写新证据;任务、attempt、截图或元数据变化返回 `409`。
|
||||
- 首次成功返回 `201`,幂等重放返回 `200`。响应只含资产 id、关联 id、kind/tier、hash、字节数、
|
||||
MIME、宽高和采集时间,不含设备 token、原文件名或存储路径。
|
||||
- 生产上传使用逐请求 SQLite 设备认证;空凭据库、未知或已撤销设备均拒绝。不得使用管理员 session、
|
||||
临时共享密钥或其他身份代替设备凭据。
|
||||
|
||||
### `POST /api/v1/purchase-attempts/{aid}/submission-fence`
|
||||
|
||||
@@ -208,11 +318,11 @@
|
||||
|
||||
```json
|
||||
{
|
||||
"fence_key": "018f-fence-request",
|
||||
"task_id": "018f-task",
|
||||
"fence_key": "e3c9f507-7473-4fa6-8d71-8786c34c6301",
|
||||
"task_id": "13c9f507-7473-4fa6-8d71-8786c34c6301",
|
||||
"expected_task_version": 3,
|
||||
"authorization_id": "018f-auth",
|
||||
"claim_token": "opaque-single-claim-token",
|
||||
"authorization_id": "73c9f507-7473-4fa6-8d71-8786c34c6301",
|
||||
"claim_token": "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
|
||||
"selected_color": "黑色CHA(纯棉)",
|
||||
"selected_size": "M(建议100-115)",
|
||||
"gate1_unit_price": "12.88",
|
||||
@@ -232,7 +342,7 @@
|
||||
|
||||
```json
|
||||
{
|
||||
"submission_id": "018f-submission",
|
||||
"submission_id": "f3c9f507-7473-4fa6-8d71-8786c34c6301",
|
||||
"status": "FENCED",
|
||||
"click_permitted": true,
|
||||
"submit_text": "提交订单"
|
||||
@@ -249,10 +359,10 @@
|
||||
|
||||
```json
|
||||
{
|
||||
"result_key": "018f-result",
|
||||
"attempt_id": "018f-attempt",
|
||||
"result_key": "03c9f507-7473-4fa6-8d71-8786c34c6301",
|
||||
"attempt_id": "53c9f507-7473-4fa6-8d71-8786c34c6301",
|
||||
"observation": "SUBMITTED",
|
||||
"evidence_asset_id": "018f-asset"
|
||||
"evidence_asset_id": "63c9f507-7473-4fa6-8d71-8786c34c6301"
|
||||
}
|
||||
```
|
||||
|
||||
@@ -267,30 +377,62 @@
|
||||
|
||||
### 文本和金额校验
|
||||
|
||||
- 规格字段:Unicode 规范化后精确相等;不得包含、前缀、编辑距离或 AI 猜测。
|
||||
- `goods_id`:仅 ASCII 十进制数字,canonical URL 中唯一。
|
||||
- 金额:`0.01` 到系统配置上限,至多两位小数;规范化后再比较和持久化。
|
||||
- 标题/规格字段:输入按 Go `strings.TrimSpace` 的 Unicode White_Space 集形成实际持久化值;title 最多
|
||||
120 个 Unicode code point,颜色与尺码各最多 80 个。服务端先拒绝非法 UTF-8,再计 code point;
|
||||
U+001C--U+001F 四个 C0 分隔符在任意位置均拒绝,客户端使用同一固定空白集而不依赖 Python
|
||||
`str.strip()` 默认语义。不按 UTF-8 字节或视觉 grapheme 计数,不截断超限值。规格比较仍为规范化后
|
||||
精确相等;不得包含、前缀、编辑距离或 AI 猜测。
|
||||
- `goods_id`:仅 1--32 位 ASCII 十进制数字,canonical URL 中唯一。
|
||||
- 金额:`0.01` 到系统配置上限,至多两位小数;规范化后必须是最多 32 个 ASCII 字符,再比较和持久化。
|
||||
- 数量:正整数,服务端与设备均设置合理上限;不能从字符串静默截断。
|
||||
|
||||
## 三、采购工具本地模块合约
|
||||
|
||||
### `TaskSource` / `ResultSink`
|
||||
### T-303 已实现端口
|
||||
|
||||
```python
|
||||
class TaskSource(Protocol):
|
||||
def claim_next(self, session: Session) -> ClaimedPurchase | None: ...
|
||||
def renew_lease(self, claim: Claim) -> Lease: ...
|
||||
def claim_next(self, credentials: DeviceCredentials, request: ClaimRequest) -> ClaimedTask | None: ...
|
||||
def renew(self, credentials: DeviceCredentials, request: RenewRequest) -> RenewResult: ...
|
||||
|
||||
class ResultSink(Protocol):
|
||||
def append_events(self, claim: Claim, events: list[TaskEvent]) -> None: ...
|
||||
def upload_screenshot(self, claim: Claim, asset: ScreenshotAsset) -> AssetRef: ...
|
||||
def fail_attempt(self, claim: Claim, failure: AttemptFailure) -> None: ...
|
||||
def create_submission_fence(self, claim: Claim, proof: SubmissionProof) -> SubmissionPermit: ...
|
||||
def report_submission_result(self, permit: SubmissionPermit, result: SubmissionResult) -> None: ...
|
||||
class EvidenceSink(Protocol):
|
||||
def upload(self, credentials: DeviceCredentials, evidence: EvidenceUpload) -> AssetReceipt: ...
|
||||
```
|
||||
|
||||
执行器不能依赖具体 HTTP 或 Excel 实现。`SubmissionPermit` 只能由 `ResultSink` 的服务端成功响应构造,
|
||||
业务代码不能手工 new 一个许可。
|
||||
T-303 只实现 claim/renew 与 `SKU_PANEL_GATE_1` 单张 PNG;不得用运行时 `NotImplementedError` 伪造
|
||||
events/fail/fence/result 或完整 `ResultSink`。T-304/T-306 必须通过 `DurableClientGateway` 调用:它先把
|
||||
同一个 request/upload key 与完整载荷写入 SQLite,再最多发送一次 HTTP;401 保留 `PENDING`,由用户修复
|
||||
同一 device id 的 Bearer 后显式重放;网络、超时、503、截断、非法/未知 2xx 同样只保留原槽。协议/409
|
||||
终止槽但不换 key。成功响应落库失败时,重启仍用原 key 向服务端恢复事实。
|
||||
|
||||
金额按服务端合法域接受规范 ASCII 十进制正数字符串(最低 `0.01`,恰好两位小数、无前导零,最多
|
||||
32 个 ASCII 字符);`goods_id` 只接受 1--32 位 ASCII 数字,title 最多 120 个 Unicode code point,
|
||||
颜色与尺码各最多 80 个。客户端用与 Go `strings.TrimSpace` 相同的固定 Unicode White_Space 集校验
|
||||
持久化文本,并与服务端共同拒绝任意位置的 U+001C--U+001F;不得截断或修复漂移响应。wire 整数为正 int64,拒绝 bool。claim 成功
|
||||
响应总上限仍为 32 KiB,最坏合法字段组合由双端契约测试证明严格小于该值。RFC3339Nano 按 0--9 位
|
||||
小数的纳秒时间轴比较,不能用 Python 微秒精度截断。
|
||||
|
||||
### 本地恢复合约
|
||||
|
||||
- 数据库固定为 `%LOCALAPPDATA%\cmbuyer\state\client-state.sqlite3`,WAL + `synchronous=FULL`;Windows
|
||||
缺少 `LOCALAPPDATA` 时失败,不回退到 home 创建第二套状态。
|
||||
- device token 与 claim token 的原始 32 字节只以当前用户 DPAPI 密文 BLOB 入库;前者 context 绑定
|
||||
profile+device,后者绑定 profile+attempt,跨行交换密文会解密失败。claim token 不可更换;有
|
||||
pending/open 状态时冻结 service/device/ADB/transport/轮询与超时配置,仅允许同 device id 修复 Bearer;
|
||||
idle 时切换 device id 也必须同时提供新 token。
|
||||
- `Global\cmbuyer-<db-path-hash>` named mutex 在任何 SQLite/DPAPI 打开前取得;同一状态库跨 Windows
|
||||
session 只允许一个采购工具进程。
|
||||
- 运行根目录与数据库路径在构造时固化为绝对路径;之后 cwd 改变不得打开第二套库或绕过原 mutex。
|
||||
- polling session、claim history、renew request 和 evidence marker/slot 都是 append-only 历史;当前行用
|
||||
`closed_at IS NULL` partial unique 表示。恢复或发送前在同一 SQLite 读快照校验整张状态图,冗余列、
|
||||
snapshot、request、claim token、marker、slot、receipt 任一不一致都零 HTTP 失败闭合。
|
||||
- evidence 槽唯一键是 `(attempt_id, kind)`。首次发送前固定显式路径的 regular/non-reparse 文件 identity、
|
||||
size、mtime、SHA-256、IHDR 尺寸与字节;pending 时变化即停,并始终用首次保存的 exact metadata 重放
|
||||
相同 multipart。成功后 receipt 成为事实,源文件变化或删除只返回原 receipt,不再次上传;receipt
|
||||
尺寸必须与本地 IHDR 一致。客户端做签名/IHDR/尺寸/hash 防御;采购服务仍负责完整 PNG 解码权威校验。
|
||||
|
||||
完整 `ResultSink` 只有在 T-205/T-208 服务端契约完成后才由后续任务组合;`SubmissionPermit` 只能由
|
||||
服务端首次明确成功响应构造,业务代码不能手工创建。
|
||||
|
||||
### 真机能力分层
|
||||
|
||||
@@ -310,6 +452,6 @@ T-103 只实现隔离的 `SkuSelectionFlow`:前四项加安全退出。它的
|
||||
## 四、实现前仍需定值
|
||||
|
||||
- 授权有效期、领取租约时长、心跳/轮询间隔和连续失败停止阈值;
|
||||
- 截图大小上限和内部保留期限;
|
||||
- 内部截图保留期限;截图大小上限已固定为 10 MiB / 8192 px 单边 / 16,777,216 像素;
|
||||
- 可配置单任务数量与最高总价系统上限;
|
||||
- 首次真实提交真机任务的人工授权和待付款订单处置步骤。
|
||||
|
||||
+29
-18
@@ -19,15 +19,19 @@
|
||||
- 技术栈:已定。采购服务(`admin/`)使用 Go 1.23+ / gin / SQLite;采购工具(`client/`)
|
||||
使用 Python 3.11+ / uiautomator2 / PySide6。
|
||||
详见 [`03-tech-stack.md`](03-tech-stack.md)
|
||||
- 生产代码:`admin/` 已有最小 Go 服务、健康检查、核心领域模型、SQLite 迁移与任务状态机;
|
||||
- 生产代码:`admin/` 已有登录/会话、手工 DRAFT 建单、批量开始采购与一次性授权、任务详情、内部
|
||||
原始截图、设备身份,以及已授权任务的原子领取/幂等重放/租约续期;
|
||||
`client/` 已有 Python 包、PySide6 最小入口、运行目录与日志脱敏策略,以及显式 serial 的 ADB
|
||||
连接边界、本地基线取证 CLI、受限商品链接打开取证 CLI、人工声明规格面板状态的只读取证 CLI,
|
||||
以及绑定 PKG110 / Android 16 / 拼多多 8.17.0 的规格证据确定性脱敏 CLI;尚无规格选择、价格读取或下单流程
|
||||
- 测试:采购服务已覆盖健康检查、核心模型、迁移与状态机等离线包级测试;采购工具 80 项离线单元测试
|
||||
(全部 mock,不连接真机)
|
||||
- 数据:SQLite v2 单趟核心表与领域状态机已落成,旧 `spec_trials`、`authorized_unit_price` 和
|
||||
两趟状态已由受保护迁移移除;无业务实例数据。T-203 可以基于新模型实现批量“开始采购”与
|
||||
一次性授权事务。
|
||||
绑定 PKG110 / Android 16 / 拼多多 8.17.0 的证据工具,以及受控规格入口、精确规格选择、SKU 当前价
|
||||
读取、原始截图和一次安全退出能力;并已具备严格 localhost claim/renew/evidence HTTP、Windows DPAPI
|
||||
凭据保护、SQLite append-only 恢复状态图、Global named mutex 与 durable gateway。真机能力尚待最终人工
|
||||
验收,数量/确认页/提交仍未开放。
|
||||
- 测试:采购服务已覆盖登录、建单、授权、详情/证据、设备身份、迁移、原子 claim/renew 与竞态;
|
||||
采购工具 192 项离线单元测试(全部 mock,不连接真机)。
|
||||
- 数据:受保护迁移已落到 `00005_task_claims.sql`。旧两趟模型已移除;开始采购会锁定任务快照并签发
|
||||
一次性授权,领取会创建唯一 attempt/claim、HMAC claim token 与有界租约。T-211 已统一 title、SKU、
|
||||
goods_id 与金额的双端 wire 上限,并证明最坏合法 claim 响应小于 32 KiB。仓库不含业务实例数据。
|
||||
- 标准启动路径:Windows PowerShell 运行 `./init.ps1`,Unix shell 运行 `./init.sh`。Windows 入口
|
||||
优先使用合规的既有 venv;仅在其缺失时才从 Python Launcher 已安装版本中选择最高的 Python 3.11+,
|
||||
并且不覆盖低版本环境;成功后打印真实启动命令。
|
||||
@@ -39,20 +43,22 @@
|
||||
分别取证后才接入生产单趟流程。T-103 的 v5 派生 XML 已由人确认无实际地址/
|
||||
手机号,并保留目标预选规格、顶部当前价“快卖完 ¥12.88”和原价“¥29.88”;底部“提交订单 ¥12.88”
|
||||
继续属于硬拒绝区。派生截图虽然把顶部价格遮住一半,但项目已停止遮罩器开发,视觉完整性不再阻塞
|
||||
规格选择与读价;T-204 将直接上传内部原始截图供管理员查看。T-010 已允许不依赖真机字段的 T-201
|
||||
和只创建 `DRAFT` 的 T-202 并行。T-209 已完成 schema / 状态机单趟迁移;T-203 服务端
|
||||
“开始采购”授权事务可立即推进。T-205 起实际 attempt / 真机字段继续等待 T-103。
|
||||
规格选择与读价;T-204 已允许认证设备上传内部原始截图供管理员查看。入口父链证据已由人确认并
|
||||
收紧为精确唯一“快要抢光”子节点及证据绑定可点击祖先,“免拼购买”仍为硬拒绝。离线实现和 112 项
|
||||
测试已通过;T-103 只差人把手机置于 PDD 安全起点后完成精确规格恢复、读取 `12.88` 和一次安全退出。
|
||||
完整事件链必须等待 T-107 与 T-210:先让 Gate2/Gate3 证据进入真实 schema,再实现 T-205 事件,
|
||||
不用 mock 或无证据成功事件抢跑。
|
||||
|
||||
## 当前目录要点
|
||||
|
||||
| 路径 | 状态 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `docs/` | 已有 | 项目规范化文档,本次已完整生成 |
|
||||
| `docs/tasks/` | 已有(含 T-001~T-111、T-201~T-203、T-209) | T-111 单趟契约、T-202 手工 DRAFT 建单及 T-209 单趟 schema 已完成;T-103、T-203 并行推进 |
|
||||
| `docs/tasks/` | 已有(含 T-001~T-111、T-201~T-211、T-301~T-308、T-400~T-405) | T-303、T-211 已完成;T-103 等最终真机验收;双端围栏、真实提交、调和、打包与只读验收均已落成任务 |
|
||||
| `docs/design/` | 已有(6 个原型) | web 登录 / 建单 / 工作台 / 详情,desk 采购执行 / 配置;均已人工确认 |
|
||||
| `scripts/` | 已有 | 上下文门禁、Vikunja 单向导出与 MCP 启动包装 |
|
||||
| `admin/` | 已初始化 | Go 1.23+ / gin / SQLite,含核心模型、迁移与状态机;无真机采购执行 |
|
||||
| `client/` | 已初始化 | Python 3.11+ 包、依赖源、PySide6 最小入口、显式 serial 的基线/商品打开/规格面板只读取证、确定性证据脱敏、离线测试与 wheel 元数据检查;无规格选择、价格读取或下单流程 |
|
||||
| `admin/` | 已初始化 | Go 1.23+ / gin / SQLite,含建单/授权/详情/证据/设备身份与原子 claim/renew;不执行真机动作 |
|
||||
| `client/` | 已初始化 | Python 3.11+、PySide6/uiautomator2、严格 HTTP/DPAPI/SQLite 恢复底座及受控规格选择/读价/安全退出;数量、确认页和提交未开放 |
|
||||
| `init.ps1` / `init.sh` | 已完成 | 统一安装与离线验证入口;PowerShell 优先复用合规 venv,缺失时自动选择最高的 Python 3.11+,Unix 缺工具链明确失败 |
|
||||
|
||||
## 任务状态
|
||||
@@ -68,11 +74,16 @@
|
||||
- 已完成 T-010(安全并行门禁)、T-201(管理员登录与会话)与 T-202(手工 DRAFT 建单和
|
||||
基础列表)。T-202 已通过主 agent 独立审查、竞态测试与完整门禁并合入主线,仍只创建/展示
|
||||
`DRAFT`,未实现授权、设备领取或采购执行。
|
||||
- 已完成 T-110(受控规格入口边界)与 T-111(开始采购授权的单趟契约)。T-103 已恢复为
|
||||
`DOING`,以 `SkuSelectionFlow` 继续最小 fixture、精确规格和读价,不实现数量、确认页或提交;
|
||||
内部原始截图上传交给 T-204。admin 方向已完成 T-209,正在转入 T-203“开始采购”授权事务。
|
||||
- 已完成 T-209:SQLite v2 已迁移为单趟授权、采购尝试和提交围栏模型,并删除旧试选领域模型;
|
||||
迁移和状态机护栏已通过完整门禁。下一项采购服务任务为 T-203 批量“开始采购”与一次性授权。
|
||||
- 已完成 T-110、T-111、T-203、T-204、T-301 与 T-302。服务端已能授权、认证设备、原子领取、
|
||||
稳定重放和续租;claim token 不是提交许可,尚无事件、提交围栏或结果 API。
|
||||
- T-103 离线 `SkuSelectionFlow` 已实现并通过独立审计,仍为 `DOING` 等人工真机验收;T-104~T-107
|
||||
按数量/确认页风险逐段开放。服务端后续顺序已收紧为 T-210 → T-205 → T-206 → T-207 → T-208。
|
||||
- T-303 已完成并通过三重审查、189 项客户端测试和合并后根门禁;客户端下一步按依赖推进
|
||||
T-304/T-306/T-307/T-308,再与 T-305 汇合到
|
||||
T-400 离线一次性提交安全编排。围栏后管理链为 T-403 → T-402;T-401 必须在新的真实单趟重新
|
||||
通过三闸门,首次点击后固定进入 `UNKNOWN`/人工调和,T-405 打包后由 T-404 只读验收。
|
||||
- T-211 已完成 claim wire 字段上限闭合:创建、授权、candidate、持久化快照、重放、HTTP 输出和
|
||||
客户端解析使用同一合法域;单侧畸形历史数据失败闭合且不写 EMPTY,最坏合法响应严格小于 32 KiB。
|
||||
- 已确认原型继续只作信息架构依据;原型假数据不调用真实接口、不驱动真机。真机结论改变
|
||||
可读字段时必须先回修原型与交互清单。
|
||||
|
||||
|
||||
+17
-4
@@ -47,7 +47,9 @@
|
||||
| 创建时间 | 本地时区显示,数据按 UTC 保存 |
|
||||
|
||||
没有操作列。双击非控件区域或键盘 Enter 打开 `/tasks/{id}` 路由化详情抽屉;新 tab 直接访问同 URL
|
||||
则显示完整详情页。关闭抽屉或浏览器返回恢复筛选、滚动和触发行焦点。
|
||||
则显示完整详情页。标题下方同时提供可见“查看详情”按钮,双击不是唯一入口。商品外链、checkbox、
|
||||
输入和按钮本身不触发行双击。抽屉成功加载后才把 URL 推进 `/tasks/{id}`;关闭、Esc 或浏览器返回
|
||||
恢复筛选、滚动和触发行焦点,浏览器前进重新打开同一详情且不重复写 history。
|
||||
|
||||
### 批量开始采购
|
||||
|
||||
@@ -74,7 +76,8 @@
|
||||
2. 开始采购授权:授权 id、授权人、锁定任务版本、创建/有效期、当前状态。
|
||||
3. 设备执行:attempt、设备、App 版本、步骤时间线和失败 code。
|
||||
4. 三道闸门:两次规格面板单价、规格/数量读回、确认页总额与判定。
|
||||
5. 内部截图:规格面板、确认页和结果页;只经受保护端点读取。
|
||||
5. 内部截图:三道闸门已批准的规格面板/确认页证据;只经受保护端点读取。MVP 不存在提交后结果
|
||||
截图类型,不展示外部支付页,也不把 Gate3 图片冒充结果图。
|
||||
6. 提交围栏:submission id、是否首次明确许可、唯一点击和调和记录。
|
||||
7. 待付款收口:明确写系统尚未付款;人工核对后记录完成。
|
||||
|
||||
@@ -91,6 +94,14 @@
|
||||
|
||||
详情中不出现 `WAITING_CONFIRMATION`、“确认机器选对了吗”、“签发第二趟授权”或“重新试选”。
|
||||
|
||||
完整页与抽屉执行同一 `task-detail-content` 模板和只读查询。直接导航返回完整 SSR 文档;列表 JS 对
|
||||
同一 URL 发出同源 `X-CMBuyer-View: drawer` 请求,只取得 HTML fragment。加载失败时抽屉提供重试和
|
||||
“在完整页打开”,不会把失败请求伪装成已打开详情。
|
||||
|
||||
T-204 只显示数据库中当前实际存在的任务、授权、attempt、submission 和内部截图,缺少事实就显示
|
||||
明确空态;它不创建 attempt/event,不计算闸门,也不提供重置、调和、标记付款或任何设备动作。
|
||||
截图以服务端记录的宽高预留布局并延迟加载,alt 只描述证据种类和采集时间,不转录截图中的地址或手机号。
|
||||
|
||||
## 四、采购工具界面结构
|
||||
|
||||
应用名:**采购工具**。顶部固定 tab:
|
||||
@@ -112,10 +123,12 @@
|
||||
|
||||
### 配置 tab
|
||||
|
||||
- 采购服务 URL、设备 token(密码框,不回显完整值);
|
||||
- 采购服务 URL、设备 token(密码框,不回显完整值;首次必填,已有凭据时留空表示保留,非空表示替换);
|
||||
- ADB 路径、设备 serial、USB/WiFi 通道选择;
|
||||
- 轮询间隔与连续失败停止阈值;
|
||||
- 连接检查:服务、设备身份、拼多多安装和已取证版本。
|
||||
- 只做本地字段与路径格式检查,并提示服务身份、设备连接、拼多多安装和已取证版本将在首次真实领取/
|
||||
后续已批准真机流程中验证。配置页不调用 `claim-next`、renew、evidence 等业务 API,也不运行 ADB/PDD
|
||||
作为“连接检查”;MVP 不提供清除已保存设备凭据的命令。
|
||||
|
||||
配置凭据进入系统安全存储;日志和界面不显示完整 token。
|
||||
|
||||
|
||||
+14
-5
@@ -17,15 +17,12 @@ write_paths:
|
||||
- client/tests/pdd/**
|
||||
- client/tests/device/**
|
||||
- client/scripts/capture_sku_panel_spike.py
|
||||
- client/scripts/run_t103_sku_selection.py
|
||||
- client/scripts/sanitize_sku_panel_evidence.py
|
||||
- docs/02-requirements.md
|
||||
- docs/03-tech-stack.md
|
||||
- docs/04-architecture.md
|
||||
- docs/api.md
|
||||
- docs/current-state.md
|
||||
---
|
||||
|
||||
<!-- BEGIN VIKUNJA EXPORT id=23 synced=2026-08-04T08:24:57Z sha256=19a0864d83c5e41e57a29002b69f7767b50429dcf0fbace02f8303f92b71bac6 -->
|
||||
<!-- BEGIN VIKUNJA EXPORT id=23 synced=2026-08-04T10:47:47Z sha256=5471bdfeed1fffe4f0abd25201b9d5bf7bf55fa9500c11aa297eb4c422da5f02 -->
|
||||
## 问题 / 背景
|
||||
|
||||
T-102 已证明 canonical 链接可进入目标商品。T-103 在 PKG110 / Android 16 / 拼多多 8.17.0、goods_id `937122477375` 上确认:规格面板由详情页精确唯一的“快要抢光”打开;T-110 已把该证据/版本绑定入口批准为受控导航。面板刚打开时目标颜色“黑色CHA(纯棉)”和尺码“M(建议100-115)”自动选中。
|
||||
@@ -173,6 +170,18 @@ T-103 sanitizer v2 坐标修正与主审:提交 44c027a 将 screenshot space
|
||||
### 2026-08-04T08:24:12Z · ila
|
||||
|
||||
2026-08-04:T-111 单趟契约主审通过,T-103 解除架构阻塞。恢复后只实现 SkuSelectionFlow:受控入口、维度内精确选择、选中态读回、SKU 当前价唯一读取、原始截图和安全退出;不得包含数量、确认页、授权、提交围栏、提交订单或支付。客户端工作树现有未提交 v6 遮罩测试残留必须先撤销到已提交 v5 基线,再开始新实现。needs_device=true,离线实现后仍等待人工真机验收。
|
||||
|
||||
### 2026-08-04T09:50:25Z · ila
|
||||
|
||||
2026-08-04 离线实现与主审通过:提交 b49a9b4 实现仅限已取证 PDD 8.17.0 / goods_id 937122477375 的受控规格入口、颜色/尺码精确恢复、SKU 当前价读取、本机原始截图和单次安全退出;不包含数量、确认页、授权、提交围栏、提交订单或付款能力。独立审计 PASS,合入主分支 5f650f1;完整 init、109 项测试、Go vet/build、compileall、上下文校验及 diff-check 全部通过。needs_device=true,任务继续 DOING,等待项目所有者先把已记忆规格改为非目标值并返回 PDD 首页后运行真机验收。
|
||||
|
||||
### 2026-08-04T10:01:50Z · ila
|
||||
|
||||
2026-08-04 T-103 入口父容器追加真机证据:只读取证目录 C:\Users\ila20\AppData\Local\cmbuyer\artifacts\T-103\entry-parent-evidence-937122477375-20260804-175737;截图 screenshot.png,XML hierarchy.xml,manifest.json。设备 PKG110 / Android 16 / 拼多多 8.17.0 / Wi-Fi,goods_id 937122477375。项目所有者人工确认截图为目标商品详情页,且“快要抢光 12.88”与“免拼购买”的位置和手机当前画面一致。只读结构核对显示:精确“快要抢光”文本节点本身不可点击,但位于唯一、可见、启用、可点击的 PDD ViewGroup 祖先内;“免拼购买”属于另一底部可点击容器。后续只允许把入口判据收紧调整为“精确唯一快要抢光子节点 + 证据绑定唯一可点击祖先”,不得允许或点击“免拼购买”,必须先补 fixture/反例/超时不重试测试再真机运行。
|
||||
|
||||
### 2026-08-04T10:47:38Z · ila
|
||||
|
||||
2026-08-04 T-103 入口父链收紧完成:提交 44586fe,合入主分支 ce9d6ca。入口只允许精确唯一“快要抢光”子节点及五层已取证可点击 PDD 祖先链,实际点击仍取子节点中心;“免拼购买”独立容器为硬拒绝。独立审计复算证据 hash、验证 focused 32 / full 112 tests 和完整 init 均 PASS。最终真机运行曾安全失败且未发布输出目录;随后只读诊断发现系统通知栏覆盖,app_current 虽仍报告 PDD,但节点树 64 个节点全部属于 SystemUI。下一次必须由人先完全收起通知栏并停在 PDD 首页再运行;T-103 保持 DOING,不把这次失败或入口截图确认误记为完整验收。
|
||||
<!-- END VIKUNJA EXPORT -->
|
||||
|
||||
## 边界
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
---
|
||||
id: T-104
|
||||
title: 验证规格选择能力安全退出
|
||||
phase: 1
|
||||
deps: [T-103]
|
||||
status: TODO
|
||||
created: 2026-08-04
|
||||
vikunja_task_id: 32
|
||||
context_ref: 3a0a41d
|
||||
work_branch: task/t-104-safe-exit
|
||||
needs_device: true
|
||||
needs_human_review: true
|
||||
write_paths:
|
||||
- docs/tasks/T-104.md
|
||||
- client/src/cmbuyer_client/pdd/sku_selection.py
|
||||
- client/src/cmbuyer_client/pdd/sku_selection_runner.py
|
||||
- client/tests/pdd/test_sku_selection.py
|
||||
- client/tests/pdd/fixtures/product_exit_8_17_0.xml
|
||||
- client/scripts/capture_sku_exit_spike.py
|
||||
- client/scripts/run_t103_sku_selection.py
|
||||
- docs/current-state.md
|
||||
---
|
||||
|
||||
<!-- BEGIN VIKUNJA EXPORT id=32 synced=2026-08-04T15:33:56Z sha256=cd649f78ae8d79edcfe94fb3702f8dbf99b12d1d4cbf2c0069b14cffa8703464 -->
|
||||
## 问题 / 背景
|
||||
|
||||
T-103 已实现受控规格选择、闸门一读价、规格面板原始截图和单次 Back,但当前 `exit_sku_panel_safely()` 只要 hierarchy 变化且规格面板判据不再成立就返回成功。现有纯 mock 测试还把 Back 后的空 hierarchy 当成成功并允许 runner 发布 `safe_exit=completed`。2026-08-04 的只读诊断同时证明:通知栏覆盖时 `app_current` 仍可能报告拼多多,而节点树全部属于 SystemUI。因此“面板消失”或“前台包仍是 PDD”都不是安全退出的充分条件;T-104 必须先取得独立 post-exit 真机证据,再把后置条件收紧为稳定回到同一目标商品详情页。
|
||||
|
||||
## 关联需求与交互
|
||||
|
||||
- 功能:F-006 单趟流程中的安全退出边界。
|
||||
- 架构/API:复用并收紧 `SkuSelectionFlow.exit_sku_panel_safely()` 与现有 runner;不改变服务端 API,不新建第二套采购流程。
|
||||
- 依赖:T-103 完成真机规格选择、读价、原始截图和人工安全退出验收后才可开工;T-105 与 T-306 消费本任务结果。
|
||||
- `status` 保持 `TODO`,`needs_device` 与 `needs_human_review` 均保持 true;开始后也必须由人完成真机验收。
|
||||
|
||||
## 方案
|
||||
|
||||
1. 阶段 A 只新增窄取证脚本。人先把手机停在已验证的拼多多 8.17.0、goods_id `937122477375` 目标规格面板;脚本必须先在内存中核验版本、PDD 前台、已知面板结构、目标颜色/尺码选中态与已取证当前价,任一不符时零动作、零产物。
|
||||
2. 阶段 A 唯一状态改变是一次命名 Android Back。脚本不得发送商品 intent,不得调用任何页面 `click`,不得点击关闭坐标、空白、购买、数量、确认、提交或支付控件;Back 调用前即封存本次机会,超时、响应不明或故障调和均不得重试。
|
||||
3. Back 返回后只读采集 post-exit screenshot/XML/App/设备/goods_id 摘要并原子发布本机证据。此阶段 manifest 只能声明 `human_review_required` 与 `back_attempts=1`,人工确认前绝不能写 `safe_exit=completed`,也不得由 agent 根据“面板消失”自行判定成功。
|
||||
4. 人必须确认 post-exit 截图确为同一 goods_id `937122477375` 商品详情、规格面板已关闭、未进入确认/提交/支付或外部支付页、未创建订单,且截图/XML 对应。只有完成该确认后,阶段 B 才能从本项目 post-exit XML 提取不含个人信息或支付凭据的最小 `product_exit_8_17_0.xml` fixture。
|
||||
5. T-103 的旧 `product_entry_8_17_0.xml` 只有通用“快要抢光”五层入口链和“免拼购买”,不能单独证明是同一商品。它最多作为新证据中的候选结构,必须由本轮 post-exit 证据重新验证,并与新证据里的同商品唯一正锚共同成立;不得把 Activity、旧 XML、前序项目、OCR、裸坐标、相似文本或其他购买文案当作同商品证明。
|
||||
6. 如果新的真实 XML 没有可唯一绑定同一目标商品的正锚,任务开始后保持 `DOING` 并记录等待新证据,不编造 selector、不把通用入口链升级为同商品判据,也不放宽为“任意 PDD 商品页”。
|
||||
7. 阶段 B 只收紧现有 Flow/runner:版本和前台包正确、节点树属于证据绑定的 PDD 受控页面、同一商品详情正判据精确唯一,并连续两次轮询命中后才算退出成功。两次之间发生漂移必须重新计数,超时仍失败。
|
||||
8. 空 hierarchy、SystemUI、锁屏、非 PDD overlay、PDD 其他页、面板仍在、正锚缺失/重复/属性漂移、版本或前台漂移、确认/提交/支付危险语义均不得成功,且不得发布 completed manifest。危险词只能作为保守硬拒绝;没有 T-106/T-107 真机证据时不得把合成反例宣称为真实确认页或支付页分类器。
|
||||
9. 所有正常、失败、超时和 finally 调和路径的 Back 总数都必须小于等于 1;Back 结果不明不重试。只有规格选择、闸门一读价、规格面板截图和严格安全退出全部成功,runner 才能原子发布结果与 `safe_exit=completed`。
|
||||
10. `SkuSelectionRunResult` 必须显式返回规格面板原始 `screenshot_path` 和 UTC-aware `captured_at`。`captured_at` 在截图 RPC 成功附近获取,并把同一个值写入 manifest,禁止在退出后重新猜时间。若本机另存 post-exit 截图,必须使用独立文件名和独立角色;它没有 `SKU_PANEL_GATE_1` 资格,不能成为 `result.screenshot_path`,也不能被 T-306 上传。
|
||||
11. 原始完整 post-exit XML 只保存在 `%LOCALAPPDATA%\cmbuyer\artifacts\T-104\...`,不得上传、提交 Git、写入 Vikunja 或普通日志;runner/CLI 不输出页面正文、设备 serial 或敏感路径。
|
||||
|
||||
## 验收要点
|
||||
|
||||
- 阶段 A 证据记录 screenshot/XML 路径与 SHA-256、PKG110、Android 16、Wi-Fi/USB、拼多多 8.17.0、goods_id 和 `back_attempts=1`;人工确认前没有 `safe_exit=completed`。
|
||||
- 用新的 `product_exit_8_17_0.xml` 正例替换旧“空 hierarchy 即成功”的 fake;空 hierarchy 必须失败。成功需连续两次正判据,单次命中后漂移必须重置。
|
||||
- 离线覆盖面板未退、SystemUI/锁屏、非 PDD overlay、PDD 其他页、正锚缺失/重复/漂移、版本/前台漂移和保守危险语义反例;所有分支 Back 调用总数小于等于 1,失败无 target、manifest 或 staging 残留。
|
||||
- runner 成功结果的 `screenshot_path` 精确指向规格面板原始 PNG,`captured_at` 是 UTC-aware 且与 manifest 同值;post-exit 图不能冒充 Gate1。
|
||||
- 静态 API、AST 和导入检查证明取证脚本零 intent/页面 click,T-104 不可达数量、确认页导航、授权、提交围栏、`submit_order_once()` 或任何支付能力。
|
||||
- 只有人能确认真实最终页面为同一商品详情、未进入确认/提交/支付页且未创建订单;agent 完成离线实现后仍保持 `DOING`,不得自行标 `DONE`。
|
||||
- client focused/full unittest、compileall、完整 init、上下文校验、Vikunja 导出检查与 diff-check 全部通过。
|
||||
|
||||
## 执行记录
|
||||
|
||||
### 2026-08-04T13:46:33Z · ila
|
||||
|
||||
2026-08-04 T-306 预研反向约束 T-104:成功 SkuSelectionRunResult 必须显式携带规格面板原始 screenshot_path 与实际 UTC captured_at;只有规格选择、闸门一读价、截图和严格安全退出全成功才返回。T-306 不枚举目录、不读 manifest 猜文件/时间,post-exit 商品详情截图不得冒充 gate1 证据。
|
||||
<!-- END VIKUNJA EXPORT -->
|
||||
|
||||
## 边界
|
||||
|
||||
- T-104 必须等待 T-103 完成人工真机验收后才可转 `DOING`。本任务先取证、后写退出成功判据;不得从
|
||||
当前实现、前序项目、旧 XML、Activity 名或推理直接声明商品详情页。
|
||||
- 阶段 A 取证脚本必须先在内存中核验拼多多 8.17.0、goods_id `937122477375` 的已知目标规格面板、
|
||||
目标颜色/尺码选中态和已取证当前价。唯一状态改变是一次命名 Android Back;不得发送 intent,不得调用
|
||||
页面 `click`,不得点击关闭坐标、空白、购买、数量、确认、提交或支付控件。Back 调用前即封存机会,
|
||||
超时、响应不明和 finally 调和均不得重试。
|
||||
- 阶段 A 在 Back 后只能只读采集 post-exit screenshot/XML/App/设备摘要并原子发布本机证据;manifest
|
||||
只能声明 `human_review_required` 与 `back_attempts=1`。人工确认前绝不能写 `safe_exit=completed`,
|
||||
agent 也不得把“面板消失”或“前台包仍是 PDD”自行解释为安全退出。
|
||||
- 人确认 post-exit 截图确为同一目标商品详情、面板已关闭、未进入确认/提交/支付或外部支付页、未创建
|
||||
订单,且截图/XML 对应后,才允许从本项目 XML 提取最小 `product_exit_8_17_0.xml` fixture。
|
||||
- 旧 `product_entry_8_17_0.xml` 只有通用“快要抢光”五层入口链和“免拼购买”,不能单独证明同一
|
||||
商品;它最多是新证据中的候选结构,必须与新证据的同商品唯一正锚共同成立。若真实 XML 没有唯一
|
||||
正锚,任务开始后保持 `DOING` 等待新证据,不臆造 selector、不放宽为任意 PDD 商品页。
|
||||
- “规格面板已消失”不是退出成功。阶段 B 成功必须由本项目 post-exit 证据证明:版本和前台包正确、
|
||||
节点树属于证据绑定的 PDD 受控页面、同一目标商品详情正判据精确唯一,并连续两次轮询命中;中间漂移
|
||||
必须重新计数。空 hierarchy、SystemUI/锁屏、非 PDD overlay、PDD 其他页、面板仍在、正锚缺失/
|
||||
重复/属性漂移、版本或前台漂移一律失败且不得发布 completed manifest。
|
||||
- 确认/提交/支付文字只能作为保守硬拒绝。T-106/T-107 真机取证前,不得把合成反例宣称为真实确认页、
|
||||
提交页或支付页分类器;未知页面只能 fail closed,不能加同义、包含、OCR、裸坐标或 Activity 兜底。
|
||||
- 只收紧现有 `SkuSelectionFlow` 与 runner 的退出后置条件,不新建第二套采购流程,不新增通用 `click`、
|
||||
数量、确认页导航、授权、提交围栏、创建待付款订单或支付能力。第一趟与任何真实提交函数继续静态隔离。
|
||||
- 原始完整 XML 只保存在 `%LOCALAPPDATA%\cmbuyer\artifacts\T-104\...`,不得上传、提交 Git、写入
|
||||
Vikunja 或日志。Git 中最小 fixture 只能保留退出判据所需结构,并由人确认不含地址、手机号或支付凭据。
|
||||
- 所有正常、失败、超时和 finally 调和路径的 Back 总数都必须小于等于 1;Back 结果不明不重试。
|
||||
只有规格选择、闸门一读价、规格面板截图和严格安全退出全部成功,runner 才能原子发布结果与
|
||||
`safe_exit=completed`;旧的空 hierarchy success 测试必须改成失败反例。
|
||||
- 供 T-306 消费的 `SkuSelectionRunResult` 必须显式携带规格面板原始 `screenshot_path` 与截图 RPC
|
||||
成功附近取得的 UTC-aware `captured_at`,并把同一个时间值写入 manifest。不得枚举目录或读取
|
||||
manifest 猜文件/时间;post-exit 商品详情截图必须使用独立文件名和角色,绝不能冒充 Gate1 证据。
|
||||
- `needs_device: true`:agent 不得自行标 `DONE`。只有人确认 post-exit 截图为同一目标商品详情、面板
|
||||
已关闭、未进入确认/提交/支付页、未创建订单,且截图/XML 对应后,任务才可完成。
|
||||
- 本任务不实现、不调用 `set_quantity_and_readback()`、`go_to_order_confirm()`、
|
||||
`create_submission_fence()`、`submit_order_once()`、支付、免密支付、先用后付或任何扣款能力;既有
|
||||
三道价格闸门、服务端围栏、只点一次且不重试的约束不得放宽。
|
||||
@@ -0,0 +1,82 @@
|
||||
---
|
||||
id: T-105
|
||||
title: 数量设置、读回与闸门二
|
||||
phase: 1
|
||||
deps: [T-104]
|
||||
status: TODO
|
||||
created: 2026-08-04
|
||||
vikunja_task_id: 40
|
||||
context_ref: 8c50e15
|
||||
work_branch: task/t-105-quantity-gate2
|
||||
needs_device: true
|
||||
needs_human_review: true
|
||||
write_paths:
|
||||
- docs/tasks/T-105.md
|
||||
- client/src/cmbuyer_client/pdd/quantity_gate2_spike.py
|
||||
- client/src/cmbuyer_client/pdd/quantity_gate2.py
|
||||
- client/src/cmbuyer_client/pdd/quantity_gate2_runner.py
|
||||
- client/tests/pdd/test_quantity_gate2.py
|
||||
- client/tests/pdd/fixtures/quantity_gate2_initial_8_17_0.xml
|
||||
- client/tests/pdd/fixtures/quantity_gate2_target_8_17_0.xml
|
||||
- client/scripts/capture_quantity_gate2_spike.py
|
||||
- client/scripts/run_t105_quantity_gate2.py
|
||||
- docs/current-state.md
|
||||
---
|
||||
|
||||
<!-- BEGIN VIKUNJA EXPORT id=40 synced=2026-08-04T13:59:13Z sha256=86e0079c5db57033818f75763d3e9012764e11f805a1ab12cafbe58a8ba9a526 -->
|
||||
## 问题 / 背景
|
||||
|
||||
T-104 将把规格选择、闸门一读价、原始截图和安全退出固化为受控能力。T-105 先对当前真机数量控件和读回位置取证,再实现规格面板内的数量设置、精确读回与闸门二;不进入确认页、不接触最终提交。
|
||||
|
||||
## 关联需求与交互
|
||||
|
||||
- 功能:F-010、F-015。
|
||||
- 用户故事:US-004、US-005。
|
||||
- 依赖:T-104;T-103 由其传递满足。
|
||||
- 后续消费者:T-106、T-305。
|
||||
- 本任务需要真机与人工证据确认。
|
||||
|
||||
## 方案
|
||||
|
||||
1. 阶段一只读取证:人分别准备初始数量与目标数量状态,脚本只采集 screenshot/XML/App/设备摘要;记录 PDD 版本、goods_id、规格、目标数量、路径和 hash。人工确认数量控件、读回位置和页面对应性前,不写点击判据。
|
||||
2. 阶段二从本项目证据提取最小 fixture,固化数量显示、增减控件、选中规格和价格节点的精确唯一结构;版本/包/面板/规格/控件任一失配都零点击。
|
||||
3. QuantityGate2Flow 接受 T-104 Gate1Observation、目标数量和最高总价。数量必须为服务端任务正整数,不接受浮点、字符串猜测或超上限值。
|
||||
4. 每次数量修改只点击证据证明的唯一控件一次,并等待明确后置条件;超时或结果不明不得重试该次点击。达到目标前每一步都重新定位和读回,不使用裸坐标或缓存节点。
|
||||
5. 闸门二重新读取颜色、尺码、数量和规格面板单价;颜色/尺码必须等于目标,数量精确等于请求,gate2 单价逐字规范化后等于 gate1 单价,并用十进制运算确认单价×数量不超最高总价。
|
||||
6. 成功结果是不可变 Gate2Observation,包含请求/实际规格、quantity_read、gate1/gate2_unit_price、max_total_price、规格面板原始 screenshot_path 与 UTC captured_at。
|
||||
7. 失败、价格不可读/多候选、规格漂移、数量不符或超价都安全停止并使用 T-104 一次性安全退出;Back 结果不明不重试。
|
||||
8. 能力与 T-103/T-104 分层:T-103 不能反向导入本任务;本任务不暴露确认页导航、通用 click、最终提交控件、围栏或付款能力。
|
||||
|
||||
## 验收要点
|
||||
|
||||
- 先证据后实现,执行记录包含两态截图/XML/hash、版本、设备、goods_id 与人工确认。
|
||||
- 离线覆盖数量边界、每步单击一次、后置条件、超时不重试、节点缺失/重复、SystemUI/锁屏/版本漂移。
|
||||
- 覆盖闸门二规格不变、价格严格相等、Decimal 总价、数量读回及所有失败分支零确认页动作。
|
||||
- 静态证明 T-103 不导入数量,本任务不可达确认页、围栏、submit_order_once 或付款。
|
||||
- 真机人工确认目标数量、规格、两次价格、截图、一次安全退出,且未进入确认/提交/支付页、未创建订单。
|
||||
- client 全量 unittest、compileall、完整 init、上下文校验、Vikunja 导出与 diff-check 通过;agent 保持 DOING 等人工验收。
|
||||
|
||||
## 执行记录
|
||||
|
||||
### 2026-08-04T13:58:34Z · ila
|
||||
|
||||
2026-08-04 预研定值:T-105 依赖 T-104 的一次安全退出,严格分“数量两态只读取证”和“人工确认后实现”两个门禁;闸门二输出显式 screenshot_path/captured_at,且不可达确认页、提交或付款。
|
||||
<!-- END VIKUNJA EXPORT -->
|
||||
|
||||
## 边界
|
||||
|
||||
- 必须先由人在拼多多 8.17.0 的目标规格面板分别准备初始数量与目标数量状态,只读采集并人工确认
|
||||
screenshot/XML/版本/goods_id/规格/数量的对应性,之后才允许从本项目证据编写数量判据和点击代码。
|
||||
不得从前序项目、旧 XML、控件常识、OCR、裸坐标或推理直接实现。
|
||||
- 数量控件、读回位置、规格选中态和两次价格都必须证据绑定且精确唯一。每次修改数量最多点击已批准
|
||||
控件一次并等待明确后置条件;超时或结果不明不得重试同一次点击,不得缓存节点跨状态继续操作。
|
||||
- 闸门二必须重新核对颜色/尺码未漂移、数量精确等于任务、gate2 单价严格等于 gate1 单价,并以
|
||||
十进制运算验证单价乘数量不超最高总价。价格只从规格面板读取,不用详情页或其他数字兜底。
|
||||
- 只有规格、数量、价格、总价上限和原始截图全部通过后才返回不可变 Gate2Observation;它必须显式
|
||||
携带 `screenshot_path` 与 UTC `captured_at`,不得让后续任务枚举目录或读取 manifest 猜证据。
|
||||
- 任一判据失败、规格漂移、数量不符、价格不可读/多候选/变化或超价都停止前进,只能调用 T-104
|
||||
已验证的一次性安全退出;Back 超时或结果不明不重试,不把“面板消失”当作退出成功。
|
||||
- T-103/T-104 不得反向导入本任务。本任务不进入确认页、不观察或获取最终提交控件,不实现通用 click、
|
||||
事件、证据上传、围栏或结果接口;不编写或引用点击“提交订单”的代码,不编写任何付款能力。
|
||||
- `needs_device: true`:agent 完成离线实现后仍保持 DOING。只有人确认真机数量、规格、两次价格、
|
||||
截图和安全退出,且未进入确认/提交/支付页、未创建订单,才可完成任务。
|
||||
@@ -0,0 +1,82 @@
|
||||
---
|
||||
id: T-106
|
||||
title: 确认页、闸门三与返回路径真机取证
|
||||
phase: 1
|
||||
deps: [T-105]
|
||||
status: TODO
|
||||
created: 2026-08-04
|
||||
vikunja_task_id: 41
|
||||
context_ref: 0144054
|
||||
work_branch: task/t-106-confirm-evidence
|
||||
needs_device: true
|
||||
needs_human_review: true
|
||||
write_paths:
|
||||
- docs/tasks/T-106.md
|
||||
- client/src/cmbuyer_client/pdd/order_confirm_spike.py
|
||||
- client/tests/pdd/test_order_confirm_spike.py
|
||||
- client/scripts/capture_order_confirm_spike.py
|
||||
- docs/current-state.md
|
||||
---
|
||||
|
||||
<!-- BEGIN VIKUNJA EXPORT id=41 synced=2026-08-04T14:02:41Z sha256=1b7fda4d3b4951f20504d45b88676fda9271f1aaeaacb67b0674e4a6357ca3cc -->
|
||||
## 问题 / 背景
|
||||
|
||||
T-105 验证规格面板数量与闸门二后,下一步会进入更高风险的订单确认页。T-106 只由人手工准备确认页、最终提交控件可见态和一次返回后的页面,再用完全只读脚本采集真机事实;本任务不自动导航、不写确认页判据。
|
||||
|
||||
## 关联需求与交互
|
||||
|
||||
- 功能:F-011、F-016。
|
||||
- 用户故事:US-004、US-005。
|
||||
- 依赖:T-105。
|
||||
- 后续消费者:T-107。
|
||||
- 需要真机和人工页面对应性、安全结果确认。
|
||||
|
||||
## 方案
|
||||
|
||||
1. 提供只读取证 CLI,唯一能力为核对指定设备/App 版本、app_current、screenshot 与 compressed=false XML、hash 和原子 manifest;静态禁止 click、press/back、swipe、input、intent 和任意 UI 修改。
|
||||
2. 人先手工完成已取证规格/数量并进入确认页,停在规格、数量、应付总额及最终“提交订单”控件可见的安全状态;绝不能点击最终提交控件。
|
||||
3. 只读采集确认页主体与最终控件状态,记录准确规格、数量、应付总额、控件精确文案/匹配数/启用态;这些只是人工观察,不转成生产 selector。
|
||||
4. 随后由人只按一次返回并停手,再用同一只读取证 CLI 采集返回后的页面。Back 结果不明不再次按;记录页面身份与是否回到证据允许的安全页面。
|
||||
5. 三组证据都记录设备型号、Android、USB/WiFi、serial、PDD 版本、goods_id、截图/XML 路径和 SHA-256;完整 XML 仅留本机。
|
||||
6. 如果人误触最终提交、产生待付款订单、进入支付/安全验证/外部页面或页面结果不明,立即停止、不重试、不继续取证,并如实记录路径为不可用;绝不操作付款。
|
||||
7. 当前规格面板中出现“提交订单 ¥...”不能仅凭文案推断为确认页导航或最终控件;T-107 只能消费本任务由人确认的结构事实。
|
||||
|
||||
## 验收要点
|
||||
|
||||
- 静态证明 spike/CLI 没有任何 UI 修改 API;mock 覆盖设备/版本/包/截图/XML/超时/原子发布/异常脱敏。
|
||||
- 人工确认三组截图/XML对应:确认页规格数量金额、最终控件可见态、一次返回后页面。
|
||||
- 执行记录列出准确控件文案与匹配数,但不把坐标/selector 作为结论;完整 XML、地址、手机号、支付信息不进 Git/Vikunja/日志。
|
||||
- 明确记录未点击最终提交、未创建订单、未进入支付;若相反则任务保持 DOING/受阻且不自动处置。
|
||||
- client 全量 unittest、compileall、完整 init、上下文校验、Vikunja 导出与 diff-check 通过;agent 不得自行标 DONE。
|
||||
|
||||
## 执行记录
|
||||
|
||||
### 2026-08-04T14:01:13Z · ila
|
||||
|
||||
2026-08-04 预研定值:T-106 与 T-107 分开,严格只做人工准备页面后的只读取证;采集代码零 UI 修改,最终提交控件不点击,一次返回也由人执行且结果不明不重试。
|
||||
|
||||
### 2026-08-04T14:02:39Z · ila
|
||||
|
||||
2026-08-04 主审补齐导航来源事实:T-106 必须采集四态——闸门二后的确认页导航前状态、确认页闸门三、最终提交控件可见态、人工一次返回后状态;人记录进入确认页时实际单击的精确控件/容器与匹配数,agent 仍只读取证。缺少来源态时 T-107 不得从目标页反推入口。
|
||||
<!-- END VIKUNJA EXPORT -->
|
||||
|
||||
## 边界
|
||||
|
||||
- 本任务只实现和运行只读取证能力:设备/App 摘要、`app_current`、screenshot、`compressed=false` XML、
|
||||
hash 与原子 manifest。代码中不得出现 click、press/back、swipe、input、intent 或其他 UI 修改 API,
|
||||
不得自动进入或退出确认页,也不得从前序项目、旧 XML 或推理写生产判据。
|
||||
- 确认页、最终提交控件可见态和一次返回后的页面都由人手工准备;人不得点击最终“提交订单”控件。
|
||||
一次返回结果不明时不得再次按 Back。agent 只采集已准备状态,不远程替人执行这些动作。
|
||||
- 人工验收必须明确规格、数量、应付总额、最终控件精确文案/匹配数/启用态和返回后的页面身份;
|
||||
本任务只记录真机事实,不把文案、坐标、节点或 selector 批准为生产能力。
|
||||
- 取证必须包含四个稳定状态:闸门二通过后的**确认页导航前**页面、确认页闸门三、最终提交控件
|
||||
可见态、一次返回后页面。人还需记录从第一态进入第二态时实际只点击的精确控件文本/容器及当时
|
||||
匹配数量;agent 不执行该点击。缺少来源态时,T-107 不得仅凭“已到确认页”反推导航控件。
|
||||
- 规格面板中出现的“提交订单 ¥...”不得凭文案推断为确认页导航或最终提交控件。任何确认页入口、
|
||||
金额节点、最终控件和返回后置条件都必须等 T-107 从本任务已确认的证据中最小化固化。
|
||||
- 若人工误触最终提交、产生待付款订单、进入支付/安全验证/外部页面或结果不明,立即停止、不重试、
|
||||
不继续取证并如实记录;绝不点击支付、免密支付、先用后付或任何扣款控件。
|
||||
- 原始 screenshot/XML 只留 `%LOCALAPPDATA%\cmbuyer\artifacts\T-106\...`,不提交 Git、不上传采购服务,
|
||||
不把完整 XML、地址、手机号、支付信息、页面全文或本机路径写入普通日志/Vikunja/fixture。
|
||||
- `needs_device: true`:agent 不得自行标 DONE。只有人确认四组证据对应、一次返回安全、未点击最终提交、
|
||||
未创建订单且未进入支付相关页面,任务才可完成。
|
||||
@@ -0,0 +1,87 @@
|
||||
---
|
||||
id: T-107
|
||||
title: 固化确认页、闸门三与提交控件只读判据
|
||||
phase: 1
|
||||
deps: [T-106]
|
||||
status: TODO
|
||||
created: 2026-08-04
|
||||
vikunja_task_id: 42
|
||||
context_ref: 83b244f
|
||||
work_branch: task/t-107-confirm-gate3
|
||||
needs_device: true
|
||||
needs_human_review: true
|
||||
write_paths:
|
||||
- docs/tasks/T-107.md
|
||||
- client/src/cmbuyer_client/pdd/order_confirm.py
|
||||
- client/src/cmbuyer_client/pdd/order_confirm_runner.py
|
||||
- client/tests/pdd/test_order_confirm.py
|
||||
- client/tests/pdd/fixtures/order_confirm_source_8_17_0.xml
|
||||
- client/tests/pdd/fixtures/order_confirm_gate3_8_17_0.xml
|
||||
- client/tests/pdd/fixtures/order_submit_control_8_17_0.xml
|
||||
- client/tests/pdd/fixtures/order_confirm_exit_8_17_0.xml
|
||||
- client/scripts/run_t107_order_confirm_dry_run.py
|
||||
- docs/api.md
|
||||
- docs/current-state.md
|
||||
---
|
||||
|
||||
<!-- BEGIN VIKUNJA EXPORT id=42 synced=2026-08-04T14:05:17Z sha256=56bb3d2575e60ac2ede9ba9c86178f5eefc4470d06b973d9f701fbc8c91635da -->
|
||||
## 问题 / 背景
|
||||
|
||||
T-106 将形成确认页导航前、闸门三、最终提交控件可见态和一次返回后的四组真机证据。T-107 只从这些已由人确认的本项目事实固化一次性确认页导航、闸门三读取、最终控件纯观察与一次安全返回;绝不点击最终提交。
|
||||
|
||||
## 关联需求与交互
|
||||
|
||||
- 功能:F-011、F-016、F-017 的围栏前只读部分。
|
||||
- 用户故事:US-004、US-005。
|
||||
- 依赖:T-106。
|
||||
- 后续消费者:T-210、T-305、T-208。
|
||||
- 需要真机与人工安全验收。
|
||||
|
||||
## 方案
|
||||
|
||||
1. 从 T-106 四态证据提取只含必要节点且去隐私的最小 fixture;页面版本、前台包、来源页面、导航控件或目标页面任一漂移都零点击。
|
||||
2. go_to_order_confirm 只允许点击 T-106 人工证明的导航前状态中精确唯一的已批准控件一次;点击后等待确认页精确后置条件。超时或结果不明不重试,不使用相似文案、OCR、裸坐标或其他购买按钮兜底。
|
||||
3. observe_gate3 只从确认页读取颜色、尺码、数量和应付总额;必须等于目标规格/数量且金额为规范十进制、不超最高总价。不得从详情页、规格面板或其他数字补值。
|
||||
4. 最终“提交订单”控件由纯 XML observer 只读:记录精确文本、匹配数、启用态和是否存在唯一可点击祖先,但不返回 selector、坐标、节点、祖先句柄或任何可点击对象;observer 不持有 device。
|
||||
5. Gate3Observation 包含规格、数量、确认页金额、最高总价、submit_control_text/match_count/enabled、确认页原始 screenshot_path 和 UTC captured_at。
|
||||
6. 观察完成后 exit_order_confirm_safely 只发送一次 Android Back,并稳定验证 T-106 post-back 正判据;超时/结果不明不重试,SystemUI/锁屏/其他 PDD 页/外部页都不是成功。
|
||||
7. runner 是永久围栏前 dry-run capability,不接受 allow_submit/dry_run=False 开关;composition/import 静态不可达 fence、SubmissionPermit、submit_order_once、payment。
|
||||
8. T-107 不上传证据、不定义 T-205 事件码、不申请围栏、不创建待付款订单。任何失败只停止并等待人工,不自动回 DRAFT/释放/转领。
|
||||
|
||||
## 验收要点
|
||||
|
||||
- fixture 正反例覆盖来源页、唯一导航控件、确认页、规格/数量/金额、最终控件 0/1/2 个、一次返回后置条件与版本漂移。
|
||||
- 动作轨迹证明成功路径仅一个已批准导航点击和一次 Back;导航结果不明与 Back 结果不明均不重试。
|
||||
- Gate3 金额用 Decimal,规格/数量/上限严格;最终控件观察 DTO 不含 selector/坐标/节点/可点击对象。
|
||||
- AST/import closure 证明没有 fence/result/SubmissionPermit/click_permitted/submit_order_once/payment,最终控件 observer 无 device/click。
|
||||
- 真机人工确认闸门三、submit match count=1、一次安全返回、未点击最终提交、未创建订单/进入支付。
|
||||
- client 全量 unittest、compileall、完整 init、上下文校验、Vikunja 导出与 diff-check 通过;agent 保持 DOING 等人工验收。
|
||||
|
||||
## 执行记录
|
||||
|
||||
### 2026-08-04T14:04:47Z · ila
|
||||
|
||||
2026-08-04 预研定值:T-107 只消费 T-106 四态证据;确认页导航最多一次,最终提交控件由无 device 的纯 observer 读取且 DTO 不暴露节点/坐标/selector,随后最多一次 Back 并验证返回后置条件。全路径静态不可达提交与付款。
|
||||
<!-- END VIKUNJA EXPORT -->
|
||||
|
||||
## 边界
|
||||
|
||||
- 任何确认页来源判据、导航控件、目标页结构、闸门三节点、最终提交控件和返回后置条件都只能来自
|
||||
T-106 已由人确认的四态真机证据,并与拼多多 8.17.0/goods_id 绑定。不得从前序项目、旧 XML、
|
||||
Activity 名、相似文案、OCR、裸坐标或推理补判据。
|
||||
- `go_to_order_confirm()` 只允许在精确来源页点击 T-106 已证明的唯一导航控件一次;超时或结果不明
|
||||
不重试,不尝试其他购买按钮,不把规格面板中任意“提交订单 ¥...”文本直接当成已批准入口。
|
||||
- 闸门三只从确认页读取颜色、尺码、数量和应付总额;必须严格等于目标规格/数量且金额为规范十进制、
|
||||
不超最高总价。不得用详情页、规格面板或其他位置数字凑齐,不得使用浮点数。
|
||||
- 最终“提交订单”控件只能由不持有 device/click 能力的纯 XML observer 只读。结果可含精确文本、
|
||||
匹配数、启用态和“唯一可点击祖先是否存在”的布尔值,但不得返回 selector、坐标、节点、祖先句柄
|
||||
或任何可点击对象;runner 不得对该控件或祖先调用 click。
|
||||
- 观察完成后只允许一次 Android Back,并必须命中 T-106 固化的稳定 post-back 正判据;超时或结果
|
||||
不明不重试。SystemUI/锁屏、其他 PDD 页面、外部支付/安全验证页或仅“确认页消失”都不是退出成功。
|
||||
- Runner 是永久围栏前 dry-run capability,不接受 `allow_submit`、`dry_run=False` 等开关;构造和
|
||||
import 闭包不得引用 submission fence、SubmissionPermit、`click_permitted`、`submit_order_once()`、
|
||||
结果调和、支付、免密支付、先用后付或任何扣款能力。
|
||||
- T-107 不上传证据、不定义 T-205 事件、不申请围栏、不创建待付款订单。任何失败只停止并等待人工,
|
||||
不自动释放 claim/授权、不回 DRAFT、不转领或重试页面动作。
|
||||
- `needs_device: true`:agent 不得自行标 DONE。只有人确认闸门三、最终控件 match count=1、一次安全
|
||||
返回,且未点击最终提交、未创建订单、未进入支付相关页面,任务才可完成。
|
||||
+10
-2
@@ -3,7 +3,7 @@ id: T-203
|
||||
title: 表格查询与批量开始采购授权
|
||||
phase: 2
|
||||
deps: [T-202, T-209]
|
||||
status: DOING
|
||||
status: DONE
|
||||
created: 2026-08-04
|
||||
vikunja_task_id: 30
|
||||
context_ref: 1f20271
|
||||
@@ -21,7 +21,7 @@ write_paths:
|
||||
- admin/README.md
|
||||
---
|
||||
|
||||
<!-- BEGIN VIKUNJA EXPORT id=30 synced=2026-08-04T09:16:48Z sha256=6c62ba368a760e96b8feb06f3f3ced8a2a40d10f7754a99471f7429c3862a80c -->
|
||||
<!-- BEGIN VIKUNJA EXPORT id=30 synced=2026-08-04T10:27:15Z sha256=540fb01c9da32148898851f6450b713103f1db40222e1ac0d6dbcc74b007bcb3 -->
|
||||
## 问题 / 背景
|
||||
|
||||
T-202 已完成手工 DRAFT 建单;T-209 将生产 schema/领域状态机迁移到单趟模型。项目所有者明确:管理员点击“开始采购(只创建待付款订单)”本身就是授权,不再增加试选后确认。T-203 负责采购服务查询和批量授权事务,使设备后续只能领取显式授权的 PENDING 任务。
|
||||
@@ -54,6 +54,14 @@ F-004、F-008、F-018;US-003、US-005;IX-005;GET /tasks、POST /tasks/star
|
||||
### 2026-08-04T09:16:39Z · ila
|
||||
|
||||
2026-08-04 开始 T-203:依赖 T-209 已完成并合入 main。主 agent 已完成开工前只读审计,冻结 v2 schema 启动校验、单进程 writeGate、start_key 规范集合重放、Asia/Shanghai 到 UTC 半开区间、julianday 查询及 big.Int 分金额边界;本地状态转 DOING,分支 task/t-203-start-purchases。
|
||||
|
||||
### 2026-08-04T10:18:10Z · ila
|
||||
|
||||
2026-08-04 T-203 独立终审退回两项:未认证须按 docs/api.md 返回 401,已认证但 CSRF 错误才返回 403;原始 JSON 在 64KiB 边界内必须先做严格 UTF-8 校验。现有 auth.Manager 无法只读区分“有效管理会话 + 错 CSRF”与“无会话”,因此任务所有者批准把 write_paths 最小扩展为 admin/internal/auth/**,仅允许增加只读认证状态 API 及测试;不得创建/旋转会话或放宽 CSRF。修复、复审和完整门禁通过前 T-203 保持 DOING。
|
||||
|
||||
### 2026-08-04T10:26:57Z · ila
|
||||
|
||||
2026-08-04 T-203 完成:提交 5dcff4b,合入主分支 03a067e。实现传统任务表格、筛选、DRAFT 批量勾选和“开始采购(只创建待付款订单)”授权;1/100 条在单一事务内生成 ACTIVE 快照并原子转 PENDING,相同 start_key 原集合稳定重放。独立终审两轮后修复未认证 401/CSRF 403 与原始 UTF-8 严格校验。主 agent 合入后完整 init(client 112 tests)、Go 全量/竞态/vet/build、Node 7 tests、上下文和 diff-check 全部通过;未实现领取、真机、提交订单或付款。
|
||||
<!-- END VIKUNJA EXPORT -->
|
||||
|
||||
## 边界
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
---
|
||||
id: T-204
|
||||
title: 路由化任务详情与内部截图资产
|
||||
phase: 2
|
||||
deps: [T-203]
|
||||
status: DONE
|
||||
created: 2026-08-04
|
||||
vikunja_task_id: 31
|
||||
context_ref: 7928457
|
||||
work_branch: task/t-204-details-evidence
|
||||
needs_device: false
|
||||
needs_human_review: false
|
||||
write_paths:
|
||||
- docs/tasks/T-204.md
|
||||
- admin/migrations/00003_evidence_assets.sql
|
||||
- admin/internal/migrations/migrations_test.go
|
||||
- admin/internal/evidence/**
|
||||
- admin/internal/storage/evidence/**
|
||||
- admin/internal/taskdetail/**
|
||||
- admin/internal/config/**
|
||||
- admin/internal/server/**
|
||||
- admin/internal/transport/webui/**
|
||||
- admin/cmd/server/**
|
||||
- admin/README.md
|
||||
- docs/api.md
|
||||
- docs/routes.md
|
||||
- docs/04-architecture.md
|
||||
---
|
||||
|
||||
<!-- BEGIN VIKUNJA EXPORT id=31 synced=2026-08-04T11:49:57Z sha256=e09d813fb2ef353a1e2b10b7b2aefd0acb3f605ac5a0a4ddac136ca9dcf2cecc -->
|
||||
## 问题 / 背景
|
||||
|
||||
T-203 已完成传统任务表格与批量“开始采购”授权。采购管理员还缺少可复制、可返回的任务详情,以及只供内部审计的规格面板/确认页原始截图。T-204 提供详情与截图资产底座;不实现 T-205 的 attempt/event 写入,不实现 T-306 的客户端上传调用。
|
||||
|
||||
## 关联需求与交互
|
||||
|
||||
- 功能:F-007、F-011。
|
||||
- 用户故事:US-002、US-004、US-007。
|
||||
- 交互:IX-003、IX-004;双击非控件区域或 Enter 打开同一 `/tasks/{id}`,直达显示完整页,列表增强为抽屉。
|
||||
- 架构/API:`GET /tasks/{id}`、`POST /api/v1/tasks/{id}/evidence`、`GET /evidence/{asset_id}`。
|
||||
|
||||
## 方案
|
||||
|
||||
1. 新增任务详情只读查询,返回任务要求、状态、版本、授权摘要和当前已有的 attempt/submission/evidence 摘要;缺少后续事实时显示明确空态,不伪造数据。
|
||||
2. `GET /tasks/{id}` 以同一数据模型渲染完整页或同源 HTML fragment。列表行双击非交互区域、键盘 Enter 打开抽屉并把 URL 推到同一路径;Esc、关闭或浏览器返回恢复筛选、滚动与触发行焦点。外部商品链接、复选框和批量按钮不得触发行详情。
|
||||
3. 详情是审计视图,不提供试选后确认、再次提交、付款自动化或围栏后重试。状态文案始终区分“订单已创建,系统尚未付款”。
|
||||
4. 新建 `evidence_assets` 迁移和存储边界:只接收明确的单个 PNG screenshot;attempt_id 必填且必须属于 URL 中的 task。元数据仅允许 upload_key、attempt_id、kind、privacy_tier=INTERNAL_RAW、sha256、captured_at;记录 MIME、字节数、宽高、存储键和上传主体。
|
||||
5. 上传先经注入的 DeviceAuthenticator;T-301 前生产默认拒绝,测试可用 fake principal 验证契约,不创建临时 token 或共享密钥。T-301 完成后再接真实 Bearer 身份。
|
||||
6. multipart 设严格总大小、字段/单文件限制;拒绝未知字段、重复字段、XML/manifest/目录/本机路径、非 PNG、魔数/DecodeConfig/尺寸/hash 不一致以及未批准的截图 kind。服务端文件名只由资产 id/存储键生成,不使用上传名。
|
||||
7. 文件同目录临时写、流式 SHA-256、校验、fsync、原子 rename 后才写 DB;故障只允许产生不可达孤儿,不允许 DB 指向缺失或半文件。upload_key 同任务同载荷幂等,载荷变化冲突,并发只生成一条可读资产。
|
||||
8. `GET /evidence/{asset_id}` 仅有效管理员会话可读,返回 `image/png`、`Cache-Control: no-store`、`X-Content-Type-Options: nosniff`;未登录不泄露资产是否存在,不经静态目录暴露。
|
||||
9. UI 采用现有采购服务高密度表格风格:可见关闭按钮、焦点圈、44px 交互目标、截图 width/height 预留和 lazy loading、响应式布局、reduced-motion;不得只有双击一种入口。
|
||||
10. T-204 明确定值单文件最大 10 MiB、解码后最大 16,777,216 像素;证据目录由显式配置提供。保留期限仍由后续部署任务定值,不在本任务自动删除资产。
|
||||
|
||||
## 验收要点
|
||||
|
||||
- 详情完整页与抽屉使用同一路由/数据;刷新/复制 URL、浏览器返回、Esc、焦点和滚动/筛选恢复均有测试。
|
||||
- 不存在、畸形 id、未登录、fragment 伪造请求均 fail closed,不泄露内部错误。
|
||||
- 上传覆盖认证矩阵、multipart 负例、PNG 魔数/解码/大小/尺寸/hash、未知/重复字段、任务/attempt 归属、路径穿越、幂等冲突/并发及原子故障注入。
|
||||
- 截图读取覆盖管理员会话、未登录、缺失资产和 no-store/nosniff;完整 XML、支付页和凭据不会进入接口或 Git。
|
||||
- 运行 Go 全量/竞态/vet/build、Node 测试与语法检查、完整 init、上下文校验和 diff-check。
|
||||
|
||||
## 执行记录
|
||||
|
||||
待认领。
|
||||
|
||||
## 执行记录
|
||||
|
||||
### 2026-08-04T10:42:01Z · ila
|
||||
|
||||
2026-08-04 开始 T-204:依赖 T-203 已完成,任务定义提交 7928457。采用生产默认拒绝的 DeviceAuthenticator 接口,T-301 前不发明临时设备凭据;先实现同 URL 详情抽屉/完整页、INTERNAL_RAW PNG 原子存储与管理员 no-store 读取。工作分支 task/t-204-details-evidence。
|
||||
|
||||
### 2026-08-04T11:49:31Z · ila
|
||||
|
||||
2026-08-04 T-204 完成:实现同一路由 `/tasks/{id}` 的完整页/详情抽屉、内部 `INTERNAL_RAW` PNG 证据资产、设备认证前置与管理员 no-store 读取。生产上传在 T-301 前固定拒绝。证据文件采用分片目录内临时文件、hash/PNG 复核、file fsync、同目录原子 rename、root/shard 目录持久化后才写 SQLite;故障注入证明 file sync、rename、目录 sync、INSERT、COMMIT 失败均无可见 DB 行,最多留下不可达孤儿。UI 覆盖 44px、可见详情按钮、双击/Enter、Esc、返回/前进、筛选滚动与精确触发焦点恢复;fragment 严格 same-origin/Accept/Vary。
|
||||
|
||||
实现提交 `8964888`,主线合并 `46fccc2`。实现 agent 完整 init 通过;主 agent 独立复跑 `go test ./...`、`go test -race ./...`、`go vet ./...`、`go build ./...`、Node 13 项测试、语法检查、上下文校验、diff-check 和主线完整 `init.ps1`(client 112 tests)全部通过。独立审计最终 PASS,无剩余 P0/P1。系统仍只创建待付款订单,绝不自动付款。
|
||||
<!-- END VIKUNJA EXPORT -->
|
||||
|
||||
## 边界
|
||||
|
||||
- 本任务只实现管理员任务详情、内部 PNG 证据资产底座和受保护读取;不实现 T-205 的 attempt/event
|
||||
写接口、闸门计算、失败分类、提交围栏或结果调和,不实现 T-306 的客户端截图与上传调用。
|
||||
- T-301 完成前不得发明临时设备 token、共享密钥或把管理员会话当设备身份。上传 handler 必须先调用窄
|
||||
`DeviceAuthenticator`;生产默认实现一律拒绝,只有测试可以注入 fake principal。
|
||||
- 上传只接受一个调用方显式选择的 PNG;不得枚举目录,不接收 XML、manifest、本机路径、原文件名、
|
||||
任意备注、Cookie、token、支付凭据或未批准的截图 kind。MVP 本任务只开放
|
||||
`SKU_PANEL_GATE_1`,后续 kind 必须在对应任务中收紧扩展。
|
||||
- `attempt_id` 必填且必须以复合外键证明属于 URL 中的 task。`privacy_tier` 只能是
|
||||
`INTERNAL_RAW`;截图可含页面已显示的地址/手机号,但服务端不得 OCR、提取、索引、搜索或写日志。
|
||||
- 单文件最多 10 MiB,单边最多 8192 px,解码后最多 16,777,216 像素;必须同时校验 multipart 类型、
|
||||
PNG 魔数、解码结果、字节数、尺寸与调用方声明的 64 位小写 SHA-256,任一不符零发布。
|
||||
- 文件必须在显式配置的证据根目录内以服务端生成的内容地址落盘,不进入公开静态目录。先同目录临时写、
|
||||
流式 hash、`fsync`、原子 rename,再写数据库;故障最多留下不可达孤儿,不得留下指向半文件或缺失
|
||||
文件的可见数据库记录,也不得为清理孤儿而删除并发复用文件。
|
||||
- 同一设备主体与 `upload_key` 的相同规范请求只返回原资产;内容、任务、attempt 或元数据变化一律
|
||||
`409`。不得把 SHA-256 当业务记录唯一键,因为不同合法证据可以复用相同物理内容。
|
||||
- `GET /evidence/{asset_id}` 只允许有效管理员会话,必须返回 `Cache-Control: no-store` 和
|
||||
`X-Content-Type-Options: nosniff`;匿名请求先拒绝,不泄露资产是否存在,不提供公开 URL、目录浏览、
|
||||
批量导出、删除或预签名链接。
|
||||
- `/tasks/{id}` 的抽屉与完整页必须共享同一数据和详情模板。双击不是唯一入口;Enter、可见关闭按钮、
|
||||
Esc、浏览器前进/后退、焦点与列表滚动/筛选恢复均可用,外部商品链接、复选框、输入框和按钮不得误触
|
||||
行详情。截图必须预留尺寸、响应式缩放、延迟加载,alt 不转录地址或手机号。
|
||||
- 详情是只读审计视图,不制造不存在的 attempt/event/闸门/提交事实,不出现“机器选对了吗”、围栏后
|
||||
重试、再次提交或自动付款动作。截图不作为价格闸门通过或人工审批的唯一依据。
|
||||
- 本任务不实现、不调用通用真机点击、`submit_order_once()`、支付、免密支付、先用后付或任何扣款能力;
|
||||
既有三道价格闸门、服务端提交围栏、唯一点击一次且不重试的规则不得放宽。
|
||||
@@ -0,0 +1,98 @@
|
||||
---
|
||||
id: T-205
|
||||
title: purchase attempt、事件与执行摘要 API
|
||||
phase: 2
|
||||
deps: [T-210]
|
||||
status: TODO
|
||||
created: 2026-08-04
|
||||
vikunja_task_id: 35
|
||||
context_ref: cd47d0c
|
||||
work_branch: task/t-205-attempt-events
|
||||
needs_device: false
|
||||
needs_human_review: false
|
||||
write_paths:
|
||||
- docs/tasks/T-205.md
|
||||
- admin/migrations/00007_attempt_events.sql
|
||||
- admin/internal/migrations/migrations_test.go
|
||||
- admin/internal/attempts/**
|
||||
- admin/internal/taskclaim/**
|
||||
- admin/internal/server/**
|
||||
- admin/cmd/server/**
|
||||
- admin/README.md
|
||||
- docs/api.md
|
||||
- docs/04-architecture.md
|
||||
---
|
||||
|
||||
<!-- BEGIN VIKUNJA EXPORT id=35 synced=2026-08-04T14:41:47Z sha256=870653415736bd7756b058467f0d6ea0114a78dba1c97ed62115067e07c32e8a -->
|
||||
## 问题 / 背景
|
||||
|
||||
T-302 负责创建并租赁唯一 purchase attempt,T-103 提供已取证的规格选择与闸门一能力。后续单趟执行需要把每个安全步骤以最小结构化事件追加到采购服务,并在围栏前失败时形成不可覆盖的执行摘要。T-205 只记录既有 claim/attempt 的事实和安全失败;不创建 attempt、不释放授权、不建立提交围栏、不点击或付款。
|
||||
|
||||
## 关联需求与交互
|
||||
|
||||
- 功能:F-006、F-007。
|
||||
- 用户故事:US-003、US-004、US-005。
|
||||
- 依赖:T-210;T-107/T-204/T-302/T-301 由 T-210 传递满足。
|
||||
- 后续消费者:T-206 状态视图、T-307 客户端 attempt sink、T-305 围栏前 dry-run、T-208 提交围栏。
|
||||
- API:`POST /api/v1/tasks/{id}/events`、`POST /api/v1/purchase-attempts/{aid}/fail`。
|
||||
|
||||
## 方案
|
||||
|
||||
1. 新增 `00007_attempt_events.sql`,保存 append-only attempt events、批次幂等请求和必要执行摘要;事件以 attempt 内单调序号、事件 id/批次 id、固定 step/outcome/reason、服务端接收时间及允许的最小字段组成。数据库约束禁止更新/覆盖旧序号、跨 task/attempt 或围栏后继续写。
|
||||
2. 复用 T-302 的统一 claim validator:每个请求都绑定当前设备 Bearer、session、task、attempt、generation、claim token 和未过期租约;认证在 body 前,业务事务在线性化位置再次验证设备 ACTIVE、claim/authorization/attempt 归属与状态。旧 generation/token、跨设备/会话、租约或授权过期均不能写事件。
|
||||
3. 事件 API 只接受有界批次和严格 JSON。每个事件的 step/outcome/reason 来自版本化白名单,不接收自由页面文本、XML、地址、手机号、路径、token、选择器、坐标或动作脚本。金额为规范正十进制字符串,数量为正整数;未知字段、额外 JSON、序号缺口/倒退/重复异载荷全部拒绝。
|
||||
4. 同一 batch id 同载荷在响应丢失/服务重启后重放原结果;同键异载荷冲突。一个事务内校验整批连续性、步骤状态机和 summary 兼容性后全批追加,任一事件非法则零写入。
|
||||
5. 三条成功步骤固定为:`GATE_1_PASSED` 只携带 color、size、unit_price 和 `SKU_PANEL_GATE_1` asset id;`GATE_2_PASSED` 只携带 color、size、quantity、unit_price 和 `SKU_PANEL_GATE_2` asset id;`GATE_3_PASSED` 只携带 color、size、quantity、confirm_amount、submit_control_text、submit_control_match_count、submit_control_enabled 和 `ORDER_CONFIRM_GATE_3` asset id。每个 asset 必须属于同一 task/attempt/device/generation 且 kind 精确匹配;缺失、错 kind、跨归属或证据未明确成功时整批零写入。
|
||||
6. 第一个明确的执行开始事件只允许把 attempt `CLAIMED→ORDERING`、task `CLAIMED→ORDERING`;不得隐式续租。后续事件只在 ORDERING 中追加。规格、两次规格面板价格、数量读回、确认页金额仅由对应固定成功 step 写入 summary,不能由客户端直接覆盖已有摘要。
|
||||
7. 围栏前 fail API 只接受固定 reason:`DEVICE_UNAVAILABLE`、`APP_VERSION_MISMATCH`、`PRODUCT_OPEN_FAILED`、`SKU_PANEL_UNAVAILABLE`、`SKU_SELECTION_MISMATCH`、`GATE_1_PRICE_INVALID`、`QUANTITY_MISMATCH`、`GATE_2_PRICE_INVALID`、`ORDER_CONFIRM_UNAVAILABLE`、`GATE_3_MISMATCH`、`SUBMIT_CONTROL_NOT_UNIQUE`、`CLAIM_NOT_CURRENT`、`AUTHORIZATION_EXPIRED`、`EVIDENCE_UPLOAD_UNRESOLVED`、`PAGE_RESULT_UNKNOWN`、`STEP_TIMEOUT`、`DRY_RUN_COMPLETED_BEFORE_FENCE`,以及已记录的最后安全步骤。幂等地把当前 attempt 置 FAILED、task 置 NEEDS_MANUAL,并写终止事件/finished_at。authorization 和 claim 保持占用,等待 T-207 人工检查后显式关闭;失败请求不自动回 DRAFT、不释放、不转领。`EXTERNAL_PAYMENT_HANDOFF`、`SECURITY_CHALLENGE` 与 `UNKNOWN` 是围栏后 result observation,不得作为围栏前失败码。
|
||||
8. 已失败、已围栏、存在 order_submission、claim/授权/租约不再有效时拒绝新事件或新的失败结果。围栏相关状态只能由 T-208 创建,T-205 不读取或返回 `click_permitted`。
|
||||
9. 原始截图继续走 T-204/T-306;事件只引用服务端已有的受控 evidence asset id(如需要),不得上传文件或枚举本地证据目录。完整 XML 永不上传。
|
||||
|
||||
## 验收要点
|
||||
|
||||
- Migration 覆盖升级/重开、外键/唯一/append-only 约束、既有事实与安全降级保护。
|
||||
- 覆盖严格 HTTP/JSON、body 前认证、claim/session/generation/token/lease/authorization 校验和跨设备拒绝。
|
||||
- 覆盖批次原子性、序号连续、并发追加、相同 key 重放、异载荷冲突、服务重启恢复。
|
||||
- 覆盖步骤状态机及 summary 单向写入;闸门金额保持十进制字符串,冲突值不得覆盖。
|
||||
- 覆盖 `CLAIMED→ORDERING` 唯一转换、固定失败到 NEEDS_MANUAL、重复失败幂等、失败后无事件、授权/claim 不释放。
|
||||
- 静态检查确认没有 selector/coordinate/free script、claim 创建/续租、围栏、提交订单点击、付款或自动重试能力。
|
||||
- `go test ./...`、`go test -race ./...`、`go vet ./...`、`go build ./...`、完整 init、上下文校验与 diff-check 全部通过。
|
||||
|
||||
## 执行记录
|
||||
|
||||
### 2026-08-04T13:44:42Z · ila
|
||||
|
||||
2026-08-04 与 T-302 恢复契约定值:第一个执行开始事件在同一事务把 attempt/task 从 CLAIMED 转为 ORDERING,并把 task.version 恰好递增一次;后续事件不再递增。T-302 同会话恢复只接受 ORDERING 且当前版本等于领取快照版本+1,其他版本漂移转人工。
|
||||
|
||||
### 2026-08-04T14:39:03Z · ila
|
||||
|
||||
2026-08-04 契约收紧:T-205 改为依赖 T-210、迁移 00007;冻结 GATE_1/2/3_PASSED 的完整字段与对应 evidence kind,并冻结围栏前失败码和 DRY_RUN_COMPLETED_BEFORE_FENCE。围栏后 observation 不得伪装成围栏前失败。
|
||||
<!-- END VIKUNJA EXPORT -->
|
||||
|
||||
## 边界
|
||||
|
||||
- T-205 只向 T-302 已创建且由当前设备/会话持有的 attempt 追加固定结构事件和摘要;不得创建
|
||||
purchase attempt、领取任务、续租、签发或释放授权。所有权、generation、claim token、租约、
|
||||
authorization 与状态任一不匹配时零业务写入。
|
||||
- 事件必须 append-only、attempt 内严格连续且批次全有或全无;重放只能返回原结果。任何旧序号、
|
||||
缺口、异载荷、跨 attempt、跨设备或并发冲突不得覆盖、跳过或重排既有事实。
|
||||
- 只接收版本化白名单中的 step/outcome/reason 及该步骤必需的规范金额、正整数数量和受控引用。
|
||||
不接收自由页面文本、完整 XML、地址、手机号、本机路径、token、动作脚本、选择器或坐标。
|
||||
- 三条成功步骤只能是 `GATE_1_PASSED`、`GATE_2_PASSED`、`GATE_3_PASSED`,且分别引用同一
|
||||
task/attempt/device/generation 下精确 kind 为 `SKU_PANEL_GATE_1`、`SKU_PANEL_GATE_2`、
|
||||
`ORDER_CONFIRM_GATE_3` 的已成功 asset。缺证据、错 kind、跨归属或上传结果不明时零事件写入。
|
||||
- 实际规格、闸门一/二单价、数量读回和确认页金额只能由对应固定成功步骤首次写入执行摘要;后续
|
||||
冲突值必须拒绝,不能最后写入者覆盖。闸门三还必须首次固定提交控件精确文案、匹配数和启用态;
|
||||
金额始终是十进制字符串,不用浮点数。
|
||||
- 第一个执行开始事件必须在同一事务中把 attempt/task 从 `CLAIMED` 转为 `ORDERING`,并把 task
|
||||
`version` **恰好递增一次**;后续事件不得继续递增。T-302 的同会话恢复只可把 ORDERING 解释为
|
||||
领取快照版本加一,其他版本漂移必须转人工,不能用“状态是 ORDERING”跳过版本一致性。
|
||||
- 围栏前失败只把 attempt 置 `FAILED`、task 置 `NEEDS_MANUAL` 并留下终止事实;authorization 与
|
||||
claim 保持占用,等待 T-207 人工检查。不得自动回 `DRAFT`、释放、重新授权、转领或重试页面动作。
|
||||
- 围栏前 reason 必须来自任务方案冻结的精确白名单,包含 `DRY_RUN_COMPLETED_BEFORE_FENCE`;
|
||||
`EXTERNAL_PAYMENT_HANDOFF`、`SECURITY_CHALLENGE` 与 `UNKNOWN` 只属于围栏后 result observation,
|
||||
不得伪装成可恢复的围栏前失败。
|
||||
- 不实现或引用 submission fence、`click_permitted`、订单结果、提交订单点击。不得编写支付、免密
|
||||
支付、先用后付或任何扣款控件代码;第一趟试选/围栏前路径不得可达下单函数。
|
||||
- 不在 T-210 三种证据 kind 之外继续扩展截图,不上传 XML/manifest/目录,不接触真机页面判据或
|
||||
client 代码。
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user