// 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 database yet) → exit 0; the JSON // carries the raw secret (`dist_` + 64 hex) exactly once; dist.db // 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; dist.db 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 dist.db unchanged. // 6. A publish into the same store still works (the model with tokens // round-trips through the publish pipeline's load/save). // // Store-side state is asserted by QUERYING `/dist.db` through the // SQLite bindings — independent of the db.sx load path under test. #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"; sq :: #import "vendors/sqlite/sqlite.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; } // The raw bytes of the store database (for "secret appears nowhere" and // byte-identity assertions). db_bytes :: () -> string { b := fs.read_file(path_join(STORE, "dist.db")); process.assert(b != null, "dist.db must exist under the store"); return b!; } // Open the store database read-only (asserts it exists and opens). db_open_ro :: () -> sq.Sqlite { c, oe := sq.Sqlite.open_v2(path_join(STORE, "dist.db"), sq.SQLITE_OPEN_READONLY); process.assert(!oe, "dist.db must open as a SQLite database"); c.busy_timeout(2000); return c; } // One-row TEXT scalar with up to two text bindings ("" = unbound). q_text :: (sql: string, p1: string, p2: string) -> string { c := db_open_ro(); st, pe := c.prepare(sql); process.assert(!pe, concat("prepare must succeed: ", sql)); if p1.len > 0 { st.bind_text(1, p1) catch { process.assert(false, "bind 1 failed"); }; } if p2.len > 0 { st.bind_text(2, p2) catch { process.assert(false, "bind 2 failed"); }; } rc, se := st.step(); process.assert(!se, concat("step must succeed: ", sql)); process.assert(rc == sq.SQLITE_ROW, concat("query must return a row: ", sql)); out := st.column_text(0); st.finalize(); c.close(); return out; } // One-row INTEGER scalar with up to two text bindings ("" = unbound). q_int :: (sql: string, p1: string, p2: string) -> i64 { c := db_open_ro(); st, pe := c.prepare(sql); process.assert(!pe, concat("prepare must succeed: ", sql)); if p1.len > 0 { st.bind_text(1, p1) catch { process.assert(false, "bind 1 failed"); }; } if p2.len > 0 { st.bind_text(2, p2) catch { process.assert(false, "bind 2 failed"); }; } rc, se := st.step(); process.assert(!se, concat("step must succeed: ", sql)); process.assert(rc == sq.SQLITE_ROW, concat("query must return a row: ", sql)); out := st.column_int64(0); st.finalize(); c.close(); return out; } // 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"); stored_hash := q_text("SELECT token_hash FROM tokens WHERE id = ?1", id1, ""); 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"); process.assert(q_int("SELECT COUNT(*) FROM audit_events WHERE actor = ?1 AND action = ?2", "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"); revoked_at := q_int("SELECT revoked_at FROM tokens WHERE id = ?1", id1, ""); process.assert(revoked_at >= created1, "db records revoked_at"); process.assert(list_status(id1, xx arena) == "revoked", "token 1 listed revoked"); process.assert(q_int("SELECT COUNT(*) FROM audit_events WHERE actor = ?1 AND action = ?2", "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 dist.db byte-identical"); print(" failures: unknown + double revoke exit 1, dist.db 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"); process.assert(q_int("SELECT COUNT(*) FROM tokens", "", "") == 2, "publish preserved both tokens"); process.assert(q_int("SELECT COUNT(*) FROM releases", "", "") == 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; }