package api import ( "bytes" "encoding/json" "io" "net/http" "net/http/httptest" "strings" "testing" "time" "github.com/loyaly/behavision-server/internal/auth" ) // Real-shaped ids: every id in the schema is a uuid, and the handlers now // check that before touching SQL, so a placeholder like "v1" would be testing // the wrong path. const ( visitorAID = "98bf7587-4d55-4ae0-99e0-de8c05dd3e78" visitorB = "11111111-2222-3333-4444-555555555555" visitorA = "/api/visitors/" + visitorAID ) func newServer(t *testing.T) (*Server, *fakeStore) { t.Helper() fs := newFakeStore() return &Server{Store: fs}, fs } func do(t *testing.T, s *Server, method, path, token string, body any) *httptest.ResponseRecorder { t.Helper() var rdr io.Reader if body != nil { b, err := json.Marshal(body) if err != nil { t.Fatal(err) } rdr = bytes.NewReader(b) } req := httptest.NewRequest(method, path, rdr) if token != "" { req.Header.Set("Authorization", "Bearer "+token) } // Every request looks like it came through the proxy, which is where the // throttle reads the client address from. req.Header.Set("X-Forwarded-For", "203.0.113.9") rec := httptest.NewRecorder() s.Routes().ServeHTTP(rec, req) return rec } func login(t *testing.T, s *Server, email, password string) Session { t.Helper() rec := do(t, s, "POST", "/api/auth/login", "", map[string]string{"email": email, "password": password}) if rec.Code != http.StatusOK { t.Fatalf("login: got %d, body %s", rec.Code, rec.Body.String()) } var sess Session if err := json.Unmarshal(rec.Body.Bytes(), &sess); err != nil { t.Fatal(err) } return sess } func seedUser(fs *fakeStore) { fs.addUser("manager@acme.com", "correct horse battery", UserRecord{ ID: "u1", ClientID: "client-acme", ClientName: "Acme Retail", FullName: "Asha", Role: "manager", Active: true, }) } // ---------------------------------------------------------------- sign in func TestLoginReturnsSessionAndNeverThePasswordHash(t *testing.T) { s, fs := newServer(t) seedUser(fs) rec := do(t, s, "POST", "/api/auth/login", "", map[string]string{"email": "Manager@Acme.com", "password": "correct horse battery"}) if rec.Code != http.StatusOK { t.Fatalf("got %d: %s", rec.Code, rec.Body.String()) } body := rec.Body.String() // The single most important assertion here: whatever else changes about the // response shape, a hash must never travel to a shop floor. if strings.Contains(body, "$2a$") || strings.Contains(body, "password_hash") { t.Fatalf("password hash leaked into the response: %s", body) } var sess Session if err := json.Unmarshal([]byte(body), &sess); err != nil { t.Fatal(err) } if sess.Token == "" || sess.RefreshToken == "" { t.Fatal("expected both tokens") } if sess.Token == sess.RefreshToken { t.Fatal("access and refresh tokens must be different secrets") } if sess.User.ClientID != "client-acme" || sess.User.Client != "Acme Retail" { t.Fatalf("user not populated: %+v", sess.User) } // The address is normalised on the way in, so a capitalised sign-in and a // lower-case one are one account. if len(fs.loginTouched) != 1 { t.Fatalf("expected last_login to be recorded once, got %v", fs.loginTouched) } } func TestUnknownEmailAndWrongPasswordAreIndistinguishable(t *testing.T) { s, fs := newServer(t) seedUser(fs) unknown := do(t, s, "POST", "/api/auth/login", "", map[string]string{"email": "nobody@acme.com", "password": "correct horse battery"}) wrong := do(t, s, "POST", "/api/auth/login", "", map[string]string{"email": "manager@acme.com", "password": "not the password"}) if unknown.Code != http.StatusUnauthorized || wrong.Code != http.StatusUnauthorized { t.Fatalf("codes: unknown=%d wrong=%d", unknown.Code, wrong.Code) } // Any difference here is a membership oracle for a customer's staff list. if unknown.Body.String() != wrong.Body.String() { t.Fatalf("responses differ:\n unknown: %s\n wrong: %s", unknown.Body.String(), wrong.Body.String()) } } func TestInactiveUserCannotSignIn(t *testing.T) { s, fs := newServer(t) fs.addUser("gone@acme.com", "correct horse battery", UserRecord{ ID: "u2", ClientID: "client-acme", Active: false, }) rec := do(t, s, "POST", "/api/auth/login", "", map[string]string{"email": "gone@acme.com", "password": "correct horse battery"}) if rec.Code != http.StatusUnauthorized { t.Fatalf("a deactivated account signed in: %d %s", rec.Code, rec.Body.String()) } } func TestRepeatedFailuresAreThrottledAndSuccessClearsIt(t *testing.T) { s, fs := newServer(t) seedUser(fs) s.Throttle = NewThrottle(3, time.Minute) s.IPThrottle = NewThrottle(1000, time.Minute) for i := 0; i < 3; i++ { do(t, s, "POST", "/api/auth/login", "", map[string]string{"email": "manager@acme.com", "password": "wrong"}) } blocked := do(t, s, "POST", "/api/auth/login", "", map[string]string{"email": "manager@acme.com", "password": "correct horse battery"}) if blocked.Code != http.StatusTooManyRequests { t.Fatalf("expected 429 after 3 failures, got %d", blocked.Code) } // A cleared window lets the real password through again, and the success // resets the counter so the next mistake does not lock the shop out. s.Throttle = NewThrottle(3, time.Minute) s.IPThrottle = NewThrottle(1000, time.Minute) login(t, s, "manager@acme.com", "correct horse battery") for i := 0; i < 2; i++ { do(t, s, "POST", "/api/auth/login", "", map[string]string{"email": "manager@acme.com", "password": "wrong"}) } again := do(t, s, "POST", "/api/auth/login", "", map[string]string{"email": "manager@acme.com", "password": "correct horse battery"}) if again.Code != http.StatusOK { t.Fatalf("success did not reset the counter: %d", again.Code) } } // ---------------------------------------------------------------- sessions func TestAuthenticatedRoutesRequireAToken(t *testing.T) { s, fs := newServer(t) seedUser(fs) for _, path := range []string{ "/api/auth/me", "/api/reports/footfall", "/api/reports/conversion", "/api/visitors", "/api/sites", } { if rec := do(t, s, "GET", path, "", nil); rec.Code != http.StatusUnauthorized { t.Errorf("%s without a token returned %d, want 401", path, rec.Code) } if rec := do(t, s, "GET", path, "not-a-real-token", nil); rec.Code != http.StatusUnauthorized { t.Errorf("%s with a bogus token returned %d, want 401", path, rec.Code) } } } func TestExpiredAccessTokenAsksForARefreshRatherThanALogin(t *testing.T) { s, fs := newServer(t) seedUser(fs) now := time.Now() s.Now = func() time.Time { return now } sess := login(t, s, "manager@acme.com", "correct horse battery") // Move past the access lifetime but stay well inside the refresh one. s.Now = func() time.Time { return now.Add(auth.AccessTTL + time.Minute) } rec := do(t, s, "GET", "/api/auth/me", sess.Token, nil) if rec.Code != http.StatusUnauthorized { t.Fatalf("expired token returned %d", rec.Code) } var body map[string]string json.Unmarshal(rec.Body.Bytes(), &body) //nolint:errcheck // The distinct code is what lets the desktop app refresh silently instead // of throwing a shop assistant back to a login form twice a day. if body["error"] != "token_expired" { t.Fatalf("want token_expired so the client can refresh, got %q", body["error"]) } } func TestRefreshRotatesAndTheOldRefreshTokenStopsWorking(t *testing.T) { s, fs := newServer(t) seedUser(fs) first := login(t, s, "manager@acme.com", "correct horse battery") rec := do(t, s, "POST", "/api/auth/refresh", "", map[string]string{"refresh_token": first.RefreshToken}) if rec.Code != http.StatusOK { t.Fatalf("refresh failed: %d %s", rec.Code, rec.Body.String()) } var second Session json.Unmarshal(rec.Body.Bytes(), &second) //nolint:errcheck if second.Token == first.Token || second.RefreshToken == first.RefreshToken { t.Fatal("refresh must issue new secrets, not return the same ones") } if got := do(t, s, "GET", "/api/auth/me", second.Token, nil); got.Code != http.StatusOK { t.Fatalf("new access token rejected: %d", got.Code) } // A refresh token copied off a resold shop PC must not keep working // alongside the real one. replay := do(t, s, "POST", "/api/auth/refresh", "", map[string]string{"refresh_token": first.RefreshToken}) if replay.Code != http.StatusUnauthorized { t.Fatalf("the old refresh token still works: %d", replay.Code) } if old := do(t, s, "GET", "/api/auth/me", first.Token, nil); old.Code == http.StatusOK { t.Fatal("the old access token still works after rotation") } } func TestLogoutRevokesTheSession(t *testing.T) { s, fs := newServer(t) seedUser(fs) sess := login(t, s, "manager@acme.com", "correct horse battery") if rec := do(t, s, "POST", "/api/auth/logout", sess.Token, nil); rec.Code != http.StatusNoContent { t.Fatalf("logout returned %d", rec.Code) } if rec := do(t, s, "GET", "/api/auth/me", sess.Token, nil); rec.Code != http.StatusUnauthorized { t.Fatalf("token still valid after logout: %d", rec.Code) } if rec := do(t, s, "POST", "/api/auth/refresh", "", map[string]string{"refresh_token": sess.RefreshToken}); rec.Code != http.StatusUnauthorized { t.Fatalf("refresh still works after logout: %d", rec.Code) } } // ---------------------------------------------------------------- tenancy func TestTenantComesFromTheSessionNotTheRequest(t *testing.T) { s, fs := newServer(t) seedUser(fs) sess := login(t, s, "manager@acme.com", "correct horse battery") // A client_id in the query string must not steer the report. do(t, s, "GET", "/api/reports/footfall?client_id=client-rival&site=", sess.Token, nil) if fs.lastReport.ClientID != "client-acme" { t.Fatalf("report ran against %q - a caller-supplied tenant was honoured", fs.lastReport.ClientID) } do(t, s, "GET", "/api/visitors?q=x", sess.Token, nil) if fs.lastReport.ClientID != "client-acme" { t.Fatalf("visitor search ran against %q", fs.lastReport.ClientID) } } func TestProfileWriteUsesThePathIdNotTheBody(t *testing.T) { s, fs := newServer(t) seedUser(fs) sess := login(t, s, "manager@acme.com", "correct horse battery") rec := do(t, s, "PUT", visitorA + "/profile", sess.Token, Profile{ // A body id pointing at somebody else's record. VisitorID: visitorB, FullName: "Asha Menon", Consent: true, }) if rec.Code != http.StatusNoContent { t.Fatalf("save profile returned %d: %s", rec.Code, rec.Body.String()) } if fs.lastProfile.VisitorID != visitorAID { t.Fatalf("wrote to %q - the body overrode the URL", fs.lastProfile.VisitorID) } if fs.lastProfileClient != "client-acme" { t.Fatalf("wrote into tenant %q", fs.lastProfileClient) } // Naming a face is the moment ordinary PII gets attached to a biometric // template, so it has to leave a trace. var found bool for _, a := range fs.audits { if a.Action == "profile.save" && a.EntityID == visitorAID { found = true } } if !found { t.Fatalf("profile save was not audited: %+v", fs.audits) } } func TestStaffCanWriteProfilesButAViewerCannot(t *testing.T) { s, fs := newServer(t) fs.addUser("viewer@acme.com", "correct horse battery", UserRecord{ ID: "u3", ClientID: "client-acme", Role: "viewer", Active: true, }) sess := login(t, s, "viewer@acme.com", "correct horse battery") rec := do(t, s, "PUT", visitorA + "/profile", sess.Token, Profile{FullName: "Someone"}) if rec.Code != http.StatusForbidden { t.Fatalf("an unknown role could write a profile: %d", rec.Code) } } // ---------------------------------------------------------------- reports func TestReportWindowValidation(t *testing.T) { s, fs := newServer(t) seedUser(fs) sess := login(t, s, "manager@acme.com", "correct horse battery") bad := []string{ "/api/reports/footfall?bucket=fortnight", "/api/reports/footfall?tz=Mars/Olympus", "/api/reports/footfall?from=2026-03-01&to=2026-02-01", "/api/reports/footfall?from=not-a-date", "/api/reports/footfall?from=2000-01-01&to=2026-01-01", } for _, path := range bad { if rec := do(t, s, "GET", path, sess.Token, nil); rec.Code != http.StatusBadRequest { t.Errorf("%s returned %d, want 400", path, rec.Code) } } } func TestAnInclusiveEndDateIncludesItsOwnLastDay(t *testing.T) { s, fs := newServer(t) seedUser(fs) sess := login(t, s, "manager@acme.com", "correct horse battery") do(t, s, "GET", "/api/reports/footfall?from=2026-08-01&to=2026-08-07", sess.Token, nil) // "1st to the 7th" means the 7th is in the report. Without the conversion // the range stops at midnight on the 7th and quietly loses a day's trade. want := time.Date(2026, 8, 8, 0, 0, 0, 0, time.UTC) if !fs.lastReport.To.Equal(want) { t.Fatalf("to = %s, want %s (exclusive end of the 7th)", fs.lastReport.To, want) } } func TestReportDefaultsAreSensible(t *testing.T) { s, fs := newServer(t) seedUser(fs) sess := login(t, s, "manager@acme.com", "correct horse battery") do(t, s, "GET", "/api/reports/footfall", sess.Token, nil) if fs.lastReport.Bucket != "day" || fs.lastReport.Timezone != "UTC" { t.Fatalf("defaults: %+v", fs.lastReport) } if d := fs.lastReport.To.Sub(fs.lastReport.From); d < 28*24*time.Hour { t.Fatalf("default window is %s, expected about a month", d) } } func TestFootfallReportCarriesItsOwnConfidence(t *testing.T) { s, fs := newServer(t) seedUser(fs) fs.footfall = []FootfallPoint{{Bucket: "2026-08-01T00:00:00", Visitors: 9, New: 4, Returning: 5}} fs.totals = Totals{UniqueVisitors: 7, Visits: 9, FractionBelowGate: 0.727, WorstSite: "Chennai"} sess := login(t, s, "manager@acme.com", "correct horse battery") rec := do(t, s, "GET", "/api/reports/footfall", sess.Token, nil) var got FootfallReport json.Unmarshal(rec.Body.Bytes(), &got) //nolint:errcheck // A footfall figure from a badly placed camera is wrong in a way the figure // itself cannot show. Measured on Office1 this was 0.727 - 73% of visitors // seen and discarded - while the report looked like a quiet week. if got.FractionBelowGate != 0.727 || got.WorstSite != "Chennai" { t.Fatalf("confidence not reported: %+v", got) } // Unique people over the window, not the sum of the buckets: a customer who // came twice is one person and two bucket-visitors. if got.Total != 7 || got.Visits != 9 { t.Fatalf("total=%d visits=%d, want 7 and 9", got.Total, got.Visits) } } // ---------------------------------------------------------------- purchases func TestPurchaseValidation(t *testing.T) { s, fs := newServer(t) seedUser(fs) sess := login(t, s, "manager@acme.com", "correct horse battery") cases := []struct { name string body PurchaseInput }{ {"no visitor", PurchaseInput{Amount: 100}}, // A refund is a different record with a different meaning. Allowing a // negative here silently deflates the revenue figure the conversion // report is judged by. {"negative amount", PurchaseInput{VisitorID: visitorAID, Amount: -50}}, {"bad currency", PurchaseInput{VisitorID: visitorAID, Amount: 10, Currency: "rupees"}}, } for _, tc := range cases { if rec := do(t, s, "POST", "/api/purchases", sess.Token, tc.body); rec.Code != http.StatusBadRequest { t.Errorf("%s: got %d, want 400", tc.name, rec.Code) } } if rec := do(t, s, "POST", "/api/purchases", sess.Token, PurchaseInput{VisitorID: visitorAID, Amount: 1499.50}); rec.Code != http.StatusNoContent { t.Fatalf("valid purchase returned %d: %s", rec.Code, rec.Body.String()) } if fs.lastPurchase.Currency != "INR" || fs.lastPurchase.Source != "manual" { t.Fatalf("defaults not applied: %+v", fs.lastPurchase) } } func TestAPurchaseForAVisitorWithNoSiteExplainsWhatToDo(t *testing.T) { s, fs := newServer(t) seedUser(fs) fs.purchaseErr = errNoSite sess := login(t, s, "manager@acme.com", "correct horse battery") rec := do(t, s, "POST", "/api/purchases", sess.Token, PurchaseInput{VisitorID: visitorAID, Amount: 10}) if rec.Code != http.StatusBadRequest { t.Fatalf("got %d, want 400", rec.Code) } if !strings.Contains(rec.Body.String(), "site_id") { t.Fatalf("the error does not say how to fix it: %s", rec.Body.String()) } } // ---------------------------------------------------------------- enrolment func TestEnrolmentHandsOutCredentialsExactlyOnce(t *testing.T) { s, fs := newServer(t) code := "ABCDEF-123456" fs.enrolment[hashHex(code)] = Enrolment{ ClientID: "client-acme", SiteID: "site-1", SiteName: "Chennai", SiteSlug: "store1", MQTTUser: "acme.store1", MQTTPass: "broker-secret", } s.Bootstrap = BootstrapConfig{MQTTURL: "tls://mcp.loyaly.ai:8883", CACert: "-----BEGIN"} rec := do(t, s, "POST", "/api/agent/enrol", "", map[string]string{"site_token": "abcdef 123456"}) if rec.Code != http.StatusOK { t.Fatalf("enrol failed: %d %s", rec.Code, rec.Body.String()) } var got map[string]any json.Unmarshal(rec.Body.Bytes(), &got) //nolint:errcheck if got["mqtt_password"] != "broker-secret" || got["mqtt_username"] != "acme.store1" { t.Fatalf("credentials missing: %v", got) } if got["models"] == nil { t.Fatal("models must be an empty list, not null, so the agent can " + "fall back to its own list without special-casing") } // Second use of the same code must fail: it is read aloud, pasted into // chat and photographed. again := do(t, s, "POST", "/api/agent/enrol", "", map[string]string{"site_token": code}) if again.Code != http.StatusUnauthorized { t.Fatalf("a spent code was accepted again: %d", again.Code) } } func TestEnrolmentFailuresAreIndistinguishable(t *testing.T) { s, _ := newServer(t) rec := do(t, s, "POST", "/api/agent/enrol", "", map[string]string{"site_token": "NOPE-NOPE-NOPE"}) if rec.Code != http.StatusUnauthorized { t.Fatalf("got %d", rec.Code) } body := rec.Body.String() // Unknown, expired and already-used must read the same. The difference only // helps somebody guessing codes; the operator's next step is identical. for _, leak := range []string{"expired", "used", "unknown"} { if strings.Contains(strings.ToLower(body), leak) { t.Fatalf("the message says which failure it was: %s", body) } } } // ---------------------------------------------------------------- plumbing func TestUnknownBodyFieldsAreRejected(t *testing.T) { s, fs := newServer(t) seedUser(fs) sess := login(t, s, "manager@acme.com", "correct horse battery") req := httptest.NewRequest("POST", "/api/purchases", strings.NewReader(`{"visitor_id":"`+visitorAID+`","amount":10,"client_id":"client-rival"}`)) req.Header.Set("Authorization", "Bearer "+sess.Token) rec := httptest.NewRecorder() s.Routes().ServeHTTP(rec, req) // Silently ignoring it would let a caller believe the field took effect. if rec.Code != http.StatusBadRequest { t.Fatalf("an unknown field was accepted: %d %s", rec.Code, rec.Body.String()) } } func TestWrongMethodIsNotAConfusing404(t *testing.T) { s, _ := newServer(t) rec := do(t, s, "GET", "/api/auth/login", "", nil) if rec.Code != http.StatusMethodNotAllowed { t.Fatalf("got %d, want 405", rec.Code) } } func TestListEndpointsReturnAnEmptyArrayNotNull(t *testing.T) { s, fs := newServer(t) seedUser(fs) sess := login(t, s, "manager@acme.com", "correct horse battery") for _, path := range []string{"/api/visitors", "/api/sites", visitorA + "/history"} { rec := do(t, s, "GET", path, sess.Token, nil) if got := strings.TrimSpace(rec.Body.String()); got != "[]" { t.Errorf("%s returned %q - a null makes every caller handle "+ "two empty cases", path, got) } } } func TestServerErrorsDoNotLeakInternals(t *testing.T) { s, fs := newServer(t) seedUser(fs) fs.profileErr = errBoom s.Log = nil // errors must not be echoed to the caller either way sess := login(t, s, "manager@acme.com", "correct horse battery") rec := do(t, s, "PUT", visitorA + "/profile", sess.Token, Profile{FullName: "Asha"}) if rec.Code != http.StatusInternalServerError { t.Fatalf("got %d", rec.Code) } if strings.Contains(rec.Body.String(), "relation") || strings.Contains(rec.Body.String(), "boom") { t.Fatalf("database detail reached the client: %s", rec.Body.String()) } } // A shop is one NAT address shared by every member of staff, so the per-IP // limit has to be far looser than the per-account one or a single person // fumbling their password locks the whole floor out. func TestOneStaffMemberLockingThemselvesOutDoesNotLockOutTheShop(t *testing.T) { s, fs := newServer(t) seedUser(fs) fs.addUser("colleague@acme.com", "correct horse battery", UserRecord{ ID: "u9", ClientID: "client-acme", Role: "staff", Active: true, }) s.Throttle = NewThrottle(3, time.Minute) s.IPThrottle = NewThrottle(60, time.Minute) // One person gets their own password wrong until they are locked out. for i := 0; i < 4; i++ { do(t, s, "POST", "/api/auth/login", "", map[string]string{"email": "manager@acme.com", "password": "wrong"}) } locked := do(t, s, "POST", "/api/auth/login", "", map[string]string{"email": "manager@acme.com", "password": "correct horse battery"}) if locked.Code != http.StatusTooManyRequests { t.Fatalf("the account was not locked: %d", locked.Code) } // Their colleague, on the same address, must still be able to work. ok := do(t, s, "POST", "/api/auth/login", "", map[string]string{"email": "colleague@acme.com", "password": "correct horse battery"}) if ok.Code != http.StatusOK { t.Fatalf("a colleague on the same address was locked out too: %d %s", ok.Code, ok.Body.String()) } } // The per-IP limiter still has to exist: without it one guess sprayed across // every address at a site costs an attacker nothing. func TestSprayingManyAddressesFromOneSourceIsStillStopped(t *testing.T) { s, fs := newServer(t) seedUser(fs) s.Throttle = NewThrottle(10, time.Minute) s.IPThrottle = NewThrottle(5, time.Minute) for i := 0; i < 5; i++ { do(t, s, "POST", "/api/auth/login", "", map[string]string{"email": "person" + itoa(i) + "@acme.com", "password": "Summer2026!"}) } blocked := do(t, s, "POST", "/api/auth/login", "", map[string]string{"email": "manager@acme.com", "password": "correct horse battery"}) if blocked.Code != http.StatusTooManyRequests { t.Fatalf("spraying five addresses from one source was not throttled: %d", blocked.Code) } } // A mistyped id must not read as a server fault. `$1::uuid` on a malformed // string is a Postgres cast error, so without a shape check every typo'd URL // answers "something went wrong at our end" and sends an operator looking for // an outage that is not there. func TestMalformedVisitorIdsAreNotFoundNotServerErrors(t *testing.T) { s, fs := newServer(t) seedUser(fs) sess := login(t, s, "manager@acme.com", "correct horse battery") for _, id := range []string{ "not-a-uuid", "98bf7587-4d55-4ae0-99e0", "98bf7587-4d55-4ae0-99e0-de8c05dd3e7z", "%27%3B%20DROP%20TABLE%20visits%3B%20--", } { hist := do(t, s, "GET", "/api/visitors/"+id+"/history", sess.Token, nil) if hist.Code != http.StatusNotFound { t.Errorf("history %q returned %d, want 404", id, hist.Code) } prof := do(t, s, "PUT", "/api/visitors/"+id+"/profile", sess.Token, Profile{FullName: "Asha"}) if prof.Code != http.StatusNotFound { t.Errorf("profile %q returned %d, want 404", id, prof.Code) } } // A well-formed id must still reach the store. ok := do(t, s, "PUT", "/api/visitors/98bf7587-4d55-4ae0-99e0-de8c05dd3e78/profile", sess.Token, Profile{FullName: "Asha"}) if ok.Code != http.StatusNoContent { t.Fatalf("a valid id was rejected: %d %s", ok.Code, ok.Body.String()) } pur := do(t, s, "POST", "/api/purchases", sess.Token, PurchaseInput{VisitorID: "not-a-uuid", Amount: 10}) if pur.Code != http.StatusNotFound { t.Fatalf("purchase with a malformed id returned %d, want 404", pur.Code) } }