memory.Held and memory.Forget were written the day the store was and had no route, so the honest answer to "show me what you hold about this candidate" was "a developer runs a query". That is not a compliance posture; it is a promise with no mechanism, and a subject access request has a statutory clock. GET /api/v1/memories?subject=candidate&id=… what is held DELETE /api/v1/memories?subject=candidate&id=… erase it Each memory comes back with its author, so "an agent inferred this" and "a recruiter wrote this" stay distinguishable — a response that flattened them would be misleading in the one place it matters most. THE AUTHORISATION IS THE WHOLE DESIGN, AND THE FIRST VERSION WAS WRONG. Gating a read of candidate memories on `list` of job-applications looked right and was not: a talent may list applications because every other read path scopes them to their OWN rows, and this route has no row scoping — so the check passed and the response would have carried the whole organisation's memories about everybody. A test caught it before it shipped. A personal memory now requires `delete` on the record it concerns, for reading as much as for erasing. Deleting somebody's application is an administrative capability and nothing scopes it to self, which makes it the honest proxy for "may act on other people's records here". No new permission, and it cannot drift from the record's own policy. Workspace facts have no personal subject and stay at `list`. Erasing workspace memories refuses without an explicit id: "erase everything" is a plausible thing to want and a catastrophic thing to do by a mistyped query string, so it is not reachable by omission. An erasure is logged at Info with who asked and when — the row itself is redacted, so the log is the durable record that it happened. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
109 lines
3.8 KiB
Go
109 lines
3.8 KiB
Go
package httpserver_test
|
|
|
|
import (
|
|
"net/http"
|
|
"testing"
|
|
)
|
|
|
|
/* Subject access and erasure for long-term memory.
|
|
The interesting assertions are the refusals: a route that lists what an
|
|
agent inferred about a named person is a disclosure route, and it has to be
|
|
gated on the same permission as the record itself. */
|
|
|
|
func TestMemoriesNeedASubject(t *testing.T) {
|
|
a := newAPI(t)
|
|
for _, path := range []string{
|
|
"/api/v1/memories",
|
|
"/api/v1/memories?subject=everything",
|
|
} {
|
|
/* 422, which is this API's code for a well-formed request that cannot
|
|
be acted on — see domain.Validation. */
|
|
if res := a.do(http.MethodGet, path, nil); res.code != http.StatusUnprocessableEntity {
|
|
t.Errorf("GET %s = %d, want 422", path, res.code)
|
|
}
|
|
}
|
|
}
|
|
|
|
// A memory about a person that names no person cannot be produced for them,
|
|
// so asking for "all candidate memories" is a mistake rather than a query.
|
|
func TestAPersonalSubjectNeedsAnId(t *testing.T) {
|
|
a := newAPI(t)
|
|
res := a.do(http.MethodGet, "/api/v1/memories?subject=candidate", nil)
|
|
if res.code != http.StatusUnprocessableEntity {
|
|
t.Errorf("got %d, want 422", res.code)
|
|
}
|
|
}
|
|
|
|
// Nothing held yet is an empty list, not an error: "we hold nothing about this
|
|
// person" is a valid and important answer to a subject access request.
|
|
func TestHoldingNothingIsAnEmptyList(t *testing.T) {
|
|
a := newAPI(t)
|
|
res := a.do(http.MethodGet,
|
|
"/api/v1/memories?subject=candidate&id=11111111-1111-1111-1111-111111111111", nil)
|
|
if res.code != http.StatusOK {
|
|
t.Fatalf("got %d, want 200: %v", res.code, res.body)
|
|
}
|
|
data, ok := res.body["data"].([]any)
|
|
if !ok && res.body["data"] != nil {
|
|
t.Fatalf("data is not a list: %#v", res.body["data"])
|
|
}
|
|
if len(data) != 0 {
|
|
t.Errorf("got %d memories, want none", len(data))
|
|
}
|
|
}
|
|
|
|
// THE DISCLOSURE BOUNDARY. Somebody who may not read a candidate's
|
|
// application must not be able to read what an agent inferred about them —
|
|
// that is the same disclosure by another route.
|
|
func TestAReaderWithoutTheRecordCannotReadItsMemories(t *testing.T) {
|
|
a := newAPI(t)
|
|
talent := signInAs(t, a.handler, a.h.Pool, a.orgID, "talent", "talent-mem@example.test", "talent")
|
|
|
|
res := a.as(talent, http.MethodGet,
|
|
"/api/v1/memories?subject=candidate&id=11111111-1111-1111-1111-111111111111", nil)
|
|
if res.code != http.StatusForbidden {
|
|
t.Errorf("got %d, want 403 — a talent read another person's inferred memories", res.code)
|
|
}
|
|
}
|
|
|
|
func TestAReaderWithoutDeleteCannotErase(t *testing.T) {
|
|
a := newAPI(t)
|
|
talent := signInAs(t, a.handler, a.h.Pool, a.orgID, "talent", "talent-del@example.test", "talent")
|
|
|
|
res := a.as(talent, http.MethodDelete,
|
|
"/api/v1/memories?subject=candidate&id=11111111-1111-1111-1111-111111111111", nil)
|
|
if res.code != http.StatusForbidden {
|
|
t.Errorf("got %d, want 403", res.code)
|
|
}
|
|
}
|
|
|
|
// "Erase every workspace memory" is a plausible thing to want and a
|
|
// catastrophic thing to do by a mistyped query string.
|
|
func TestErasingWorkspaceMemoriesNeedsAnExplicitId(t *testing.T) {
|
|
a := newAPI(t)
|
|
res := a.do(http.MethodDelete, "/api/v1/memories?subject=workspace", nil)
|
|
if res.code != http.StatusUnprocessableEntity {
|
|
t.Errorf("got %d, want 422 — a bare delete reached the whole workspace", res.code)
|
|
}
|
|
}
|
|
|
|
// An erasure against nothing is still a successful erasure: the caller asked
|
|
// for a state, and the state holds.
|
|
func TestErasingNothingSucceeds(t *testing.T) {
|
|
a := newAPI(t)
|
|
res := a.do(http.MethodDelete,
|
|
"/api/v1/memories?subject=candidate&id=11111111-1111-1111-1111-111111111111", nil)
|
|
if res.code != http.StatusOK {
|
|
t.Fatalf("got %d, want 200: %v", res.code, res.body)
|
|
}
|
|
}
|
|
|
|
func TestMemoriesRefuseAnAnonymousCaller(t *testing.T) {
|
|
a := newAPI(t)
|
|
res := a.doAnon(http.MethodGet,
|
|
"/api/v1/memories?subject=candidate&id=11111111-1111-1111-1111-111111111111", nil)
|
|
if res.code != http.StatusUnauthorized {
|
|
t.Errorf("got %d, want 401", res.code)
|
|
}
|
|
}
|