Files
pad/internal/server/task3866_token_scopes_create_test.go
T
xarmian b901ec629c feat(web): choose an API key's access when creating it, and see every key's access (TASK-3866) (#2216)
* feat(web): choose an API key's access when creating it, and see every key's access (TASK-3866)

Console settings now offers Read and write (["*"], the default, the same
value the store writes when scopes are omitted) or Read only (["read"])
when making a key, and the token list says what each key can do. The
label mirrors tokenScopeAllows, so legacy ""/[] keys read as
unrestricted and null, unparseable or all-unknown values read as No
access, never as full access. One fixture,
internal/server/testdata/token_scope_levels.json, is checked by both the
Go test and the vitest test, so the label cannot drift from the server.

Both create doors (POST /auth/tokens and POST /workspaces/{ws}/tokens)
now refuse scopes the server would not honour, with 400 validation_error
naming the value: non-arrays, the string "null", the legacy [] form,
and unknown values. They used to be stored verbatim, minting dead keys.
Omitted still means ["*"]. Documented in docs/api.md.

Claude-Session: https://claude.ai/code/session_01HsAbPwmN6oyEuQAp6z6NhQ

* test(e2e): mint through the admin session; the suite PAT is refused session_required (TASK-3866)

Claude-Session: https://claude.ai/code/session_01HsAbPwmN6oyEuQAp6z6NhQ

* test: fixture rows where JS trim and Go TrimSpace disagree, and read after write; scopes is always a string (TASK-3866)

Mutants T2, T6 and T8 survived the first set: NBSP is trimmed by both
runtimes, no row put read after write, and the null branch was
unreachable because the API's scopes is a Go string.

Claude-Session: https://claude.ai/code/session_01HsAbPwmN6oyEuQAp6z6NhQ

* fix(server): an explicit "scopes": null is refused, not read as omitted (TASK-3866)

Codex r1 (High): decoded into APITokenCreate, a JSON null scopes field
arrived as "" and minted a full-access key. Both create doors now keep
the field raw (apiTokenCreateRequest), so a null, an array instead of a
string, or a number is refused 400 validation_error naming the value.
Omitted or "" still keeps the ["*"] default.

describeTokenScopes treats any non-string as No access instead of
throwing: the existing settings tests build tokens without scopes, and
the string-only signature from the previous commit crashed them.

Claude-Session: https://claude.ai/code/session_01HsAbPwmN6oyEuQAp6z6NhQ

* fix(server): only an omitted scopes field gets the default; "" is refused (TASK-3866)

Codex r2 M1: an explicit "scopes": "" minted a full-access key. "" is
the stored legacy unrestricted form, refused for the reason [] is. No
caller sends it: APITokenCreate.Scopes has carried omitempty since the
model was added, and the web sends no field or a non-empty one.

The raw field now decodes into a *string, so a null is nil however it
is spaced. Codex r2 H1 (a spaced null bypassing the check) did not
reproduce: encoding/json hands RawMessage the bare literal.

Claude-Session: https://claude.ai/code/session_01HsAbPwmN6oyEuQAp6z6NhQ
2026-10-17 06:13:14 -04:00

128 lines
4.4 KiB
Go

package server
import (
"encoding/json"
"net/http"
"strings"
"testing"
)
// TASK-3866: both doors that mint an API key accept exactly the scope
// values tokenScopeAllows recognizes, store them compact, and refuse
// anything that would mint a key the server then refuses everywhere, or
// one in the legacy unrestricted [] form.
func TestTASK3866_CreateTokenScopes(t *testing.T) {
t.Parallel()
env := setupTokenAuthEnv(t)
doors := map[string]string{
"user": "/api/v1/auth/tokens",
"workspace": "/api/v1/workspaces/" + env.ws.Slug + "/tokens",
}
accepted := []struct {
name string
scopes any // omitted when nil
stored string
}{
{"omitted keeps the default", nil, `["*"]`},
{"read", `["read"]`, `["read"]`},
{"wildcard", `["*"]`, `["*"]`},
{"padded and mixed are stored compact", `[ "read" , "pad:write" ]`, `["read","pad:write"]`},
{"every vocabulary value", `["*","read","write","pad:read","pad:write","pad:admin"]`, `["*","read","write","pad:read","pad:write","pad:admin"]`},
}
// Not strings: the field itself is null, an array or a number. A JSON
// null used to decode as "" and mint a full-access key (Codex r1).
refusedRaw := []struct {
name string
scopes json.RawMessage
named string
}{
{"a JSON null field", json.RawMessage(`null`), "got null"},
{"an array, not a string", json.RawMessage(`["read"]`), `got ["read"]`},
{"a number", json.RawMessage(`1`), "got 1"},
{"a spaced JSON null field", json.RawMessage(" null "), "got null"},
{"an empty string", json.RawMessage(`""`), "omit scopes"},
}
refused := []struct {
name, scopes, named string
}{
{"null", `null`, `"null"`},
{"unparseable", `read`, `"read"`},
{"an object", `{}`, `"{}"`},
{"a bare string", `"read"`, `"\"read\""`},
{"a non-string element", `["read",1]`, `"[\"read\",1]"`},
{"empty array", `[]`, "omit scopes"},
{"padded empty array", `[ ]`, "omit scopes"},
{"unknown value", `["read-only"]`, `"read-only"`},
{"case matters", `["Read"]`, `"Read"`},
{"unknown beside a known one", `["read","docs"]`, `"docs"`},
{"null element", `["read",null]`, `""`},
}
for door, path := range doors {
for i, tc := range accepted {
body := map[string]any{"name": door + "-ok-" + string(rune('a'+i))}
if tc.scopes != nil {
body["scopes"] = tc.scopes
}
rr := doRequestWithBearer(env.srv, "POST", path, env.sessTok, body)
if rr.Code != http.StatusCreated {
t.Errorf("%s door, %s: %d %s, want 201", door, tc.name, rr.Code, rr.Body.String())
continue
}
var got struct {
Scopes string `json:"scopes"`
}
if err := json.Unmarshal(rr.Body.Bytes(), &got); err != nil {
t.Fatal(err)
}
if got.Scopes != tc.stored {
t.Errorf("%s door, %s: stored %q, want %q", door, tc.name, got.Scopes, tc.stored)
}
}
for _, tc := range refused {
rr := doRequestWithBearer(env.srv, "POST", path, env.sessTok,
map[string]any{"name": door + "-bad", "scopes": tc.scopes})
if rr.Code != http.StatusBadRequest || errorCode(t, rr) != "validation_error" {
t.Errorf("%s door, %s: %d %s, want 400 validation_error", door, tc.name, rr.Code, rr.Body.String())
continue
}
if !strings.Contains(rr.Body.String(), jsonEscapedForBody(tc.named)) {
t.Errorf("%s door, %s: message does not name %s: %s", door, tc.name, tc.named, rr.Body.String())
}
}
}
for door, path := range doors {
for _, tc := range refusedRaw {
rr := doRequestWithBearer(env.srv, "POST", path, env.sessTok,
map[string]any{"name": door + "-bad", "scopes": tc.scopes})
if rr.Code != http.StatusBadRequest || errorCode(t, rr) != "validation_error" {
t.Errorf("%s door, %s: %d %s, want 400 validation_error", door, tc.name, rr.Code, rr.Body.String())
continue
}
if !strings.Contains(rr.Body.String(), jsonEscapedForBody(tc.named)) {
t.Errorf("%s door, %s: message does not name %s: %s", door, tc.name, tc.named, rr.Body.String())
}
}
}
// Nothing refused was minted.
tokens, err := env.srv.store.ListUserAPITokens(env.user.ID)
if err != nil {
t.Fatal(err)
}
for _, tok := range tokens {
if strings.HasSuffix(tok.Name, "-bad") {
t.Errorf("a refused create minted %q with scopes %q", tok.Name, tok.Scopes)
}
}
}
// jsonEscapedForBody is s as it appears inside a JSON string value, the
// form the error message takes in the response body.
func jsonEscapedForBody(s string) string {
b, _ := json.Marshal(s)
return string(b[1 : len(b)-1])
}