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(""), 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 }