src/repo/db.sx persists the whole Repo to <store>/dist.db through the vendored SQLite bindings, keeping the load-whole/save-whole call shape. One table per entity; enums as lowercase variant names; list order round-trips via rowid. Enforced uniqueness: apps.slug, channels(app_id, name), tokens.token_hash; lookup indexes on releases(app_id) and artifacts(sha256) (non-unique - identical bytes may ship in several releases). save is DELETE-all + INSERT-all inside BEGIN IMMEDIATE...COMMIT with rollback on failure; every connection sets busy_timeout so the CLI and a running distd interleave safely. A store holding only a pre-SQLite db.json imports once on first load, then the file is renamed db.json.imported; a store with neither starts empty. Consumers gate on db.store_exists instead of probing db.json. The JSON read-back stays for the import path; the entity->json writers stay for distd's /api responses. Tests that parsed db.json directly now assert by querying dist.db through the SQLite bindings; tests/db_import.sx pins the import path; tests/repo_roundtrip.sx pins the SQLite round-trip. make test 22/22.
302 lines
11 KiB
Plaintext
302 lines
11 KiB
Plaintext
// 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.
|
|
// * store round trip — tokens persist field-for-field, and an absent
|
|
// `tokens` member (an import-path 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 empty REQUEST app passes the app gate likewise (an upload is
|
|
// app-agnostic until a release references it).
|
|
check_empty_request_app :: () -> bool {
|
|
return refusal(scoped_token(), "publish", "", "", 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 ──────────────────────────────────────────────────────
|
|
|
|
// An import-path 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: empty request app passes", check_empty_request_app());
|
|
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;
|
|
}
|