@powerhousedao/switchboard 6.1.0-staging.0 → 6.2.0-dev.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.tsbuild/test/attachments/auth.test.d.ts +2 -0
- package/.tsbuild/test/attachments/auth.test.d.ts.map +1 -0
- package/.tsbuild/test/attachments/index.test.d.ts +2 -0
- package/.tsbuild/test/attachments/index.test.d.ts.map +1 -0
- package/.tsbuild/test/attachments/routes-integration.test.d.ts +2 -0
- package/.tsbuild/test/attachments/routes-integration.test.d.ts.map +1 -0
- package/.tsbuild/test/attachments/routes.test.d.ts +2 -0
- package/.tsbuild/test/attachments/routes.test.d.ts.map +1 -0
- package/.tsbuild/test/attachments/service-config.test.d.ts +2 -0
- package/.tsbuild/test/attachments/service-config.test.d.ts.map +1 -0
- package/.tsbuild/test/metrics.test.d.ts +2 -0
- package/.tsbuild/test/metrics.test.d.ts.map +1 -0
- package/.tsbuild/test/pglite-dialect.test.d.ts +2 -0
- package/.tsbuild/test/pglite-dialect.test.d.ts.map +1 -0
- package/.tsbuild/test/pglite-version.test.d.ts +2 -0
- package/.tsbuild/test/pglite-version.test.d.ts.map +1 -0
- package/.tsbuild/tsconfig.tsbuildinfo +1 -0
- package/.tsbuild/tsdown.config.d.ts +3 -0
- package/.tsbuild/tsdown.config.d.ts.map +1 -0
- package/.tsbuild/vitest.config.d.ts +3 -0
- package/.tsbuild/vitest.config.d.ts.map +1 -0
- package/CHANGELOG.md +171 -1
- package/dist/index.mjs +7 -7
- package/dist/index.mjs.map +1 -1
- package/dist/{server-bMFA4VKj.mjs → server-DCeVXVeJ.mjs} +96 -13
- package/dist/server-DCeVXVeJ.mjs.map +1 -0
- package/dist/server.d.mts +15 -6
- package/dist/server.d.mts.map +1 -1
- package/dist/server.mjs +5 -5
- package/dist/{utils-BVNg1DRI.mjs → utils-Baw7rThP.mjs} +3 -4
- package/dist/utils-Baw7rThP.mjs.map +1 -0
- package/dist/utils.d.mts.map +1 -1
- package/dist/utils.mjs +3 -3
- package/package.json +25 -25
- package/test/attachments/auth.test.ts +36 -25
- package/test/attachments/index.test.ts +14 -10
- package/test/attachments/routes.test.ts +421 -0
- package/test/attachments/service-config.test.ts +170 -0
- package/dist/server-bMFA4VKj.mjs.map +0 -1
- package/dist/utils-BVNg1DRI.mjs.map +0 -1
|
@@ -701,7 +701,428 @@ describe("attachment routes", () => {
|
|
|
701
701
|
expect(h1).toBe(h2);
|
|
702
702
|
});
|
|
703
703
|
|
|
704
|
+
describe("hash-first reserve: 409 already_exists", () => {
|
|
705
|
+
it("returns 409 { error: already_exists, ref } when hash is already available", async () => {
|
|
706
|
+
// Upload a file first using the legacy flow to populate the store.
|
|
707
|
+
const reserveHandler = makeReserveHandler(attachments);
|
|
708
|
+
const uploadHandler = makeUploadHandler(attachments);
|
|
709
|
+
|
|
710
|
+
const legacyReserveReq = makeReq({
|
|
711
|
+
method: "POST",
|
|
712
|
+
body: JSON.stringify({ mimeType: "text/plain", fileName: "dup.txt" }),
|
|
713
|
+
});
|
|
714
|
+
const legacyReserveRes = makeRes();
|
|
715
|
+
await reserveHandler(legacyReserveReq, legacyReserveRes);
|
|
716
|
+
await waitFor(legacyReserveRes);
|
|
717
|
+
const { reservationId: legacyId } = JSON.parse(
|
|
718
|
+
legacyReserveRes._body.toString("utf8"),
|
|
719
|
+
) as { reservationId: string };
|
|
720
|
+
|
|
721
|
+
const payload = "deduplicated content";
|
|
722
|
+
const uploadReq = makeReq({
|
|
723
|
+
method: "PUT",
|
|
724
|
+
params: { reservationId: legacyId },
|
|
725
|
+
body: payload,
|
|
726
|
+
});
|
|
727
|
+
const uploadRes = makeRes();
|
|
728
|
+
await uploadHandler(uploadReq, uploadRes);
|
|
729
|
+
await waitFor(uploadRes);
|
|
730
|
+
const { hash: existingHash, ref: existingRef } = JSON.parse(
|
|
731
|
+
uploadRes._body.toString("utf8"),
|
|
732
|
+
) as { hash: string; ref: string };
|
|
733
|
+
|
|
734
|
+
// Now reserve with clientHash pointing at the existing content.
|
|
735
|
+
const hashFirstReq = makeReq({
|
|
736
|
+
method: "POST",
|
|
737
|
+
body: JSON.stringify({
|
|
738
|
+
mimeType: "text/plain",
|
|
739
|
+
fileName: "dup.txt",
|
|
740
|
+
clientHash: existingHash,
|
|
741
|
+
sizeBytes: Buffer.byteLength(payload, "utf8"),
|
|
742
|
+
}),
|
|
743
|
+
});
|
|
744
|
+
const hashFirstRes = makeRes();
|
|
745
|
+
await reserveHandler(hashFirstReq, hashFirstRes);
|
|
746
|
+
await waitFor(hashFirstRes);
|
|
747
|
+
|
|
748
|
+
expect(hashFirstRes.statusCode).toBe(409);
|
|
749
|
+
const body = JSON.parse(hashFirstRes._body.toString("utf8")) as {
|
|
750
|
+
error: string;
|
|
751
|
+
ref: string;
|
|
752
|
+
reservationId?: string;
|
|
753
|
+
};
|
|
754
|
+
expect(body.error).toBe("already_exists");
|
|
755
|
+
expect(body.ref).toBe(existingRef);
|
|
756
|
+
// The 409 body must not expose another reservation's ID.
|
|
757
|
+
expect("reservationId" in body).toBe(false);
|
|
758
|
+
});
|
|
759
|
+
|
|
760
|
+
it("reserve 201 body includes reservationId, ref, and expiresAtUtc in hash-first mode", async () => {
|
|
761
|
+
const { createHash } = await import("node:crypto");
|
|
762
|
+
const payload = "some content for hash-first";
|
|
763
|
+
const hash = createHash("sha256").update(payload, "utf8").digest("hex");
|
|
764
|
+
|
|
765
|
+
const handler = makeReserveHandler(attachments);
|
|
766
|
+
const req = makeReq({
|
|
767
|
+
method: "POST",
|
|
768
|
+
body: JSON.stringify({
|
|
769
|
+
mimeType: "text/plain",
|
|
770
|
+
fileName: "hashfirst.txt",
|
|
771
|
+
clientHash: hash,
|
|
772
|
+
sizeBytes: Buffer.byteLength(payload, "utf8"),
|
|
773
|
+
}),
|
|
774
|
+
});
|
|
775
|
+
const res = makeRes();
|
|
776
|
+
await handler(req, res);
|
|
777
|
+
await waitFor(res);
|
|
778
|
+
|
|
779
|
+
expect(res.statusCode).toBe(201);
|
|
780
|
+
const body = JSON.parse(res._body.toString("utf8")) as {
|
|
781
|
+
reservationId: string;
|
|
782
|
+
ref: string | null;
|
|
783
|
+
expiresAtUtc: string;
|
|
784
|
+
};
|
|
785
|
+
expect(typeof body.reservationId).toBe("string");
|
|
786
|
+
expect(body.ref).toBe(`attachment://v1:${hash}`);
|
|
787
|
+
expect(typeof body.expiresAtUtc).toBe("string");
|
|
788
|
+
expect(new Date(body.expiresAtUtc).toString()).not.toBe("Invalid Date");
|
|
789
|
+
});
|
|
790
|
+
});
|
|
791
|
+
|
|
792
|
+
describe("hash-first upload: 422 responses", () => {
|
|
793
|
+
it("returns 422 { error: hash_mismatch, claimed, actual } when uploaded bytes hash differently", async () => {
|
|
794
|
+
const { createHash } = await import("node:crypto");
|
|
795
|
+
|
|
796
|
+
// claimedContent and wrongContent must be the same byte length so that
|
|
797
|
+
// sizeBytes matches and the server reaches the hash check (not size check).
|
|
798
|
+
const claimedContent = "AAAAAAAAAAAAAAAA"; // 16 bytes
|
|
799
|
+
const wrongContent = "BBBBBBBBBBBBBBBB"; // 16 bytes, different hash
|
|
800
|
+
expect(Buffer.byteLength(claimedContent, "utf8")).toBe(
|
|
801
|
+
Buffer.byteLength(wrongContent, "utf8"),
|
|
802
|
+
);
|
|
803
|
+
|
|
804
|
+
const claimedHash = createHash("sha256")
|
|
805
|
+
.update(claimedContent, "utf8")
|
|
806
|
+
.digest("hex");
|
|
807
|
+
const sizeBytes = Buffer.byteLength(claimedContent, "utf8");
|
|
808
|
+
|
|
809
|
+
// Reserve with the claimed hash.
|
|
810
|
+
const reserveHandler = makeReserveHandler(attachments);
|
|
811
|
+
const reserveReq = makeReq({
|
|
812
|
+
method: "POST",
|
|
813
|
+
body: JSON.stringify({
|
|
814
|
+
mimeType: "text/plain",
|
|
815
|
+
fileName: "mismatch.txt",
|
|
816
|
+
clientHash: claimedHash,
|
|
817
|
+
sizeBytes,
|
|
818
|
+
}),
|
|
819
|
+
});
|
|
820
|
+
const reserveRes = makeRes();
|
|
821
|
+
await reserveHandler(reserveReq, reserveRes);
|
|
822
|
+
await waitFor(reserveRes);
|
|
823
|
+
expect(reserveRes.statusCode).toBe(201);
|
|
824
|
+
const { reservationId } = JSON.parse(
|
|
825
|
+
reserveRes._body.toString("utf8"),
|
|
826
|
+
) as { reservationId: string };
|
|
827
|
+
|
|
828
|
+
// Upload wrongContent (same length, different hash).
|
|
829
|
+
const uploadHandler = makeUploadHandler(attachments);
|
|
830
|
+
const uploadReq = makeReq({
|
|
831
|
+
method: "PUT",
|
|
832
|
+
params: { reservationId },
|
|
833
|
+
body: wrongContent,
|
|
834
|
+
});
|
|
835
|
+
const uploadRes = makeRes();
|
|
836
|
+
await uploadHandler(uploadReq, uploadRes);
|
|
837
|
+
await waitFor(uploadRes);
|
|
838
|
+
|
|
839
|
+
expect(uploadRes.statusCode).toBe(422);
|
|
840
|
+
const body = JSON.parse(uploadRes._body.toString("utf8")) as {
|
|
841
|
+
error: string;
|
|
842
|
+
claimed: string;
|
|
843
|
+
actual: string;
|
|
844
|
+
};
|
|
845
|
+
expect(body.error).toBe("hash_mismatch");
|
|
846
|
+
expect(body.claimed).toBe(claimedHash);
|
|
847
|
+
expect(typeof body.actual).toBe("string");
|
|
848
|
+
expect(body.actual).not.toBe(claimedHash);
|
|
849
|
+
});
|
|
850
|
+
|
|
851
|
+
it("returns 422 { error: size_mismatch, declared, actual } when byte count differs from declared sizeBytes", async () => {
|
|
852
|
+
const { createHash } = await import("node:crypto");
|
|
853
|
+
const actualContent = "short";
|
|
854
|
+
const declaredSize = 9999;
|
|
855
|
+
const claimedHash = createHash("sha256")
|
|
856
|
+
.update(actualContent, "utf8")
|
|
857
|
+
.digest("hex");
|
|
858
|
+
|
|
859
|
+
const reserveHandler = makeReserveHandler(attachments);
|
|
860
|
+
const reserveReq = makeReq({
|
|
861
|
+
method: "POST",
|
|
862
|
+
body: JSON.stringify({
|
|
863
|
+
mimeType: "text/plain",
|
|
864
|
+
fileName: "sizemismatch.txt",
|
|
865
|
+
clientHash: claimedHash,
|
|
866
|
+
sizeBytes: declaredSize,
|
|
867
|
+
}),
|
|
868
|
+
});
|
|
869
|
+
const reserveRes = makeRes();
|
|
870
|
+
await reserveHandler(reserveReq, reserveRes);
|
|
871
|
+
await waitFor(reserveRes);
|
|
872
|
+
expect(reserveRes.statusCode).toBe(201);
|
|
873
|
+
const { reservationId } = JSON.parse(
|
|
874
|
+
reserveRes._body.toString("utf8"),
|
|
875
|
+
) as { reservationId: string };
|
|
876
|
+
|
|
877
|
+
const uploadHandler = makeUploadHandler(attachments);
|
|
878
|
+
const uploadReq = makeReq({
|
|
879
|
+
method: "PUT",
|
|
880
|
+
params: { reservationId },
|
|
881
|
+
body: actualContent,
|
|
882
|
+
});
|
|
883
|
+
const uploadRes = makeRes();
|
|
884
|
+
await uploadHandler(uploadReq, uploadRes);
|
|
885
|
+
await waitFor(uploadRes);
|
|
886
|
+
|
|
887
|
+
expect(uploadRes.statusCode).toBe(422);
|
|
888
|
+
const body = JSON.parse(uploadRes._body.toString("utf8")) as {
|
|
889
|
+
error: string;
|
|
890
|
+
declared: number;
|
|
891
|
+
actual: number;
|
|
892
|
+
};
|
|
893
|
+
expect(body.error).toBe("size_mismatch");
|
|
894
|
+
expect(body.declared).toBe(declaredSize);
|
|
895
|
+
expect(typeof body.actual).toBe("number");
|
|
896
|
+
});
|
|
897
|
+
});
|
|
898
|
+
|
|
899
|
+
describe("pending state: stat and download 202 responses", () => {
|
|
900
|
+
async function createPendingReservation(
|
|
901
|
+
content: string,
|
|
902
|
+
): Promise<{ hash: string }> {
|
|
903
|
+
const { createHash } = await import("node:crypto");
|
|
904
|
+
const hash = createHash("sha256").update(content, "utf8").digest("hex");
|
|
905
|
+
|
|
906
|
+
const reserveHandler = makeReserveHandler(attachments);
|
|
907
|
+
const req = makeReq({
|
|
908
|
+
method: "POST",
|
|
909
|
+
body: JSON.stringify({
|
|
910
|
+
mimeType: "text/plain",
|
|
911
|
+
fileName: "pending.txt",
|
|
912
|
+
clientHash: hash,
|
|
913
|
+
sizeBytes: Buffer.byteLength(content, "utf8"),
|
|
914
|
+
}),
|
|
915
|
+
});
|
|
916
|
+
const res = makeRes();
|
|
917
|
+
await reserveHandler(req, res);
|
|
918
|
+
await waitFor(res);
|
|
919
|
+
expect(res.statusCode).toBe(201);
|
|
920
|
+
return { hash };
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
it("HEAD stat returns 202 with Retry-After and Attachment-Pending for pending hash", async () => {
|
|
924
|
+
const { hash } = await createPendingReservation("pending content data");
|
|
925
|
+
|
|
926
|
+
const handler = makeStatHandler(attachments);
|
|
927
|
+
const req = makeReq({ method: "HEAD", params: { hash } });
|
|
928
|
+
const res = makeRes();
|
|
929
|
+
await handler(req, res);
|
|
930
|
+
await waitFor(res);
|
|
931
|
+
|
|
932
|
+
expect(res.statusCode).toBe(202);
|
|
933
|
+
expect(res.getHeader("retry-after")).toBeTruthy();
|
|
934
|
+
const pendingHeader = res.getHeader("attachment-pending") as string;
|
|
935
|
+
expect(pendingHeader).toBeTruthy();
|
|
936
|
+
const parsed = JSON.parse(pendingHeader) as {
|
|
937
|
+
expiresAtUtc: string;
|
|
938
|
+
mimeType: string;
|
|
939
|
+
fileName: string;
|
|
940
|
+
sizeBytes: number;
|
|
941
|
+
};
|
|
942
|
+
expect(typeof parsed.expiresAtUtc).toBe("string");
|
|
943
|
+
expect(parsed.mimeType).toBe("text/plain");
|
|
944
|
+
expect(parsed.fileName).toBe("pending.txt");
|
|
945
|
+
expect(typeof parsed.sizeBytes).toBe("number");
|
|
946
|
+
});
|
|
947
|
+
|
|
948
|
+
it("HEAD stat 202 for pending hash does not set Content-Disposition or Attachment-Metadata", async () => {
|
|
949
|
+
const { hash } = await createPendingReservation("no metadata content");
|
|
950
|
+
|
|
951
|
+
const handler = makeStatHandler(attachments);
|
|
952
|
+
const req = makeReq({ method: "HEAD", params: { hash } });
|
|
953
|
+
const res = makeRes();
|
|
954
|
+
await handler(req, res);
|
|
955
|
+
await waitFor(res);
|
|
956
|
+
|
|
957
|
+
expect(res.statusCode).toBe(202);
|
|
958
|
+
expect(res.getHeader("content-disposition")).toBeFalsy();
|
|
959
|
+
expect(res.getHeader("attachment-metadata")).toBeFalsy();
|
|
960
|
+
});
|
|
961
|
+
|
|
962
|
+
it("GET download returns 202 with Retry-After and Attachment-Pending for pending hash", async () => {
|
|
963
|
+
const { hash } = await createPendingReservation("download pending data");
|
|
964
|
+
|
|
965
|
+
const handler = makeDownloadHandler(attachments);
|
|
966
|
+
const req = makeReq({ method: "GET", params: { hash } });
|
|
967
|
+
const res = makeRes();
|
|
968
|
+
await handler(req, res);
|
|
969
|
+
await waitFor(res);
|
|
970
|
+
|
|
971
|
+
expect(res.statusCode).toBe(202);
|
|
972
|
+
expect(res.getHeader("retry-after")).toBeTruthy();
|
|
973
|
+
const pendingHeader = res.getHeader("attachment-pending") as string;
|
|
974
|
+
expect(pendingHeader).toBeTruthy();
|
|
975
|
+
const parsed = JSON.parse(pendingHeader) as {
|
|
976
|
+
expiresAtUtc: string;
|
|
977
|
+
};
|
|
978
|
+
expect(typeof parsed.expiresAtUtc).toBe("string");
|
|
979
|
+
});
|
|
980
|
+
|
|
981
|
+
it("GET download 202 for pending hash must NOT set Content-Disposition or Attachment-Metadata (prevents zero-byte corruption)", async () => {
|
|
982
|
+
// Critical pin: if 202 accidentally set Content-Disposition and
|
|
983
|
+
// Attachment-Metadata, a receiving RemoteAttachmentStore would interpret
|
|
984
|
+
// the response as a successful zero-byte attachment.
|
|
985
|
+
const { hash } = await createPendingReservation("check-headers content");
|
|
986
|
+
|
|
987
|
+
const handler = makeDownloadHandler(attachments);
|
|
988
|
+
const req = makeReq({ method: "GET", params: { hash } });
|
|
989
|
+
const res = makeRes();
|
|
990
|
+
await handler(req, res);
|
|
991
|
+
await waitFor(res);
|
|
992
|
+
|
|
993
|
+
expect(res.statusCode).toBe(202);
|
|
994
|
+
expect(res.getHeader("content-disposition")).toBeFalsy();
|
|
995
|
+
expect(res.getHeader("attachment-metadata")).toBeFalsy();
|
|
996
|
+
});
|
|
997
|
+
|
|
998
|
+
it("GET download 202 body is empty (not a zero-byte attachment)", async () => {
|
|
999
|
+
const { hash } = await createPendingReservation("empty body assertion");
|
|
1000
|
+
|
|
1001
|
+
const handler = makeDownloadHandler(attachments);
|
|
1002
|
+
const req = makeReq({ method: "GET", params: { hash } });
|
|
1003
|
+
const res = makeRes();
|
|
1004
|
+
await handler(req, res);
|
|
1005
|
+
await waitFor(res);
|
|
1006
|
+
|
|
1007
|
+
expect(res.statusCode).toBe(202);
|
|
1008
|
+
expect(res._body.length).toBe(0);
|
|
1009
|
+
});
|
|
1010
|
+
});
|
|
1011
|
+
|
|
704
1012
|
describe("validation and header encoding", () => {
|
|
1013
|
+
it("parseReserveOptions accepts valid clientHash and sizeBytes", () => {
|
|
1014
|
+
const opts = parseReserveOptions({
|
|
1015
|
+
mimeType: "text/plain",
|
|
1016
|
+
fileName: "file.txt",
|
|
1017
|
+
clientHash: "a".repeat(64),
|
|
1018
|
+
sizeBytes: 1024,
|
|
1019
|
+
});
|
|
1020
|
+
expect(opts).not.toBeNull();
|
|
1021
|
+
expect(opts!.clientHash).toBe("a".repeat(64));
|
|
1022
|
+
expect(opts!.sizeBytes).toBe(1024);
|
|
1023
|
+
});
|
|
1024
|
+
|
|
1025
|
+
it("parseReserveOptions rejects clientHash with wrong length", () => {
|
|
1026
|
+
expect(
|
|
1027
|
+
parseReserveOptions({
|
|
1028
|
+
mimeType: "text/plain",
|
|
1029
|
+
fileName: "file.txt",
|
|
1030
|
+
clientHash: "abc",
|
|
1031
|
+
sizeBytes: 100,
|
|
1032
|
+
}),
|
|
1033
|
+
).toBeNull();
|
|
1034
|
+
});
|
|
1035
|
+
|
|
1036
|
+
it("parseReserveOptions rejects clientHash with non-hex characters", () => {
|
|
1037
|
+
expect(
|
|
1038
|
+
parseReserveOptions({
|
|
1039
|
+
mimeType: "text/plain",
|
|
1040
|
+
fileName: "file.txt",
|
|
1041
|
+
clientHash: "z".repeat(64),
|
|
1042
|
+
sizeBytes: 100,
|
|
1043
|
+
}),
|
|
1044
|
+
).toBeNull();
|
|
1045
|
+
});
|
|
1046
|
+
|
|
1047
|
+
it("parseReserveOptions rejects when clientHash is present but sizeBytes is absent", () => {
|
|
1048
|
+
expect(
|
|
1049
|
+
parseReserveOptions({
|
|
1050
|
+
mimeType: "text/plain",
|
|
1051
|
+
fileName: "file.txt",
|
|
1052
|
+
clientHash: "a".repeat(64),
|
|
1053
|
+
}),
|
|
1054
|
+
).toBeNull();
|
|
1055
|
+
});
|
|
1056
|
+
|
|
1057
|
+
it("parseReserveOptions ignores sizeBytes when clientHash is absent (legacy compat)", () => {
|
|
1058
|
+
expect(
|
|
1059
|
+
parseReserveOptions({
|
|
1060
|
+
mimeType: "text/plain",
|
|
1061
|
+
fileName: "file.txt",
|
|
1062
|
+
sizeBytes: 100,
|
|
1063
|
+
}),
|
|
1064
|
+
).toEqual({
|
|
1065
|
+
mimeType: "text/plain",
|
|
1066
|
+
fileName: "file.txt",
|
|
1067
|
+
extension: null,
|
|
1068
|
+
});
|
|
1069
|
+
});
|
|
1070
|
+
|
|
1071
|
+
it("parseReserveOptions rejects sizeBytes of zero", () => {
|
|
1072
|
+
expect(
|
|
1073
|
+
parseReserveOptions({
|
|
1074
|
+
mimeType: "text/plain",
|
|
1075
|
+
fileName: "file.txt",
|
|
1076
|
+
clientHash: "a".repeat(64),
|
|
1077
|
+
sizeBytes: 0,
|
|
1078
|
+
}),
|
|
1079
|
+
).toBeNull();
|
|
1080
|
+
});
|
|
1081
|
+
|
|
1082
|
+
it("parseReserveOptions rejects negative sizeBytes", () => {
|
|
1083
|
+
expect(
|
|
1084
|
+
parseReserveOptions({
|
|
1085
|
+
mimeType: "text/plain",
|
|
1086
|
+
fileName: "file.txt",
|
|
1087
|
+
clientHash: "a".repeat(64),
|
|
1088
|
+
sizeBytes: -1,
|
|
1089
|
+
}),
|
|
1090
|
+
).toBeNull();
|
|
1091
|
+
});
|
|
1092
|
+
|
|
1093
|
+
it("parseReserveOptions rejects non-integer sizeBytes", () => {
|
|
1094
|
+
expect(
|
|
1095
|
+
parseReserveOptions({
|
|
1096
|
+
mimeType: "text/plain",
|
|
1097
|
+
fileName: "file.txt",
|
|
1098
|
+
clientHash: "a".repeat(64),
|
|
1099
|
+
sizeBytes: 1.5,
|
|
1100
|
+
}),
|
|
1101
|
+
).toBeNull();
|
|
1102
|
+
});
|
|
1103
|
+
|
|
1104
|
+
it("parseReserveOptions rejects string sizeBytes", () => {
|
|
1105
|
+
expect(
|
|
1106
|
+
parseReserveOptions({
|
|
1107
|
+
mimeType: "text/plain",
|
|
1108
|
+
fileName: "file.txt",
|
|
1109
|
+
clientHash: "a".repeat(64),
|
|
1110
|
+
sizeBytes: "1024",
|
|
1111
|
+
}),
|
|
1112
|
+
).toBeNull();
|
|
1113
|
+
});
|
|
1114
|
+
|
|
1115
|
+
it("parseReserveOptions normalizes uppercase clientHash to lowercase", () => {
|
|
1116
|
+
const opts = parseReserveOptions({
|
|
1117
|
+
mimeType: "text/plain",
|
|
1118
|
+
fileName: "file.txt",
|
|
1119
|
+
clientHash: "A".repeat(64),
|
|
1120
|
+
sizeBytes: 1,
|
|
1121
|
+
});
|
|
1122
|
+
expect(opts).not.toBeNull();
|
|
1123
|
+
expect(opts!.clientHash).toBe("a".repeat(64));
|
|
1124
|
+
});
|
|
1125
|
+
|
|
705
1126
|
it("parseReserveOptions rejects fileName with CR/LF", () => {
|
|
706
1127
|
expect(
|
|
707
1128
|
parseReserveOptions({
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import { PGlite } from "@electric-sql/pglite";
|
|
2
|
+
import type { API } from "@powerhousedao/reactor-api";
|
|
3
|
+
import { createHttpAdapter } from "@powerhousedao/reactor-api";
|
|
4
|
+
import {
|
|
5
|
+
AttachmentBuilder,
|
|
6
|
+
type AttachmentBuildResult,
|
|
7
|
+
createRemoteAttachmentService,
|
|
8
|
+
} from "@powerhousedao/reactor-attachments";
|
|
9
|
+
import type { IRenown } from "@renown/sdk/node";
|
|
10
|
+
import { Kysely } from "kysely";
|
|
11
|
+
import { PGliteDialect } from "kysely-pglite-dialect";
|
|
12
|
+
import { mkdtemp, rm } from "node:fs/promises";
|
|
13
|
+
import type { Server } from "node:http";
|
|
14
|
+
import { tmpdir } from "node:os";
|
|
15
|
+
import { join } from "node:path";
|
|
16
|
+
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
|
|
17
|
+
import { registerAttachmentRoutes } from "../../src/attachments/index.js";
|
|
18
|
+
import { deriveAttachmentServiceConfig } from "../../src/server.mjs";
|
|
19
|
+
|
|
20
|
+
describe("deriveAttachmentServiceConfig", () => {
|
|
21
|
+
const ORIGINAL_PUBLIC_URL = process.env.PH_SWITCHBOARD_PUBLIC_URL;
|
|
22
|
+
|
|
23
|
+
afterEach(() => {
|
|
24
|
+
if (ORIGINAL_PUBLIC_URL === undefined) {
|
|
25
|
+
delete process.env.PH_SWITCHBOARD_PUBLIC_URL;
|
|
26
|
+
} else {
|
|
27
|
+
process.env.PH_SWITCHBOARD_PUBLIC_URL = ORIGINAL_PUBLIC_URL;
|
|
28
|
+
}
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("defaults to http://localhost:${port} with no auth when renown is null", () => {
|
|
32
|
+
delete process.env.PH_SWITCHBOARD_PUBLIC_URL;
|
|
33
|
+
const config = deriveAttachmentServiceConfig({}, 4001, null);
|
|
34
|
+
expect(config.remoteUrl).toBe("http://localhost:4001");
|
|
35
|
+
expect(config.jwtHandler).toBeUndefined();
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("uses https when options.https is set", () => {
|
|
39
|
+
delete process.env.PH_SWITCHBOARD_PUBLIC_URL;
|
|
40
|
+
const config = deriveAttachmentServiceConfig({ https: true }, 4443, null);
|
|
41
|
+
expect(config.remoteUrl).toBe("https://localhost:4443");
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("prefers PH_SWITCHBOARD_PUBLIC_URL over the localhost default", () => {
|
|
45
|
+
process.env.PH_SWITCHBOARD_PUBLIC_URL = "https://sb.example.com";
|
|
46
|
+
const config = deriveAttachmentServiceConfig({}, 4001, null);
|
|
47
|
+
expect(config.remoteUrl).toBe("https://sb.example.com");
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("prefers the explicit attachmentServiceUrl option above all else", () => {
|
|
51
|
+
process.env.PH_SWITCHBOARD_PUBLIC_URL = "https://sb.example.com";
|
|
52
|
+
const config = deriveAttachmentServiceConfig(
|
|
53
|
+
{ attachmentServiceUrl: "https://override.example.com" },
|
|
54
|
+
4001,
|
|
55
|
+
null,
|
|
56
|
+
);
|
|
57
|
+
expect(config.remoteUrl).toBe("https://override.example.com");
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("builds a jwtHandler from renown that scopes the token to the request url", async () => {
|
|
61
|
+
const calls: Array<{ expiresIn: number; aud: string }> = [];
|
|
62
|
+
const renown = {
|
|
63
|
+
user: { address: "0xabc" },
|
|
64
|
+
getBearerToken: (opts: { expiresIn: number; aud: string }) => {
|
|
65
|
+
calls.push(opts);
|
|
66
|
+
return Promise.resolve("tok-for-" + opts.aud);
|
|
67
|
+
},
|
|
68
|
+
} as unknown as IRenown;
|
|
69
|
+
|
|
70
|
+
const { jwtHandler } = deriveAttachmentServiceConfig({}, 4001, renown);
|
|
71
|
+
expect(jwtHandler).toBeDefined();
|
|
72
|
+
const token = await jwtHandler!("http://localhost:4001/attachments/x");
|
|
73
|
+
expect(token).toBe("tok-for-http://localhost:4001/attachments/x");
|
|
74
|
+
expect(calls[0]).toEqual({
|
|
75
|
+
expiresIn: 10,
|
|
76
|
+
aud: "http://localhost:4001/attachments/x",
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("returns undefined token when renown has no user identity", async () => {
|
|
81
|
+
const renown = {
|
|
82
|
+
user: null,
|
|
83
|
+
getBearerToken: () => Promise.resolve("should-not-be-called"),
|
|
84
|
+
} as unknown as IRenown;
|
|
85
|
+
|
|
86
|
+
const { jwtHandler } = deriveAttachmentServiceConfig({}, 4001, renown);
|
|
87
|
+
expect(jwtHandler).toBeDefined();
|
|
88
|
+
await expect(
|
|
89
|
+
jwtHandler!("http://localhost:4001/x"),
|
|
90
|
+
).resolves.toBeUndefined();
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
describe("attachment service built from deriveAttachmentServiceConfig round-trips", () => {
|
|
95
|
+
let attachments: AttachmentBuildResult;
|
|
96
|
+
let kysely: Kysely<unknown>;
|
|
97
|
+
let storagePath: string;
|
|
98
|
+
let server: Server;
|
|
99
|
+
let port: number;
|
|
100
|
+
|
|
101
|
+
beforeAll(async () => {
|
|
102
|
+
const pglite = new PGlite();
|
|
103
|
+
kysely = new Kysely<unknown>({ dialect: new PGliteDialect(pglite) });
|
|
104
|
+
storagePath = await mkdtemp(join(tmpdir(), "switchboard-attach-cfg-"));
|
|
105
|
+
attachments = await new AttachmentBuilder(kysely, storagePath).build();
|
|
106
|
+
|
|
107
|
+
const { adapter } = await createHttpAdapter("express");
|
|
108
|
+
adapter.setupMiddleware({});
|
|
109
|
+
registerAttachmentRoutes({
|
|
110
|
+
httpAdapter: adapter,
|
|
111
|
+
attachments,
|
|
112
|
+
authService: undefined,
|
|
113
|
+
} as unknown as API);
|
|
114
|
+
|
|
115
|
+
server = await adapter.listen(0);
|
|
116
|
+
const addr = server.address();
|
|
117
|
+
if (!addr || typeof addr === "string") throw new Error("no addr");
|
|
118
|
+
port = addr.port;
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
afterAll(async () => {
|
|
122
|
+
await new Promise<void>((resolve) => server.close(() => resolve()));
|
|
123
|
+
await kysely.destroy();
|
|
124
|
+
await rm(storagePath, { recursive: true, force: true });
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it("reserve -> upload -> get works against the live routes", async () => {
|
|
128
|
+
const config = deriveAttachmentServiceConfig(
|
|
129
|
+
{ attachmentServiceUrl: `http://127.0.0.1:${port}` },
|
|
130
|
+
port,
|
|
131
|
+
null,
|
|
132
|
+
);
|
|
133
|
+
const service = createRemoteAttachmentService(config);
|
|
134
|
+
|
|
135
|
+
const upload = await service.reserve({
|
|
136
|
+
mimeType: "text/plain",
|
|
137
|
+
fileName: "hello.txt",
|
|
138
|
+
extension: "txt",
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
const payload = "switchboard attachment service";
|
|
142
|
+
const bytes = new TextEncoder().encode(payload);
|
|
143
|
+
const stream = new ReadableStream<Uint8Array>({
|
|
144
|
+
start(controller) {
|
|
145
|
+
controller.enqueue(bytes);
|
|
146
|
+
controller.close();
|
|
147
|
+
},
|
|
148
|
+
});
|
|
149
|
+
const result = await upload.send(stream);
|
|
150
|
+
expect(result.header.sizeBytes).toBe(bytes.byteLength);
|
|
151
|
+
|
|
152
|
+
const got = await service.get(result.ref);
|
|
153
|
+
expect(got.header.mimeType).toBe("text/plain");
|
|
154
|
+
const reader = got.body.getReader();
|
|
155
|
+
const chunks: Uint8Array[] = [];
|
|
156
|
+
for (;;) {
|
|
157
|
+
const { done, value } = await reader.read();
|
|
158
|
+
if (done) break;
|
|
159
|
+
chunks.push(value);
|
|
160
|
+
}
|
|
161
|
+
const total = chunks.reduce((n, c) => n + c.byteLength, 0);
|
|
162
|
+
const merged = new Uint8Array(total);
|
|
163
|
+
let off = 0;
|
|
164
|
+
for (const c of chunks) {
|
|
165
|
+
merged.set(c, off);
|
|
166
|
+
off += c.byteLength;
|
|
167
|
+
}
|
|
168
|
+
expect(new TextDecoder().decode(merged)).toBe(payload);
|
|
169
|
+
});
|
|
170
|
+
});
|