291 lines
13 KiB
Go
291 lines
13 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/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"
|
|
)
|
|
|
|
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 || 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 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 TestEvidenceUploadReplayConflictAndProtectedRead(t *testing.T) {
|
|
router, database := newEvidenceRouter(t, &fakeDeviceAuthenticator{allowed: true, principal: evidence.DevicePrincipal{ID: "device-one"}})
|
|
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{allowed: true, principal: evidence.DevicePrincipal{ID: "device-one"}})
|
|
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 {
|
|
allowed bool
|
|
principal evidence.DevicePrincipal
|
|
calls int
|
|
}
|
|
|
|
func (authenticator *fakeDeviceAuthenticator) Authenticate(*http.Request) (evidence.DevicePrincipal, bool) {
|
|
authenticator.calls++
|
|
return authenticator.principal, authenticator.allowed
|
|
}
|
|
|
|
type poisonBody struct{ reads int }
|
|
|
|
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 evidence.DeviceAuthenticator) (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() })
|
|
_, 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)
|
|
}
|
|
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 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()
|
|
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())
|
|
response := httptest.NewRecorder()
|
|
router.ServeHTTP(response, request)
|
|
return response
|
|
}
|
|
|
|
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
|
|
}
|