452 lines
20 KiB
Go
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
|
|
}
|