feat(admin): add device credential isolation
This commit is contained in:
@@ -11,6 +11,7 @@ import (
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"cmbuyer/admin/internal/deviceauth"
|
||||
"cmbuyer/admin/internal/evidence"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
@@ -29,11 +30,22 @@ 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, authenticated := options.DeviceAuthenticator.Authenticate(context.Request)
|
||||
if !authenticated {
|
||||
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 {
|
||||
|
||||
@@ -19,6 +19,7 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"cmbuyer/admin/internal/deviceauth"
|
||||
"cmbuyer/admin/internal/evidence"
|
||||
"cmbuyer/admin/internal/migrations"
|
||||
evidencestorage "cmbuyer/admin/internal/storage/evidence"
|
||||
@@ -30,6 +31,7 @@ const (
|
||||
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) {
|
||||
@@ -43,12 +45,60 @@ func TestEvidenceUploadAuthenticatesBeforeReadingBody(t *testing.T) {
|
||||
|
||||
router.ServeHTTP(response, request)
|
||||
|
||||
if response.Code != http.StatusUnauthorized || poison.reads != 0 || authenticator.calls != 1 {
|
||||
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)
|
||||
@@ -62,8 +112,96 @@ func TestAdminSessionCannotActAsDeviceUploader(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
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{allowed: true, principal: evidence.DevicePrincipal{ID: "device-one"}})
|
||||
router, database := newEvidenceRouter(t, &fakeDeviceAuthenticator{principal: deviceauth.Principal{ID: evidenceDeviceID}})
|
||||
pngBytes := serverTestPNG(t, 6, 4)
|
||||
fields := validEvidenceFields(pngBytes)
|
||||
|
||||
@@ -118,7 +256,7 @@ func TestEvidenceUploadReplayConflictAndProtectedRead(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestEvidenceUploadRejectsStrictMultipartViolations(t *testing.T) {
|
||||
router, database := newEvidenceRouter(t, &fakeDeviceAuthenticator{allowed: true, principal: evidence.DevicePrincipal{ID: "device-one"}})
|
||||
router, database := newEvidenceRouter(t, &fakeDeviceAuthenticator{principal: deviceauth.Principal{ID: evidenceDeviceID}})
|
||||
pngBytes := serverTestPNG(t, 2, 2)
|
||||
base := validEvidenceFields(pngBytes)
|
||||
wrongHash := copyStringMap(base)
|
||||
@@ -174,36 +312,44 @@ func TestEvidenceUploadRejectsStrictMultipartViolations(t *testing.T) {
|
||||
}
|
||||
|
||||
type fakeDeviceAuthenticator struct {
|
||||
allowed bool
|
||||
principal evidence.DevicePrincipal
|
||||
principal deviceauth.Principal
|
||||
err error
|
||||
calls int
|
||||
}
|
||||
|
||||
func (authenticator *fakeDeviceAuthenticator) Authenticate(*http.Request) (evidence.DevicePrincipal, bool) {
|
||||
func (authenticator *fakeDeviceAuthenticator) Authenticate(*http.Request) (deviceauth.Principal, error) {
|
||||
authenticator.calls++
|
||||
return authenticator.principal, authenticator.allowed
|
||||
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 evidence.DeviceAuthenticator) (http.Handler, *sql.DB) {
|
||||
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() })
|
||||
_, 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 {
|
||||
if err := migrations.Up(context.Background(), database, testMigrationDirectory(t)); err != nil {
|
||||
t.Fatalf("migrate database: %v", err)
|
||||
}
|
||||
insertEvidenceAttempt(t, database)
|
||||
@@ -215,6 +361,15 @@ func newEvidenceRouter(t *testing.T, authenticator evidence.DeviceAuthenticator)
|
||||
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"
|
||||
@@ -230,6 +385,14 @@ func insertEvidenceAttempt(t *testing.T, database *sql.DB) {
|
||||
}
|
||||
|
||||
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)
|
||||
@@ -258,9 +421,7 @@ func serveEvidenceUpload(t *testing.T, router http.Handler, taskID string, field
|
||||
}
|
||||
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
|
||||
return request
|
||||
}
|
||||
|
||||
func validEvidenceFields(pngBytes []byte) map[string]string {
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
"unicode/utf8"
|
||||
|
||||
"cmbuyer/admin/internal/auth"
|
||||
"cmbuyer/admin/internal/deviceauth"
|
||||
"cmbuyer/admin/internal/evidence"
|
||||
"cmbuyer/admin/internal/taskdetail"
|
||||
"cmbuyer/admin/internal/tasks"
|
||||
@@ -34,7 +35,7 @@ type Options struct {
|
||||
Tasks tasks.Store
|
||||
TaskDetails taskdetail.Store
|
||||
Evidence evidence.Store
|
||||
DeviceAuthenticator evidence.DeviceAuthenticator
|
||||
DeviceAuthenticator deviceauth.Authenticator
|
||||
}
|
||||
|
||||
// NewRouter 返回当前服务范围内的完整 HTTP 路由。
|
||||
@@ -266,6 +267,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
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"time"
|
||||
|
||||
"cmbuyer/admin/internal/auth"
|
||||
"cmbuyer/admin/internal/deviceauth"
|
||||
"cmbuyer/admin/internal/evidence"
|
||||
"cmbuyer/admin/internal/server"
|
||||
"cmbuyer/admin/internal/taskdetail"
|
||||
@@ -389,8 +390,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 {
|
||||
@@ -488,10 +489,10 @@ 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{}, evidence.RejectAllDeviceAuthenticator{})
|
||||
return newRouterWithDependencies(t, store, emptyDetailStore{}, emptyEvidenceStore{}, deviceauth.RejectAllAuthenticator{})
|
||||
}
|
||||
|
||||
func newRouterWithDependencies(t *testing.T, store tasks.Store, details taskdetail.Store, evidenceStore evidence.Store, deviceAuthenticator evidence.DeviceAuthenticator) (*gin.Engine, *auth.Manager) {
|
||||
func newRouterWithDependencies(t *testing.T, store tasks.Store, details taskdetail.Store, evidenceStore evidence.Store, deviceAuthenticator deviceauth.Authenticator) (*gin.Engine, *auth.Manager) {
|
||||
t.Helper()
|
||||
gin.SetMode(gin.TestMode)
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte("test-password"), bcrypt.MinCost)
|
||||
@@ -526,7 +527,7 @@ func (emptyEvidenceStore) Stage(io.Reader, string) (evidence.StagedFile, error)
|
||||
return evidence.StagedFile{}, evidence.ErrInvalid
|
||||
}
|
||||
func (emptyEvidenceStore) Discard(evidence.StagedFile) {}
|
||||
func (emptyEvidenceStore) Commit(context.Context, evidence.DevicePrincipal, evidence.UploadMetadata, evidence.StagedFile) (evidence.Asset, bool, error) {
|
||||
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) {
|
||||
@@ -538,6 +539,7 @@ type memoryStore struct {
|
||||
rows []tasks.TaskRow
|
||||
listDraftsCalls int
|
||||
listTasksCalls int
|
||||
startCalls int
|
||||
}
|
||||
|
||||
func (store *memoryStore) CreateDraft(_ context.Context, draft tasks.Draft) (tasks.Draft, error) {
|
||||
@@ -568,6 +570,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
|
||||
}
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"cmbuyer/admin/internal/evidence"
|
||||
"cmbuyer/admin/internal/deviceauth"
|
||||
"cmbuyer/admin/internal/taskdetail"
|
||||
)
|
||||
|
||||
@@ -16,7 +16,7 @@ const detailTaskID = "a3c9f507-7473-4fa6-8d71-8786c34c6301"
|
||||
|
||||
func TestTaskDetailRequiresAdminBeforeLookup(t *testing.T) {
|
||||
details := &recordingDetailStore{detail: taskDetailFixture()}
|
||||
router, _ := newRouterWithDependencies(t, &memoryStore{}, details, emptyEvidenceStore{}, evidence.RejectAllDeviceAuthenticator{})
|
||||
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)
|
||||
@@ -25,7 +25,7 @@ func TestTaskDetailRequiresAdminBeforeLookup(t *testing.T) {
|
||||
|
||||
func TestTaskDetailFullPageAndDrawerShareAuditContent(t *testing.T) {
|
||||
details := &recordingDetailStore{detail: taskDetailFixture()}
|
||||
router, _ := newRouterWithDependencies(t, &memoryStore{}, details, emptyEvidenceStore{}, evidence.RejectAllDeviceAuthenticator{})
|
||||
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`) {
|
||||
@@ -59,7 +59,7 @@ func TestTaskDetailFullPageAndDrawerShareAuditContent(t *testing.T) {
|
||||
|
||||
func TestTaskDetailRejectsForgedFragmentAndMissingTask(t *testing.T) {
|
||||
details := &recordingDetailStore{err: taskdetail.ErrNotFound}
|
||||
router, _ := newRouterWithDependencies(t, &memoryStore{}, details, emptyEvidenceStore{}, evidence.RejectAllDeviceAuthenticator{})
|
||||
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"},
|
||||
|
||||
Reference in New Issue
Block a user