package api import ( "bytes" "encoding/json" "errors" "net/http" "net/http/httptest" "strings" "testing" ) // Face images held by this server, for a deployment with no object storage. // // The property under test throughout is that the two storage routes differ in // exactly one hop: the key is minted differently and everything downstream - // ingest, the feed, the customer record, erasure - is one implementation. func putFace(t *testing.T, srv *Server, token string, body []byte) *httptest.ResponseRecorder { t.Helper() rr := httptest.NewRecorder() req := httptest.NewRequest(http.MethodPost, "/api/agent/faces", bytes.NewReader(body)) req.Header.Set("Authorization", "Bearer "+token) srv.Routes().ServeHTTP(rr, req) return rr } func TestAnAgentStoresAFaceAndAPersonReadsItBack(t *testing.T) { srv, fs := newServer(t) srv.Blob = nil // no object storage anywhere: the case this exists for fs.addAgent("agent-token", AgentPrincipal{ClientID: "client-acme", SiteID: "site-1"}) seedUser(fs) img := jpegBytes(512) rr := putFace(t, srv, "agent-token", img) if rr.Code != http.StatusCreated { t.Fatalf("upload: %d %s", rr.Code, rr.Body) } var out struct { Key string `json:"key"` } if err := json.Unmarshal(rr.Body.Bytes(), &out); err != nil { t.Fatal(err) } // A prefixed key, so `visits.image_key` can name an object in either store // and the read path can tell which without a second lookup. if !strings.HasPrefix(out.Key, "db:") { t.Fatalf("want a db: key, got %q", out.Key) } // The tenant and the site come from the AGENT's credential, never the // request, so a shop PC cannot file an image under another company. if fs.lastFaceClient != "client-acme" || fs.lastFaceSite != "site-1" { t.Fatalf("stored against %s/%s", fs.lastFaceClient, fs.lastFaceSite) } sess := login(t, srv, "manager@acme.com", "correct horse battery") rec := do(t, srv, "GET", faceURL(out.Key), sess.Token, nil) if rec.Code != http.StatusOK { t.Fatalf("read back: %d %s", rec.Code, rec.Body.String()) } if got := rec.Header().Get("Content-Type"); got != "image/jpeg" { t.Errorf("content type %q", got) } if !bytes.Equal(rec.Body.Bytes(), img) { t.Error("the bytes that came back are not the ones that went in") } } // This endpoint stores what it is handed and serves it back to a browser, so // the one thing it must not become is a way to park arbitrary content under a // URL this server will serve. Checked against the bytes, never the header. func TestOnlyAJPEGIsStoredAsAFace(t *testing.T) { srv, fs := newServer(t) fs.addAgent("agent-token", AgentPrincipal{ClientID: "client-acme", SiteID: "site-1"}) for _, body := range []string{ "", "GIF89a", "%PDF-1.4", "", } { rr := putFace(t, srv, "agent-token", []byte(body)) if rr.Code == http.StatusCreated { t.Errorf("accepted %q as a face image", body) } } } func TestAFaceIsNotReadableWithoutASession(t *testing.T) { srv, fs := newServer(t) fs.addAgent("agent-token", AgentPrincipal{ClientID: "client-acme", SiteID: "site-1"}) rr := putFace(t, srv, "agent-token", jpegBytes(64)) var out struct { Key string `json:"key"` } _ = json.Unmarshal(rr.Body.Bytes(), &out) // The reason it is session-authenticated rather than a signed link: there // is no third party to delegate to, and an unauthenticated URL would be a // way to reach a customer's photograph with no session at all. if rec := do(t, srv, "GET", faceURL(out.Key), "", nil); rec.Code != http.StatusUnauthorized { t.Fatalf("a face was served with no session, got %d", rec.Code) } } func TestAnotherTenantCannotReadYourStoredFace(t *testing.T) { srv, fs := newServer(t) fs.addAgent("acme-agent", AgentPrincipal{ClientID: "client-acme", SiteID: "site-1"}) seedUser(fs) fs.addUser("other@beta.com", "correct horse battery", UserRecord{ ID: "u2", ClientID: "client-beta", ClientName: "Beta Ltd", FullName: "Bo", Role: "manager", Active: true, }) rr := putFace(t, srv, "acme-agent", jpegBytes(64)) var out struct { Key string `json:"key"` } _ = json.Unmarshal(rr.Body.Bytes(), &out) // An image key travels in API responses, so a caller who kept one - or // guessed one - must get nothing rather than somebody else's customer. beta := login(t, srv, "other@beta.com", "correct horse battery") if rec := do(t, srv, "GET", faceURL(out.Key), beta.Token, nil); rec.Code != http.StatusNotFound { t.Fatalf("another tenant read a stored face, got %d", rec.Code) } acme := login(t, srv, "manager@acme.com", "correct horse battery") if rec := do(t, srv, "GET", faceURL(out.Key), acme.Token, nil); rec.Code != http.StatusOK { t.Fatalf("the owning tenant could not read its own face, got %d", rec.Code) } } // The customer record has to work on a deployment with no bucket too - it is // the screen staff use to recognise the person in front of them. func TestTheCustomerPhotoWorksWithNoObjectStorage(t *testing.T) { srv, fs := newServer(t) srv.Blob = nil fs.addAgent("agent-token", AgentPrincipal{ClientID: "client-acme", SiteID: "site-1"}) seedUser(fs) rr := putFace(t, srv, "agent-token", jpegBytes(64)) var up struct { Key string `json:"key"` } _ = json.Unmarshal(rr.Body.Bytes(), &up) const visitor = "44444444-4444-4444-8444-444444444444" fs.imageKeys[visitor] = up.Key sess := login(t, srv, "manager@acme.com", "correct horse battery") rec := do(t, srv, "GET", "/api/visitors/"+visitor+"/image", sess.Token, nil) if rec.Code != http.StatusOK { t.Fatalf("customer photo: %d %s", rec.Code, rec.Body.String()) } var img Image if err := json.Unmarshal(rec.Body.Bytes(), &img); err != nil { t.Fatal(err) } if !img.Available || !img.Auth { t.Fatalf("want an available image that needs the session, got %+v", img) } // The storage key names a tenant's prefix and must never be what a client // receives, on either route. if strings.Contains(rec.Body.String(), "db:") { t.Errorf("the storage key leaked: %s", rec.Body.String()) } // Reading a face is worth an audit row wherever the LINK is handed out. // Recorded here rather than at the byte fetch, because the bucket route's // bytes never touch this server and the two must be counted the same way. if !audited(fs, "image.view") { t.Error("reading a customer photo left no audit row") } } func audited(fs *fakeStore, action string) bool { fs.mu.Lock() defer fs.mu.Unlock() for _, a := range fs.audits { if a.Action == action { return true } } return false } // Erasure has to destroy an image this server holds, not only one in a bucket. // A face image that survives an erasure request is the one outcome that // endpoint must never produce. func TestErasureDestroysAStoredFace(t *testing.T) { srv, fs := newServer(t) srv.Blob = nil fs.addAgent("agent-token", AgentPrincipal{ClientID: "client-acme", SiteID: "site-1"}) seedUser(fs) rr := putFace(t, srv, "agent-token", jpegBytes(64)) var up struct { Key string `json:"key"` } _ = json.Unmarshal(rr.Body.Bytes(), &up) const visitor = "55555555-5555-4555-8555-555555555555" fs.imageKeys[visitor] = up.Key sess := login(t, srv, "manager@acme.com", "correct horse battery") if rec := do(t, srv, "DELETE", "/api/visitors/"+visitor, sess.Token, nil); rec.Code != http.StatusNoContent { t.Fatalf("erase: %d %s", rec.Code, rec.Body.String()) } if rec := do(t, srv, "GET", faceURL(up.Key), sess.Token, nil); rec.Code != http.StatusNotFound { t.Fatalf("the face survived erasure, got %d", rec.Code) } } // If the image cannot be destroyed, NOTHING is erased and the caller is told. // Reporting a legal request as honoured when it was not is the failure this // path exists to prevent. func TestAFailedFaceDeleteAbortsTheWholeErasure(t *testing.T) { srv, fs := newServer(t) srv.Blob = nil seedUser(fs) const visitor = "66666666-6666-4666-8666-666666666666" fs.imageKeys[visitor] = "db:66666666-6666-4666-8666-666666666666" fs.faceDeleteErr = errors.New("storage is down") sess := login(t, srv, "manager@acme.com", "correct horse battery") rec := do(t, srv, "DELETE", "/api/visitors/"+visitor, sess.Token, nil) if rec.Code != http.StatusBadGateway { t.Fatalf("want 502 and nothing erased, got %d: %s", rec.Code, rec.Body.String()) } if len(fs.forgotten) != 0 { t.Fatalf("the record was erased even though the photo could not be: %v", fs.forgotten) } }