Files
cmbuyer/admin/internal/server/evidence_test.go
T

452 lines
20 KiB
Go

package server_test
import (
"bytes"
"context"
"crypto/sha256"
"database/sql"
"encoding/hex"
"encoding/json"
"image"
"image/png"
"io"
"mime/multipart"
"net/http"
"net/http/httptest"
"net/textproto"
"path/filepath"
"runtime"
"strings"
"testing"
"cmbuyer/admin/internal/deviceauth"
"cmbuyer/admin/internal/evidence"
"cmbuyer/admin/internal/migrations"
evidencestorage "cmbuyer/admin/internal/storage/evidence"
"cmbuyer/admin/internal/storage/sqlite"
)
const (
evidenceTaskID = "63c9f507-7473-4fa6-8d71-8786c34c6301"
evidenceAuthID = "73c9f507-7473-4fa6-8d71-8786c34c6301"
evidenceAttemptID = "83c9f507-7473-4fa6-8d71-8786c34c6301"
evidenceUploadKey = "93c9f507-7473-4fa6-8d71-8786c34c6301"
evidenceDeviceID = "13c9f507-7473-4fa6-8d71-8786c34c6301"
)
func TestEvidenceUploadAuthenticatesBeforeReadingBody(t *testing.T) {
authenticator := &fakeDeviceAuthenticator{}
router, _ := newRouterWithDependencies(t, &memoryStore{}, emptyDetailStore{}, emptyEvidenceStore{}, authenticator)
poison := &poisonBody{}
request := httptest.NewRequest(http.MethodPost, "/api/v1/tasks/"+evidenceTaskID+"/evidence", nil)
request.Body = poison
request.Header.Set("Content-Type", "text/plain")
response := httptest.NewRecorder()
router.ServeHTTP(response, request)
if response.Code != http.StatusUnauthorized || response.Body.Len() != 0 || response.Header().Get("WWW-Authenticate") != "Bearer" || poison.reads != 0 || authenticator.calls != 1 {
t.Fatalf("status/reads/auth calls = %d/%d/%d, want 401/0/1", response.Code, poison.reads, authenticator.calls)
}
assertSecurityHeaders(t, response)
}
func TestEvidenceUploadAuthenticationStorageFailureBeforeReadingBody(t *testing.T) {
database, err := sqlite.Open(filepath.Join(t.TempDir(), "authentication-failure.db"))
if err != nil {
t.Fatalf("open database: %v", err)
}
if err := migrations.Up(context.Background(), database, testMigrationDirectory(t)); err != nil {
t.Fatalf("migrate database: %v", err)
}
authenticator, err := deviceauth.NewSQLiteAuthenticator(database)
if err != nil {
t.Fatalf("new authenticator: %v", err)
}
credentialStore, err := deviceauth.NewCredentialStore(database)
if err != nil {
t.Fatalf("new credential store: %v", err)
}
issued, err := credentialStore.Issue(context.Background(), "test device")
if err != nil {
t.Fatalf("issue credential: %v", err)
}
if err := database.Close(); err != nil {
t.Fatalf("close database: %v", err)
}
router, _ := newRouterWithDependencies(t, &memoryStore{}, emptyDetailStore{}, emptyEvidenceStore{}, authenticator)
poison := &poisonBody{}
request := httptest.NewRequest(http.MethodPost, "/api/v1/tasks/"+evidenceTaskID+"/evidence", nil)
request.Body = poison
request.Header.Set(deviceauth.AuthorizationHeader, "Bearer "+issued.Token)
request.Header.Set(deviceauth.DeviceIDHeader, issued.DeviceID)
response := httptest.NewRecorder()
router.ServeHTTP(response, request)
if response.Code != http.StatusServiceUnavailable || response.Body.Len() != 0 || poison.reads != 0 {
t.Fatalf("storage failure status/body/reads = %d/%q/%d, want 503/empty/0", response.Code, response.Body.String(), poison.reads)
}
}
func TestEvidenceUploadRejectsInvalidSuccessfulPrincipalBeforeReadingBody(t *testing.T) {
router, _ := newRouterWithDependencies(t, &memoryStore{}, emptyDetailStore{}, emptyEvidenceStore{}, uncheckedDeviceAuthenticator{})
poison := &poisonBody{}
request := httptest.NewRequest(http.MethodPost, "/api/v1/tasks/"+evidenceTaskID+"/evidence", nil)
request.Body = poison
response := httptest.NewRecorder()
router.ServeHTTP(response, request)
if response.Code != http.StatusServiceUnavailable || response.Body.Len() != 0 || poison.reads != 0 {
t.Fatalf("invalid principal status/body/reads = %d/%q/%d, want 503/empty/0", response.Code, response.Body.String(), poison.reads)
}
}
func TestAdminSessionCannotActAsDeviceUploader(t *testing.T) {
router, _ := newRouter(t)
cookie := authenticate(t, router)
request := httptest.NewRequest(http.MethodPost, "/api/v1/tasks/"+evidenceTaskID+"/evidence", nil)
request.Body = &poisonBody{}
request.AddCookie(cookie)
response := httptest.NewRecorder()
router.ServeHTTP(response, request)
if response.Code != http.StatusUnauthorized {
t.Fatalf("admin upload status = %d, want 401", response.Code)
}
}
func TestRealDeviceCredentialIdentityIsolationAndMixedCredentials(t *testing.T) {
database, err := sqlite.Open(filepath.Join(t.TempDir(), "identity-isolation.db"))
if err != nil {
t.Fatalf("open database: %v", err)
}
t.Cleanup(func() { _ = database.Close() })
if err := migrations.Up(context.Background(), database, testMigrationDirectory(t)); err != nil {
t.Fatalf("migrate database: %v", err)
}
insertEvidenceAttempt(t, database)
assetStore, err := evidencestorage.NewStore(database, filepath.Join(t.TempDir(), "assets"))
if err != nil {
t.Fatalf("new evidence store: %v", err)
}
credentialStore, err := deviceauth.NewCredentialStore(database)
if err != nil {
t.Fatalf("new credential store: %v", err)
}
issued, err := credentialStore.Issue(context.Background(), "采购工具一号")
if err != nil {
t.Fatalf("issue credential: %v", err)
}
authenticator, err := deviceauth.NewSQLiteAuthenticator(database)
if err != nil {
t.Fatalf("new authenticator: %v", err)
}
taskStore := &memoryStore{}
router, _ := newRouterWithDependencies(t, taskStore, emptyDetailStore{}, assetStore, authenticator)
addDeviceHeaders := func(request *http.Request) {
request.Header.Set(deviceauth.AuthorizationHeader, "Bearer "+issued.Token)
request.Header.Set(deviceauth.DeviceIDHeader, issued.DeviceID)
}
start := newStartRequest(t, validStartBody(), "application/json", "", nil)
addDeviceHeaders(start)
startResponse := httptest.NewRecorder()
router.ServeHTTP(startResponse, start)
create := httptest.NewRequest(http.MethodPost, "/tasks", strings.NewReader("title=device"))
create.Header.Set("Content-Type", "application/x-www-form-urlencoded")
addDeviceHeaders(create)
createResponse := httptest.NewRecorder()
router.ServeHTTP(createResponse, create)
if startResponse.Code != http.StatusUnauthorized || createResponse.Code != http.StatusUnauthorized || taskStore.startCalls != 0 || len(taskStore.drafts) != 0 {
t.Fatalf("device management isolation = start %d/create %d/calls %d/drafts %d", startResponse.Code, createResponse.Code, taskStore.startCalls, len(taskStore.drafts))
}
adminCookie, csrf := authenticatedStartSession(t, router)
mixedWithoutCSRF := newStartRequest(t, validStartBody(), "application/json", "", adminCookie)
addDeviceHeaders(mixedWithoutCSRF)
mixedWithoutCSRFResponse := httptest.NewRecorder()
router.ServeHTTP(mixedWithoutCSRFResponse, mixedWithoutCSRF)
if mixedWithoutCSRFResponse.Code != http.StatusForbidden || taskStore.startCalls != 0 {
t.Fatalf("mixed request bypassed admin CSRF: status/calls=%d/%d", mixedWithoutCSRFResponse.Code, taskStore.startCalls)
}
mixedAdmin := newStartRequest(t, validStartBody(), "application/json", csrf, adminCookie)
addDeviceHeaders(mixedAdmin)
mixedAdminResponse := httptest.NewRecorder()
router.ServeHTTP(mixedAdminResponse, mixedAdmin)
if mixedAdminResponse.Code != http.StatusBadRequest || taskStore.startCalls != 1 {
t.Fatalf("mixed admin request changed identity domain: status/calls=%d/%d", mixedAdminResponse.Code, taskStore.startCalls)
}
pngBytes := serverTestPNG(t, 3, 2)
upload := newEvidenceUploadRequest(t, evidenceTaskID, validEvidenceFields(pngBytes), pngBytes, evidence.PNGContentType, "raw.png", nil)
addDeviceHeaders(upload)
upload.AddCookie(adminCookie)
uploadResponse := httptest.NewRecorder()
router.ServeHTTP(uploadResponse, upload)
if uploadResponse.Code != http.StatusCreated {
t.Fatalf("mixed upload status/body = %d/%q", uploadResponse.Code, uploadResponse.Body.String())
}
var uploadedBy string
if err := database.QueryRow(`SELECT uploaded_by_device_id FROM evidence_assets`).Scan(&uploadedBy); err != nil || uploadedBy != issued.DeviceID {
t.Fatalf("uploaded principal = %q, err=%v", uploadedBy, err)
}
if _, _, err := credentialStore.Revoke(context.Background(), issued.DeviceID); err != nil {
t.Fatalf("revoke credential: %v", err)
}
revokedUpload := newEvidenceUploadRequest(t, evidenceTaskID, validEvidenceFields(pngBytes), pngBytes, evidence.PNGContentType, "raw.png", nil)
addDeviceHeaders(revokedUpload)
revokedResponse := httptest.NewRecorder()
router.ServeHTTP(revokedResponse, revokedUpload)
if revokedResponse.Code != http.StatusUnauthorized || revokedResponse.Body.Len() != 0 {
t.Fatalf("revoked upload = %d/%q", revokedResponse.Code, revokedResponse.Body.String())
}
}
func TestEvidenceUploadReplayConflictAndProtectedRead(t *testing.T) {
router, database := newEvidenceRouter(t, &fakeDeviceAuthenticator{principal: deviceauth.Principal{ID: evidenceDeviceID}})
pngBytes := serverTestPNG(t, 6, 4)
fields := validEvidenceFields(pngBytes)
first := serveEvidenceUpload(t, router, evidenceTaskID, fields, pngBytes, evidence.PNGContentType, `..\private\original.png`, nil)
if first.Code != http.StatusCreated {
t.Fatalf("first upload status/body = %d/%q", first.Code, first.Body.String())
}
var asset evidence.Asset
if err := json.Unmarshal(first.Body.Bytes(), &asset); err != nil {
t.Fatalf("decode upload response: %v", err)
}
if asset.TaskID != evidenceTaskID || asset.AttemptID != evidenceAttemptID || asset.SHA256 != fields["sha256"] || strings.Contains(first.Body.String(), "private") || strings.Contains(first.Body.String(), "original.png") {
t.Fatalf("unsafe upload response = %s", first.Body.String())
}
replay := serveEvidenceUpload(t, router, evidenceTaskID, fields, pngBytes, evidence.PNGContentType, "again.png", nil)
if replay.Code != http.StatusOK {
t.Fatalf("replay status = %d, want 200", replay.Code)
}
var replayed evidence.Asset
if err := json.Unmarshal(replay.Body.Bytes(), &replayed); err != nil || replayed.ID != asset.ID {
t.Fatalf("replay asset = %#v, err %v", replayed, err)
}
conflicting := copyStringMap(fields)
conflicting["captured_at"] = "2026-08-04T09:01:01Z"
if response := serveEvidenceUpload(t, router, evidenceTaskID, conflicting, pngBytes, evidence.PNGContentType, "same.png", nil); response.Code != http.StatusConflict {
t.Fatalf("conflicting replay status = %d, want 409", response.Code)
}
var count int
if err := database.QueryRow("SELECT COUNT(*) FROM evidence_assets").Scan(&count); err != nil || count != 1 {
t.Fatalf("asset count = %d, err %v", count, err)
}
if response := serve(router, http.MethodGet, "/evidence/"+asset.ID, nil, nil); response.Code != http.StatusUnauthorized || response.Body.Len() != 0 {
t.Fatalf("anonymous read = %d/%q", response.Code, response.Body.String())
}
adminCookie := authenticate(t, router)
read := serve(router, http.MethodGet, "/evidence/"+asset.ID, nil, adminCookie)
if read.Code != http.StatusOK || !bytes.Equal(read.Body.Bytes(), pngBytes) {
t.Fatalf("admin read = %d, bytes equal %t", read.Code, bytes.Equal(read.Body.Bytes(), pngBytes))
}
for header, want := range map[string]string{"Content-Type": "image/png", "Cache-Control": "no-store", "X-Content-Type-Options": "nosniff", "Content-Disposition": `inline; filename="evidence.png"`} {
if got := read.Header().Get(header); got != want {
t.Fatalf("%s = %q, want %q", header, got, want)
}
}
missing := serve(router, http.MethodGet, "/evidence/not-a-uuid", nil, adminCookie)
if missing.Code != http.StatusNotFound || missing.Body.Len() != 0 {
t.Fatalf("missing evidence = %d/%q", missing.Code, missing.Body.String())
}
}
func TestEvidenceUploadRejectsStrictMultipartViolations(t *testing.T) {
router, database := newEvidenceRouter(t, &fakeDeviceAuthenticator{principal: deviceauth.Principal{ID: evidenceDeviceID}})
pngBytes := serverTestPNG(t, 2, 2)
base := validEvidenceFields(pngBytes)
wrongHash := copyStringMap(base)
wrongHash["sha256"] = strings.Repeat("b", 64)
uppercaseHash := copyStringMap(base)
uppercaseHash["sha256"] = strings.ToUpper(uppercaseHash["sha256"])
wrongPrivacy := copyStringMap(base)
wrongPrivacy["privacy_tier"] = "PUBLIC"
wrongKind := copyStringMap(base)
wrongKind["kind"] = "ORDER_CONFIRM"
tests := []struct {
name string
fields map[string]string
file []byte
contentType string
extra func(*multipart.Writer) error
want int
}{
{name: "attempt belongs to another task", fields: base, file: pngBytes, contentType: evidence.PNGContentType, want: http.StatusBadRequest},
{name: "xml file", fields: base, file: []byte("<hierarchy/>"), contentType: evidence.PNGContentType, want: http.StatusBadRequest},
{name: "wrong hash", fields: wrongHash, file: pngBytes, contentType: evidence.PNGContentType, want: http.StatusBadRequest},
{name: "uppercase hash", fields: uppercaseHash, file: pngBytes, contentType: evidence.PNGContentType, want: http.StatusBadRequest},
{name: "wrong privacy", fields: wrongPrivacy, file: pngBytes, contentType: evidence.PNGContentType, want: http.StatusBadRequest},
{name: "unapproved kind", fields: wrongKind, file: pngBytes, contentType: evidence.PNGContentType, want: http.StatusBadRequest},
{name: "too large", fields: base, file: make([]byte, evidence.MaxFileBytes+1), contentType: evidence.PNGContentType, want: http.StatusRequestEntityTooLarge},
{name: "wrong file content type", fields: base, file: pngBytes, contentType: "application/xml", want: http.StatusUnsupportedMediaType},
{name: "unknown path field", fields: base, file: pngBytes, contentType: evidence.PNGContentType, extra: func(writer *multipart.Writer) error { return writer.WriteField("path", `C:\secret.xml`) }, want: http.StatusBadRequest},
{name: "duplicate metadata", fields: base, file: pngBytes, contentType: evidence.PNGContentType, extra: func(writer *multipart.Writer) error { return writer.WriteField("sha256", base["sha256"]) }, want: http.StatusBadRequest},
{name: "second file", fields: base, file: pngBytes, contentType: evidence.PNGContentType, extra: func(writer *multipart.Writer) error {
part, err := writer.CreateFormFile("file", "second.png")
if err == nil {
_, err = part.Write(pngBytes)
}
return err
}, want: http.StatusUnsupportedMediaType},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
taskID := evidenceTaskID
if test.name == "attempt belongs to another task" {
taskID = "a3c9f507-7473-4fa6-8d71-8786c34c6301"
}
response := serveEvidenceUpload(t, router, taskID, copyStringMap(test.fields), test.file, test.contentType, "file.png", test.extra)
if response.Code != test.want || response.Body.Len() != 0 {
t.Fatalf("status/body = %d/%q, want %d/empty", response.Code, response.Body.String(), test.want)
}
})
}
var count int
if err := database.QueryRow("SELECT COUNT(*) FROM evidence_assets").Scan(&count); err != nil || count != 0 {
t.Fatalf("invalid requests created %d assets, err %v", count, err)
}
}
type fakeDeviceAuthenticator struct {
principal deviceauth.Principal
err error
calls int
}
func (authenticator *fakeDeviceAuthenticator) Authenticate(*http.Request) (deviceauth.Principal, error) {
authenticator.calls++
if authenticator.err != nil {
return deviceauth.Principal{}, authenticator.err
}
if authenticator.principal.ID == "" {
return deviceauth.Principal{}, deviceauth.ErrUnauthenticated
}
return authenticator.principal, nil
}
type poisonBody struct{ reads int }
type uncheckedDeviceAuthenticator struct{}
func (uncheckedDeviceAuthenticator) Authenticate(*http.Request) (deviceauth.Principal, error) {
return deviceauth.Principal{}, nil
}
func (body *poisonBody) Read([]byte) (int, error) {
body.reads++
return 0, io.ErrUnexpectedEOF
}
func (*poisonBody) Close() error { return nil }
func newEvidenceRouter(t *testing.T, authenticator deviceauth.Authenticator) (http.Handler, *sql.DB) {
t.Helper()
database, err := sqlite.Open(filepath.Join(t.TempDir(), "server-evidence.db"))
if err != nil {
t.Fatalf("open database: %v", err)
}
t.Cleanup(func() { _ = database.Close() })
if err := migrations.Up(context.Background(), database, testMigrationDirectory(t)); err != nil {
t.Fatalf("migrate database: %v", err)
}
insertEvidenceAttempt(t, database)
store, err := evidencestorage.NewStore(database, filepath.Join(t.TempDir(), "assets"))
if err != nil {
t.Fatalf("new evidence store: %v", err)
}
router, _ := newRouterWithDependencies(t, &memoryStore{}, emptyDetailStore{}, store, authenticator)
return router, database
}
func testMigrationDirectory(t *testing.T) string {
t.Helper()
_, file, _, ok := runtime.Caller(0)
if !ok {
t.Fatal("locate migration directory")
}
return filepath.Join(filepath.Dir(file), "..", "..", "migrations")
}
func insertEvidenceAttempt(t *testing.T, database *sql.DB) {
t.Helper()
timestamp := "2026-08-04T00:00:00Z"
if _, err := database.Exec(`INSERT INTO tasks (id, source, title, goods_id, sku_color, sku_size, quantity, max_total_price, status, version, created_at, updated_at) VALUES (?, 'MANUAL', 'task', '123', 'black', 'M', 1, '1.00', 'DRAFT', 1, ?, ?)`, evidenceTaskID, timestamp, timestamp); err != nil {
t.Fatalf("insert task: %v", err)
}
if _, err := database.Exec(`INSERT INTO order_authorizations (id, task_id, task_version, start_key, goods_id, sku_color, sku_size, quantity, total_price_cap, status, created_by, created_at, expires_at) VALUES (?, ?, 1, 'start', '123', 'black', 'M', 1, '1.00', 'ACTIVE', 'admin', ?, ?)`, evidenceAuthID, evidenceTaskID, timestamp, timestamp); err != nil {
t.Fatalf("insert authorization: %v", err)
}
if _, err := database.Exec(`INSERT INTO purchase_attempts (id, task_id, authorization_id, claim_generation, status, started_at) VALUES (?, ?, ?, 1, 'CLAIMED', ?)`, evidenceAttemptID, evidenceTaskID, evidenceAuthID, timestamp); err != nil {
t.Fatalf("insert attempt: %v", err)
}
}
func serveEvidenceUpload(t *testing.T, router http.Handler, taskID string, fields map[string]string, file []byte, fileContentType, filename string, extra func(*multipart.Writer) error) *httptest.ResponseRecorder {
t.Helper()
request := newEvidenceUploadRequest(t, taskID, fields, file, fileContentType, filename, extra)
response := httptest.NewRecorder()
router.ServeHTTP(response, request)
return response
}
func newEvidenceUploadRequest(t *testing.T, taskID string, fields map[string]string, file []byte, fileContentType, filename string, extra func(*multipart.Writer) error) *http.Request {
t.Helper()
var body bytes.Buffer
writer := multipart.NewWriter(&body)
for _, name := range []string{"upload_key", "attempt_id", "kind", "privacy_tier", "sha256", "captured_at"} {
if err := writer.WriteField(name, fields[name]); err != nil {
t.Fatalf("write field %s: %v", name, err)
}
}
header := make(textproto.MIMEHeader)
header.Set("Content-Disposition", `form-data; name="file"; filename="`+filename+`"`)
header.Set("Content-Type", fileContentType)
part, err := writer.CreatePart(header)
if err != nil {
t.Fatalf("create file part: %v", err)
}
if _, err := part.Write(file); err != nil {
t.Fatalf("write file: %v", err)
}
if extra != nil {
if err := extra(writer); err != nil {
t.Fatalf("write extra part: %v", err)
}
}
if err := writer.Close(); err != nil {
t.Fatalf("close multipart: %v", err)
}
request := httptest.NewRequest(http.MethodPost, "/api/v1/tasks/"+taskID+"/evidence", bytes.NewReader(body.Bytes()))
request.Header.Set("Content-Type", writer.FormDataContentType())
return request
}
func validEvidenceFields(pngBytes []byte) map[string]string {
hash := sha256.Sum256(pngBytes)
return map[string]string{
"upload_key": evidenceUploadKey, "attempt_id": evidenceAttemptID,
"kind": evidence.KindSKUPanelGate1, "privacy_tier": evidence.PrivacyInternalRaw,
"sha256": hex.EncodeToString(hash[:]), "captured_at": "2026-08-04T09:01:00Z",
}
}
func serverTestPNG(t *testing.T, width, height int) []byte {
t.Helper()
var buffer bytes.Buffer
if err := png.Encode(&buffer, image.NewNRGBA(image.Rect(0, 0, width, height))); err != nil {
t.Fatalf("encode PNG: %v", err)
}
return buffer.Bytes()
}
func copyStringMap(values map[string]string) map[string]string {
copy := make(map[string]string, len(values))
for key, value := range values {
copy[key] = value
}
return copy
}