package ci
import (
"bytes"
"strings"
"testing"
"gopkg.in/yaml.v3"
)
// PERMISSIONS: ARE READ, NARROWED PER JOB, AND STRIPPED (RFC 0021 §runs).
func TestPermissionsAreReadPerJobAndNarrowed(t *testing.T) {
spec := []byte(`on:
tag: {}
permissions:
contents: write
uv: write
jobs:
build:
runs-on: any
steps:
- run: make
publish:
environment: release
timeout-minutes: 20
permissions:
uv: write
steps:
- run: ./publish
wide:
permissions: {contents: write, wiki: write}
steps:
- run: true
`)
grants, err := permissionsOf(spec)
if err != nil {
t.Fatalf("FAIL: %v", err)
}
want := map[string]JobGrant{
"build": {Letters: "gioy"},
"publish": {Environment: "release", Letters: "y", Timeout: 20},
// A job narrows the workflow's, never widens: wiki is not there.
"wide": {Letters: "gio"},
}
for job, w := range want {
if g := grants[job]; g != w {
t.Errorf("FAIL: %s = %+v; want %+v", job, g, w)
}
}
if len(grants) != len(want) {
t.Errorf("FAIL: grants for %d jobs; want %d", len(grants), len(want))
}
// No key anywhere is contents: read.
grants, err = permissionsOf([]byte("jobs:\n t:\n steps:\n - run: make\n"))
if err != nil || grants["t"].Letters != "go" {
t.Errorf("FAIL: undeclared = %+v, %v; want contents: read (go)", grants, err)
}
// A job's own key stands when the workflow declares none.
grants, err = permissionsOf([]byte("jobs:\n t:\n permissions:\n tickets: write\n steps:\n - run: make\n"))
if err != nil || grants["t"].Letters != "crw" {
t.Errorf("FAIL: job-only = %+v, %v; want crw", grants, err)
}
t.Log("ok: workflow and job permissions read, a job narrows, the default is contents: read")
}
func TestUnknownPermissionsAreNamedWithAPosition(t *testing.T) {
for _, c := range []struct{ spec, line string }{
{"permissions:\n packages: write\njobs:\n t:\n steps:\n - run: x\n", "line 2, column 3"},
{"permissions:\n contents: admin\njobs:\n t:\n steps:\n - run: x\n", "line 2, column 13"},
{"jobs:\n t:\n permissions:\n uv: delete\n steps:\n - run: x\n", "line 4, column 11"},
{"permissions: write-all\njobs:\n t:\n steps:\n - run: x\n", "line 1, column 14"},
} {
_, err := permissionsOf([]byte(c.spec))
if err == nil || !strings.Contains(err.Error(), c.line) || !strings.Contains(err.Error(), "not supported") {
t.Errorf("FAIL: %q gave %v; want an unsupported error at %s", c.spec, err, c.line)
}
}
t.Log("ok: unknown nouns, values and forms are refused with a line and a column")
}
func TestPermissionsAreStrippedKeepingLines(t *testing.T) {
spec := []byte(`permissions:
uv: write
inherit: false
jobs:
publish:
permissions: {uv: write}
steps:
- run: ./publish # permissions: in a comment stays
`)
out := withoutQuarryKeys(spec)
if bytes.Count(out, []byte("\n")) != bytes.Count(spec, []byte("\n")) {
t.Errorf("FAIL: line count changed:\n%s", out)
}
var doc map[string]any
if err := yaml.Unmarshal(out, &doc); err != nil {
t.Fatalf("FAIL: stripped spec does not parse: %v\n%s", err, out)
}
if _, ok := doc["permissions"]; ok {
t.Errorf("FAIL: top-level permissions survived:\n%s", out)
}
if _, ok := doc["inherit"]; ok {
t.Errorf("FAIL: inherit survived:\n%s", out)
}
job := doc["jobs"].(map[string]any)["publish"].(map[string]any)
if _, ok := job["permissions"]; ok {
t.Errorf("FAIL: the job's permissions survived:\n%s", out)
}
if _, ok := job["steps"]; !ok {
t.Errorf("FAIL: the job's steps went with it:\n%s", out)
}
if !bytes.Contains(out, []byte("# permissions: in a comment stays")) {
t.Errorf("FAIL: a step's line was touched:\n%s", out)
}
t.Log("ok: permissions: stripped at both levels, every other line where it was")
}