P4.3: token security at rest + dist token CLI
Subplan 02 Slice 5: Token domain entity (scopes, app/channel scoping, expiry, revocation, last-used) with boundary validation; secrets are dist_<64 hex> drawn from arc4random_buf and only their SHA-256 is persisted. check_token gates revocation > expiry > scope > app/channel; mark_token_used stamps usage for the P4.4 server auth. CLI: dist token create (raw secret shown exactly once; works on a fresh store so CI tokens can predate the first publish), list (lifecycle status, never the secret), revoke (unknown id and double-revoke are distinct errors). Every mutation appends an audit event; tokens joins db.json's persisted arrays, with an absent member loading as empty so older db.json files stay readable. make test 16/16 (new: token_check.sx unit suite, token_ops.sx pinned CLI acceptance).
This commit is contained in:
294
tests/token_check.sx
Normal file
294
tests/token_check.sx
Normal file
@@ -0,0 +1,294 @@
|
||||
// Unit coverage for P4.3's token domain + persistence:
|
||||
//
|
||||
// * check_token — the auth gate the P4.4 server will sit behind: ok path,
|
||||
// refusal order (revoked outranks expired), expiry boundary (now >=
|
||||
// expires_at), scope membership (whole words, multi-scope), app/channel
|
||||
// scope match, and the unscoped-matches-anything rules.
|
||||
// * validate_token — each invalid form rejected with its SPECIFIC tag.
|
||||
// * mark_token_used — last-used stamping through the repo.
|
||||
// * db.json round trip — tokens persist field-for-field, and an absent
|
||||
// `tokens` member (a db.json from before tokens existed) loads as zero
|
||||
// tokens instead of BadShape.
|
||||
#import "modules/std.sx";
|
||||
#import "../src/domain/platform.sx";
|
||||
#import "../src/domain/app.sx";
|
||||
#import "../src/domain/release.sx";
|
||||
#import "../src/domain/artifact.sx";
|
||||
#import "../src/domain/channel.sx";
|
||||
#import "../src/domain/token.sx";
|
||||
#import "../src/domain/audit.sx";
|
||||
#import "../src/domain/validate.sx";
|
||||
#import "../src/repo/repo.sx";
|
||||
db :: #import "../src/repo/db.sx";
|
||||
|
||||
// A fully-scoped, live token: publish-only, bound to acme-app/beta,
|
||||
// expiring at t=1000.
|
||||
scoped_token :: () -> Token {
|
||||
return Token.{
|
||||
id = "tok-aaaaaaaaaaaa",
|
||||
name = "ci-main",
|
||||
token_hash = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
|
||||
scopes = "publish",
|
||||
app_slug = "acme-app",
|
||||
channel = "beta",
|
||||
created_at = 100,
|
||||
expires_at = 1000,
|
||||
last_used_at = 0,
|
||||
revoked_at = 0,
|
||||
};
|
||||
}
|
||||
|
||||
// The error tag check_token raises for these arguments, as a label ("" =
|
||||
// accepted).
|
||||
refusal :: (t: Token, scope: string, app: string, chan: string, now: i64) -> string {
|
||||
tag := "";
|
||||
check_token(t, scope, app, chan, now) catch (e) {
|
||||
if e == error.Revoked { tag = "revoked"; }
|
||||
if e == error.Expired { tag = "expired"; }
|
||||
if e == error.ScopeMissing { tag = "scope"; }
|
||||
if e == error.AppMismatch { tag = "app"; }
|
||||
if e == error.ChannelMismatch { tag = "channel"; }
|
||||
};
|
||||
return tag;
|
||||
}
|
||||
|
||||
check_ok_path :: () -> bool {
|
||||
return refusal(scoped_token(), "publish", "acme-app", "beta", 500) == "";
|
||||
}
|
||||
|
||||
check_revoked :: () -> bool {
|
||||
t := scoped_token();
|
||||
t.revoked_at = 200;
|
||||
return refusal(t, "publish", "acme-app", "beta", 500) == "revoked";
|
||||
}
|
||||
|
||||
// A token both revoked and expired refuses as Revoked — refusal-severity
|
||||
// order.
|
||||
check_revoked_outranks_expired :: () -> bool {
|
||||
t := scoped_token();
|
||||
t.revoked_at = 200;
|
||||
return refusal(t, "publish", "acme-app", "beta", 5000) == "revoked";
|
||||
}
|
||||
|
||||
check_expired_boundary :: () -> bool {
|
||||
t := scoped_token();
|
||||
if refusal(t, "publish", "acme-app", "beta", 999) != "" { return false; } // still live
|
||||
if refusal(t, "publish", "acme-app", "beta", 1000) != "expired" { return false; } // now == expires_at
|
||||
return true;
|
||||
}
|
||||
|
||||
check_never_expires :: () -> bool {
|
||||
t := scoped_token();
|
||||
t.expires_at = 0;
|
||||
return refusal(t, "publish", "acme-app", "beta", 9999999999) == "";
|
||||
}
|
||||
|
||||
check_scope_missing :: () -> bool {
|
||||
return refusal(scoped_token(), "read", "acme-app", "beta", 500) == "scope";
|
||||
}
|
||||
|
||||
check_multi_scope :: () -> bool {
|
||||
t := scoped_token();
|
||||
t.scopes = "publish read";
|
||||
if refusal(t, "read", "acme-app", "beta", 500) != "" { return false; }
|
||||
if refusal(t, "publish", "acme-app", "beta", 500) != "" { return false; }
|
||||
return true;
|
||||
}
|
||||
|
||||
// Scope words match whole words only: "publish" does not grant "pub", and
|
||||
// a "pub" word grants nothing in the vocabulary.
|
||||
check_scope_whole_words :: () -> bool {
|
||||
if has_scope("publish", "pub") { return false; }
|
||||
if has_scope("pub lish", "publish") { return false; }
|
||||
if !has_scope("publish read", "read") { return false; }
|
||||
return true;
|
||||
}
|
||||
|
||||
check_app_mismatch :: () -> bool {
|
||||
return refusal(scoped_token(), "publish", "other-app", "beta", 500) == "app";
|
||||
}
|
||||
|
||||
check_channel_mismatch :: () -> bool {
|
||||
return refusal(scoped_token(), "publish", "acme-app", "stable", 500) == "channel";
|
||||
}
|
||||
|
||||
// An empty REQUEST channel passes the channel gate (the operation has no
|
||||
// channel to constrain).
|
||||
check_empty_request_channel :: () -> bool {
|
||||
return refusal(scoped_token(), "publish", "acme-app", "", 500) == "";
|
||||
}
|
||||
|
||||
// An unscoped token (empty app_slug/channel) authorizes any app/channel.
|
||||
check_unscoped_matches_all :: () -> bool {
|
||||
t := scoped_token();
|
||||
t.app_slug = "";
|
||||
t.channel = "";
|
||||
return refusal(t, "publish", "any-app", "any-channel", 500) == "";
|
||||
}
|
||||
|
||||
// ── validate_token ───────────────────────────────────────────────────
|
||||
|
||||
// The tag validate_token raises for `t`, as a label ("" = accepted).
|
||||
vrefusal :: (t: Token) -> string {
|
||||
tag := "";
|
||||
validate_token(t) catch (e) {
|
||||
if e == error.MissingField { tag = "missing"; }
|
||||
if e == error.BadTokenName { tag = "name"; }
|
||||
if e == error.BadScope { tag = "scope"; }
|
||||
if e == error.BadSlug { tag = "slug"; }
|
||||
if e == error.BadChannelName { tag = "channel"; }
|
||||
if e == error.BadDigest { tag = "digest"; }
|
||||
};
|
||||
return tag;
|
||||
}
|
||||
|
||||
check_validate_accepts :: () -> bool {
|
||||
if vrefusal(scoped_token()) != "" { return false; }
|
||||
u := scoped_token(); // unscoped + multi-scope is also valid
|
||||
u.app_slug = "";
|
||||
u.channel = "";
|
||||
u.scopes = "publish read";
|
||||
return vrefusal(u) == "";
|
||||
}
|
||||
|
||||
check_validate_rejects :: () -> bool {
|
||||
t := scoped_token();
|
||||
t.name = "Bad Name";
|
||||
if vrefusal(t) != "name" { return false; }
|
||||
|
||||
t = scoped_token();
|
||||
t.scopes = "publish admin";
|
||||
if vrefusal(t) != "scope" { return false; }
|
||||
|
||||
t = scoped_token();
|
||||
t.scopes = " ";
|
||||
if vrefusal(t) != "scope" { return false; }
|
||||
|
||||
t = scoped_token();
|
||||
t.app_slug = "-bad-";
|
||||
if vrefusal(t) != "slug" { return false; }
|
||||
|
||||
t = scoped_token();
|
||||
t.channel = "Beta";
|
||||
if vrefusal(t) != "channel" { return false; }
|
||||
|
||||
t = scoped_token();
|
||||
t.token_hash = "deadbeef";
|
||||
if vrefusal(t) != "digest" { return false; }
|
||||
|
||||
t = scoped_token();
|
||||
t.id = "";
|
||||
if vrefusal(t) != "missing" { return false; }
|
||||
return true;
|
||||
}
|
||||
|
||||
// ── repo last-used stamping ──────────────────────────────────────────
|
||||
|
||||
check_mark_used :: () -> bool {
|
||||
repo := Repo.init();
|
||||
repo.create_token(scoped_token());
|
||||
if !tok_mark_used_shim(@repo, "tok-aaaaaaaaaaaa", 777) { return false; }
|
||||
tq := repo.get_token("tok-aaaaaaaaaaaa");
|
||||
if tq == null { return false; }
|
||||
if tq!.last_used_at != 777 { return false; }
|
||||
return !tok_mark_used_shim(@repo, "tok-nope", 778);
|
||||
}
|
||||
|
||||
// Inline twin of token/ops.sx's mark_token_used: importing the ops module
|
||||
// here would drag the publish pipeline (and its libc deps) into a unit
|
||||
// test, so the three-line repo interaction is restated instead.
|
||||
tok_mark_used_shim :: (repo: *Repo, id: string, now: i64) -> bool {
|
||||
tq := repo.get_token(id);
|
||||
if tq == null { return false; }
|
||||
t := tq!;
|
||||
t.last_used_at = now;
|
||||
return repo.update_token(t);
|
||||
}
|
||||
|
||||
// ── persistence ──────────────────────────────────────────────────────
|
||||
|
||||
// db.json with no `tokens` member loads as zero tokens (compat with
|
||||
// pre-token layouts), not BadShape.
|
||||
NO_TOKENS_DB :: "{\"apps\":[],\"releases\":[],\"artifacts\":[],\"channels\":[],\"audit_events\":[]}";
|
||||
|
||||
check_load_without_tokens :: () -> bool {
|
||||
gpa := GPA.init();
|
||||
arena := Arena.init(xx gpa, 65536);
|
||||
defer arena.deinit();
|
||||
repo := Repo.init();
|
||||
ok := true;
|
||||
db.load_into(@repo, NO_TOKENS_DB, xx arena) catch { ok = false; };
|
||||
return ok and repo.tokens.len == 0;
|
||||
}
|
||||
|
||||
// A present `tokens` member with the wrong JSON type is still BadShape.
|
||||
BAD_TOKENS_DB :: "{\"apps\":[],\"releases\":[],\"artifacts\":[],\"channels\":[],\"tokens\":7,\"audit_events\":[]}";
|
||||
|
||||
check_load_bad_tokens_shape :: () -> bool {
|
||||
gpa := GPA.init();
|
||||
arena := Arena.init(xx gpa, 65536);
|
||||
defer arena.deinit();
|
||||
repo := Repo.init();
|
||||
bad := false;
|
||||
db.load_into(@repo, BAD_TOKENS_DB, xx arena) catch (e) { bad = (e == error.BadShape); };
|
||||
return bad;
|
||||
}
|
||||
|
||||
// Token fields survive a save -> load round trip byte-for-byte.
|
||||
check_token_roundtrip :: () -> bool {
|
||||
dir := ".sx-tmp/token_check";
|
||||
repo := Repo.init();
|
||||
src := scoped_token();
|
||||
src.last_used_at = 555;
|
||||
src.revoked_at = 666;
|
||||
repo.create_token(src);
|
||||
werr := false;
|
||||
db.save(@repo, dir) catch { werr = true; };
|
||||
if werr { return false; }
|
||||
|
||||
loaded, le := db.load(dir);
|
||||
if le { return false; }
|
||||
if loaded.tokens.len != 1 { return false; }
|
||||
t := loaded.tokens.items[0];
|
||||
return t.id == src.id and t.name == src.name
|
||||
and t.token_hash == src.token_hash and t.scopes == src.scopes
|
||||
and t.app_slug == src.app_slug and t.channel == src.channel
|
||||
and t.created_at == src.created_at and t.expires_at == src.expires_at
|
||||
and t.last_used_at == 555 and t.revoked_at == 666;
|
||||
}
|
||||
|
||||
run_case :: (label: string, ok: bool) -> i32 {
|
||||
if ok { print(" PASS {}\n", label); return 0; }
|
||||
print(" FAIL {}\n", label);
|
||||
return 1;
|
||||
}
|
||||
|
||||
main :: () -> i32 {
|
||||
failures : i32 = 0;
|
||||
failures += run_case("check: live scoped token accepted", check_ok_path());
|
||||
failures += run_case("check: revoked refused", check_revoked());
|
||||
failures += run_case("check: revoked outranks expired", check_revoked_outranks_expired());
|
||||
failures += run_case("check: expiry boundary now >= expires_at", check_expired_boundary());
|
||||
failures += run_case("check: expires_at 0 never expires", check_never_expires());
|
||||
failures += run_case("check: missing scope refused", check_scope_missing());
|
||||
failures += run_case("check: multi-scope grants each word", check_multi_scope());
|
||||
failures += run_case("check: scope words match whole words", check_scope_whole_words());
|
||||
failures += run_case("check: app scope mismatch refused", check_app_mismatch());
|
||||
failures += run_case("check: channel scope mismatch refused", check_channel_mismatch());
|
||||
failures += run_case("check: empty request channel passes", check_empty_request_channel());
|
||||
failures += run_case("check: unscoped token matches all", check_unscoped_matches_all());
|
||||
failures += run_case("validate: accepts good tokens", check_validate_accepts());
|
||||
failures += run_case("validate: rejects each bad form", check_validate_rejects());
|
||||
failures += run_case("repo: mark-used stamps last_used_at", check_mark_used());
|
||||
failures += run_case("db: absent tokens member loads as empty", check_load_without_tokens());
|
||||
failures += run_case("db: wrong-typed tokens member is BadShape", check_load_bad_tokens_shape());
|
||||
failures += run_case("db: token fields survive a round trip", check_token_roundtrip());
|
||||
|
||||
print("------------------------------------------------\n");
|
||||
if failures == 0 {
|
||||
print("token_check: ALL CASES PASS\n");
|
||||
return 0;
|
||||
}
|
||||
print("token_check: {} CASE(S) FAILED\n", failures);
|
||||
return 1;
|
||||
}
|
||||
221
tests/token_ops.sx
Normal file
221
tests/token_ops.sx
Normal file
@@ -0,0 +1,221 @@
|
||||
// Pinned acceptance for P4.3 — `dist token create` / `list` / `revoke`
|
||||
// over the persisted store (subplan 02 Slice 5).
|
||||
//
|
||||
// Drives the BUILT `build/dist` binary through the slice contract:
|
||||
//
|
||||
// 1. `token create` on a FRESH store (no db.json yet) → exit 0; the JSON
|
||||
// carries the raw secret (`dist_` + 64 hex) exactly once; db.json
|
||||
// stores ONLY sha256(secret) — the secret's bytes appear nowhere under
|
||||
// the store — plus a `token.create` audit event.
|
||||
// 2. A second token with `--scope read --expires-in 60` → expires_at is
|
||||
// created_at + 60.
|
||||
// 3. `token list` → both tokens "active"; no secret in the output.
|
||||
// 4. `token revoke` → exit 0; db.json gains revoked_at + a `token.revoke`
|
||||
// audit event; list now shows "revoked".
|
||||
// 5. Revoking an UNKNOWN id → exit 1 + `token.unknown`; revoking AGAIN →
|
||||
// exit 1 + `token.already_revoked`; both leave db.json unchanged.
|
||||
// 6. A publish into the same store still works (the model with tokens
|
||||
// round-trips through the publish pipeline's load/save).
|
||||
#import "modules/std.sx";
|
||||
#import "modules/std/json.sx";
|
||||
process :: #import "modules/std/process.sx";
|
||||
fs :: #import "modules/std/fs.sx";
|
||||
hash :: #import "modules/std/hash.sx";
|
||||
|
||||
STORE :: ".sx-tmp/token_ops";
|
||||
MDIR :: ".sx-tmp/token_ops_m";
|
||||
|
||||
MANIFEST :: "{\"app\":\"acme-app\",\"version\":\"1.2.3\",\"channel\":\"beta\",\"artifacts\":[{\"platform\":\"android_apk\",\"path\":\"../../examples/fixtures/acme-1.2.3-android.apk\"}]}";
|
||||
|
||||
get :: (o: Object, key: string) -> Value {
|
||||
i := 0;
|
||||
while i < o.len {
|
||||
if o.items[i].key == key { return o.items[i].val; }
|
||||
i += 1;
|
||||
}
|
||||
process.assert(false, concat("missing json key: ", key));
|
||||
dummy : Value = .null_;
|
||||
return dummy;
|
||||
}
|
||||
get_str :: (o: Object, key: string) -> string { return get(o, key).str; }
|
||||
get_int :: (o: Object, key: string) -> i64 { return get(o, key).int_; }
|
||||
get_obj :: (o: Object, key: string) -> Object { return get(o, key).object; }
|
||||
get_arr :: (o: Object, key: string) -> Array { return get(o, key).array; }
|
||||
|
||||
contains :: (haystack: string, needle: string) -> bool {
|
||||
if needle.len == 0 or needle.len > haystack.len { return false; }
|
||||
i := 0;
|
||||
while i + needle.len <= haystack.len {
|
||||
j := 0;
|
||||
while j < needle.len and haystack[i + j] == needle[j] { j += 1; }
|
||||
if j == needle.len { return true; }
|
||||
i += 1;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
count_actor_action :: (events: Array, actor: string, action: string) -> i64 {
|
||||
c : i64 = 0;
|
||||
i := 0;
|
||||
while i < events.len {
|
||||
eo := events.items[i].object;
|
||||
if get_str(eo, "actor") == actor and get_str(eo, "action") == action { c += 1; }
|
||||
i += 1;
|
||||
}
|
||||
return c;
|
||||
}
|
||||
|
||||
db_bytes :: () -> string {
|
||||
b := fs.read_file(path_join(STORE, "db.json"));
|
||||
process.assert(b != null, "db.json must exist under the store");
|
||||
return b!;
|
||||
}
|
||||
|
||||
load_db :: (scratch: Allocator) -> Object {
|
||||
dv, de := parse(db_bytes(), scratch);
|
||||
if de { process.assert(false, "db.json must be valid JSON"); dummy : Object = .{}; return dummy; }
|
||||
return dv.object;
|
||||
}
|
||||
|
||||
// The db.json token entry with this id.
|
||||
db_token :: (id: string, scratch: Allocator) -> Object {
|
||||
toks := get_arr(load_db(scratch), "tokens");
|
||||
i := 0;
|
||||
while i < toks.len {
|
||||
to := toks.items[i].object;
|
||||
if get_str(to, "id") == id { return to; }
|
||||
i += 1;
|
||||
}
|
||||
process.assert(false, concat("token not in db.json: ", id));
|
||||
dummy : Object = .{};
|
||||
return dummy;
|
||||
}
|
||||
|
||||
// The "status" of token `id` in `token list --json` output.
|
||||
list_status :: (id: string, scratch: Allocator) -> string {
|
||||
rl := process.run("build/dist token list --local-store .sx-tmp/token_ops --json 2>/dev/null");
|
||||
process.assert(rl != null and rl!.exit_code == 0, "token list must exit 0");
|
||||
lv, le := parse(rl!.stdout, scratch);
|
||||
if le { process.assert(false, "list stdout must be one JSON object"); dummy : Object = .{}; return ""; }
|
||||
toks := get_arr(lv.object, "tokens");
|
||||
i := 0;
|
||||
while i < toks.len {
|
||||
to := toks.items[i].object;
|
||||
if get_str(to, "id") == id { return get_str(to, "status"); }
|
||||
i += 1;
|
||||
}
|
||||
process.assert(false, concat("token not in list output: ", id));
|
||||
return "";
|
||||
}
|
||||
|
||||
revoke_cmd :: (id: string) -> string {
|
||||
c := concat("build/dist token revoke --id ", id);
|
||||
return concat(c, " --local-store .sx-tmp/token_ops --json 2>/dev/null");
|
||||
}
|
||||
|
||||
main :: () -> i32 {
|
||||
gpa := GPA.init();
|
||||
arena := Arena.init(xx gpa, 1 << 20);
|
||||
defer arena.deinit();
|
||||
|
||||
process.run(concat("rm -rf ", STORE));
|
||||
process.run(concat("rm -rf ", MDIR));
|
||||
process.run(concat("mkdir -p ", MDIR));
|
||||
process.run(concat(concat(concat("printf '%s' '", MANIFEST), "' > "), path_join(MDIR, "m.json")));
|
||||
|
||||
// ── 1. Create on a fresh store: secret once, only the hash at rest ─
|
||||
r1 := process.run("build/dist token create --name ci-main --app acme-app --channel beta --local-store .sx-tmp/token_ops --json 2>/dev/null");
|
||||
process.assert(r1 != null and r1!.exit_code == 0, "token create must exit 0 on a fresh store");
|
||||
v1, e1 := parse(r1!.stdout, xx arena);
|
||||
if e1 { process.assert(false, "create stdout must be one JSON object"); return 1; }
|
||||
o1 := v1.object;
|
||||
process.assert(get_str(o1, "status") == "created", "create json status");
|
||||
t1 := get_obj(o1, "token");
|
||||
id1 := get_str(t1, "id");
|
||||
secret := get_str(t1, "secret");
|
||||
created1 := get_int(t1, "created_at");
|
||||
process.assert(contains(id1, "tok-") and id1.len == 16, "id is tok-<12 hex>");
|
||||
process.assert(secret.len == 69 and contains(secret, "dist_"), "secret is dist_<64 hex>");
|
||||
process.assert(get_str(t1, "scopes") == "publish", "default scope is publish");
|
||||
process.assert(get_int(t1, "expires_at") == 0, "default expiry is never");
|
||||
|
||||
dt1 := db_token(id1, xx arena);
|
||||
stored_hash := get_str(dt1, "token_hash");
|
||||
d := hash.sha256_hex(secret);
|
||||
expect_hash := string.{ ptr = @d[0], len = 64 };
|
||||
process.assert(stored_hash == expect_hash, "db stores sha256(secret) as token_hash");
|
||||
process.assert(!contains(db_bytes(), secret), "the raw secret appears nowhere under the store");
|
||||
db1 := load_db(xx arena);
|
||||
process.assert(count_actor_action(get_arr(db1, "audit_events"), "cli", "token.create") == 1,
|
||||
"create recorded one cli token.create audit event");
|
||||
print(" create: secret shown once, only sha256 at rest, audited\n");
|
||||
|
||||
// ── 2. Second token: read scope, 60s expiry ──────────────────────
|
||||
r2 := process.run("build/dist token create --name reader --scope read --expires-in 60 --local-store .sx-tmp/token_ops --json 2>/dev/null");
|
||||
process.assert(r2 != null and r2!.exit_code == 0, "second token create must exit 0");
|
||||
v2, e2 := parse(r2!.stdout, xx arena);
|
||||
if e2 { process.assert(false, "create2 stdout must be one JSON object"); return 1; }
|
||||
t2 := get_obj(v2.object, "token");
|
||||
id2 := get_str(t2, "id");
|
||||
process.assert(get_str(t2, "scopes") == "read", "second token carries --scope read");
|
||||
process.assert(get_int(t2, "expires_at") == get_int(t2, "created_at") + 60,
|
||||
"expires_at is created_at + --expires-in");
|
||||
print(" create: scope + expiry flags honored\n");
|
||||
|
||||
// ── 3. List: both active, no secret anywhere ─────────────────────
|
||||
rl := process.run("build/dist token list --local-store .sx-tmp/token_ops --json 2>/dev/null");
|
||||
process.assert(rl != null and rl!.exit_code == 0, "token list must exit 0");
|
||||
process.assert(!contains(rl!.stdout, secret), "list output never carries a secret");
|
||||
process.assert(!contains(rl!.stdout, stored_hash), "list output never carries a hash");
|
||||
process.assert(list_status(id1, xx arena) == "active", "token 1 listed active");
|
||||
process.assert(list_status(id2, xx arena) == "active", "token 2 listed active");
|
||||
print(" list: two active tokens, no secret/hash in output\n");
|
||||
|
||||
// ── 4. Revoke: status flips, audited ─────────────────────────────
|
||||
rr := process.run(revoke_cmd(id1));
|
||||
process.assert(rr != null and rr!.exit_code == 0, "revoke must exit 0");
|
||||
rv, re := parse(rr!.stdout, xx arena);
|
||||
if re { process.assert(false, "revoke stdout must be one JSON object"); return 1; }
|
||||
process.assert(get_str(rv.object, "status") == "revoked", "revoke json status");
|
||||
dt1b := db_token(id1, xx arena);
|
||||
revoked_at := get_int(dt1b, "revoked_at");
|
||||
process.assert(revoked_at >= created1, "db records revoked_at");
|
||||
process.assert(list_status(id1, xx arena) == "revoked", "token 1 listed revoked");
|
||||
db4 := load_db(xx arena);
|
||||
process.assert(count_actor_action(get_arr(db4, "audit_events"), "cli", "token.revoke") == 1,
|
||||
"revoke recorded one cli token.revoke audit event");
|
||||
print(" revoke: status flips, audited\n");
|
||||
|
||||
// ── 5. Failure paths leave db.json unchanged ─────────────────────
|
||||
before := db_bytes();
|
||||
rn := process.run(revoke_cmd("tok-nope"));
|
||||
process.assert(rn != null and rn!.exit_code == 1, "revoking an unknown id must exit 1");
|
||||
nv, ne := parse(rn!.stdout, xx arena);
|
||||
if ne { process.assert(false, "unknown-revoke stdout must be one JSON object"); return 1; }
|
||||
process.assert(get_str(get_obj(nv.object, "error"), "code") == "token.unknown",
|
||||
"unknown-revoke json names token.unknown");
|
||||
|
||||
ra := process.run(revoke_cmd(id1));
|
||||
process.assert(ra != null and ra!.exit_code == 1, "double revoke must exit 1");
|
||||
av, ae := parse(ra!.stdout, xx arena);
|
||||
if ae { process.assert(false, "double-revoke stdout must be one JSON object"); return 1; }
|
||||
process.assert(get_str(get_obj(av.object, "error"), "code") == "token.already_revoked",
|
||||
"double-revoke json names token.already_revoked");
|
||||
process.assert(db_bytes() == before, "failed revokes leave db.json byte-identical");
|
||||
print(" failures: unknown + double revoke exit 1, db.json untouched\n");
|
||||
|
||||
// ── 6. Publish coexists with tokens in the same store ────────────
|
||||
pc := concat("build/dist ci publish --manifest ", path_join(MDIR, "m.json"));
|
||||
pc = concat(pc, " --local-store .sx-tmp/token_ops --json 2>/dev/null");
|
||||
rp := process.run(pc);
|
||||
process.assert(rp != null and rp!.exit_code == 0, "publish into a tokened store must exit 0");
|
||||
db6 := load_db(xx arena);
|
||||
process.assert(get_arr(db6, "tokens").len == 2, "publish preserved both tokens");
|
||||
process.assert(get_arr(db6, "releases").len == 1, "publish landed its release");
|
||||
print(" publish: round-trips the model with tokens intact\n");
|
||||
|
||||
process.run(concat("rm -rf ", STORE));
|
||||
process.run(concat("rm -rf ", MDIR));
|
||||
print("token_ops: ALL CASES PASS\n");
|
||||
return 0;
|
||||
}
|
||||
Reference in New Issue
Block a user